Files
meshcore-analyzer/test-mqtt-status-panel.js
T
Kpa-clawbotandopenclaw-bot efd66ea3f5 feat(mqtt): per-source status endpoint + Observers panel (#1682)
## Summary

Adds MQTT source status visibility per #1043 acceptance criteria:

- **Ingestor:** per-source counter registry
(`cmd/ingestor/source_status.go`) tracking `connected`,
`lastConnectUnix`, `lastDisconnectUnix`, `lastPacketUnix`,
`connectCount`, `disconnectCount`, `packetsTotal`, `packetsLast5m`
(sliding 5-min window via per-second buckets keyed by unix second — no
stale-leak), `lastError`. Wired at the existing OnConnect /
ConnectionLost / DefaultPublish callsites alongside the liveness
watchdog. Idempotent registration so counters survive reconnects.
Snapshot emitted in the existing stats file under `source_statuses`
(additive, `omitempty`).
- **Backend:** new `GET /api/mqtt/status` handler reads the ingestor
stats file and returns the per-source list. **Broker passwords are
masked** via a regex over the `scheme://user:pass@host` form (covers
mqtt/mqtts/tcp/ssl/ws/wss). Mask is also applied to `lastError` as
defense-in-depth (broker libs occasionally quote the failing URL).
OpenAPI completeness gate satisfied with a `routeDescriptions` entry.
- **Frontend:** small self-contained panel
(`public/mqtt-status-panel.js`) mounted above the Observers table.
Auto-refreshes every 10s, color-codes each row (green = connected +
recent packet, yellow = connected idle, red = disconnected), and tears
down its timer on SPA route change.

## TDD

- Red commit `f19a93b5` — stub `/api/mqtt/status` handler + assertion
test that the broker password is `****`-redacted. Test fails on the
assertion (handler passes the URL through verbatim). Compile-clean —
assertion-fail, not build-fail.
- Green commit `77042e41` — `maskBrokerURL` helper + table-driven unit
tests across all schemes + handler rewires to mask both `Broker` and
`LastError`.
- Subsequent commits land the ingestor wiring and the frontend panel.

## Tests

```
$ cd cmd/server && go test -run 'TestMqttStatus|TestMaskBrokerURL' -v ./...
PASS: TestMqttStatus_MasksBrokerPassword
PASS: TestMqttStatus_EmptyWhenNoStatsFile
PASS: TestMaskBrokerURL_Patterns (10 subtests)

$ cd cmd/ingestor && go test -run 'TestSourceStatus|TestSnapshotSourceStatuses' -v ./...
PASS: TestSourceStatus_BasicLifecycle
PASS: TestSourceStatus_Disconnect
PASS: TestSnapshotSourceStatuses_ReturnsAll

$ node test-mqtt-status-panel.js
7 passed, 0 failed
```

Full `go test ./...` clean in both `cmd/server` and `cmd/ingestor`.

## Preflight overrides

- `cross-stack`: justified — issue #1043 is intrinsically full-stack
(ingestor stats → server endpoint → observers panel). Per-stack split
would land an unreachable endpoint or a fetch with no backend.
- `check-xss-sinks` (public/mqtt-status-panel.js:55): justified — the
flagged `innerHTML=` is a fully-static literal (empty-state placeholder,
no payload data interpolated). All payload-bearing `innerHTML=` sites in
this file run through `escapeHTML` (defined in the same file); the test
`renderPanel never echoes a plaintext password (defense-in-depth)`
exercises the rendered HTML against payload strings.

## Acceptance criteria

- [x] `/api/mqtt/status` returns per-source connection state —
`cmd/server/mqtt_status.go`
- [x] UI panel shows all configured sources with live status —
`public/mqtt-status-panel.js`
- [x] Connection state updates on reconnect/disconnect events —
`MarkConnect` / `MarkDisconnect` wired in `cmd/ingestor/main.go`
- [x] Broker URLs don't expose passwords in the API response —
`maskBrokerURL` + 13 test cases
- [x] Works with 1-N sources — registry is keyed per-source, snapshot
iterates the map

**Partial fix for #1043** — per-packet `mqtt_source` attribution (the
issue's "Follow-up" section) is **deferred** per the `mc-bot-triaged:v1`
triage and the autofix comment ("Per-packet attribution deferred to
follow-up issue"). That work requires a new observation-row column and
DB schema migration, both explicitly out of scope for this PR.

Refs #1043

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-12 08:11:02 -07:00

162 lines
7.1 KiB
JavaScript

/* test-mqtt-status-panel.js (#1043)
*
* DOM-grep test for public/mqtt-status-panel.js. Loads the module into a
* VM sandbox (no jsdom), stubs the fetch + container, drives renderPanel
* directly with a mocked /api/mqtt/status payload, then asserts:
* - one row per source
* - a row with no recent packet but `connected:true` is classified yellow
* - a disconnected source is classified red
* - a connected source with a recent packet is classified green
* - the masked broker URL is rendered (server is responsible for
* masking; the test verifies the panel does not re-leak it)
* - no plaintext password appears in the rendered HTML
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(` ✅ ${name}`); }
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
}
const src = fs.readFileSync(require('path').resolve(__dirname, 'public/mqtt-status-panel.js'), 'utf8');
const ctx = {
window: {},
module: { exports: {} },
setInterval, clearInterval, setTimeout, clearTimeout,
Promise
};
vm.createContext(ctx);
vm.runInContext(src, ctx);
const Panel = ctx.window.MqttStatusPanel;
assert.ok(Panel, 'window.MqttStatusPanel must be exposed after loading mqtt-status-panel.js');
console.log('mqtt-status-panel:');
test('classifySource: connected + recent packet → green', () => {
const now = 1_700_000_000_000;
const state = Panel.classifySource({ connected: true, lastPacketUnix: now / 1000 - 30 }, now);
assert.strictEqual(state, 'green');
});
test('classifySource: connected, no recent packets → yellow', () => {
const now = 1_700_000_000_000;
const state = Panel.classifySource({ connected: true, lastPacketUnix: now / 1000 - 9 * 60 }, now);
assert.strictEqual(state, 'yellow');
});
test('classifySource: disconnected → red (regardless of packet age)', () => {
const now = 1_700_000_000_000;
const state = Panel.classifySource({ connected: false, lastPacketUnix: now / 1000 - 1 }, now);
assert.strictEqual(state, 'red');
});
test('renderPanel emits one <tr> per source with the masked broker URL', () => {
const container = { innerHTML: '' };
const now = 1_700_000_000_000;
const payload = {
sampleAt: '2026-06-12T12:30:00Z',
sources: [
{ name: 'local', broker: 'mqtt://obsuser:****@broker.example.com:1883',
connected: true, lastPacketUnix: now / 1000 - 10, packetsTotal: 999, packetsLast5m: 42, disconnectCount: 0 },
{ name: 'cascadia', broker: 'mqtts://cascadia.example.com:8883',
connected: true, lastPacketUnix: now / 1000 - 9 * 60, packetsTotal: 50, packetsLast5m: 0, disconnectCount: 2 },
{ name: 'dead', broker: 'mqtt://dead.example.com:1883',
connected: false, lastPacketUnix: 0, packetsTotal: 0, packetsLast5m: 0, disconnectCount: 7 }
]
};
Panel.renderPanel(container, payload, now);
const html = container.innerHTML;
// 3 <tr data-source-name="...">
const rowMatches = html.match(/<tr data-source-name=/g) || [];
assert.strictEqual(rowMatches.length, 3, `expected 3 rows, got ${rowMatches.length}: ${html}`);
// State data attributes wired through.
assert.ok(/data-source-name="local"[^>]*data-state="green"/.test(html), 'local row must be green');
assert.ok(/data-source-name="cascadia"[^>]*data-state="yellow"/.test(html), 'cascadia row must be yellow (connected, idle)');
assert.ok(/data-source-name="dead"[^>]*data-state="red"/.test(html), 'dead row must be red (disconnected)');
// Masked broker URL rendered verbatim; password placeholder visible.
assert.ok(html.includes('mqtt://obsuser:****@broker.example.com:1883'),
'masked broker URL must be present in rendered HTML');
// Counts rendered.
assert.ok(html.includes('999'), 'packetsTotal must be rendered');
assert.ok(html.includes('42'), 'packetsLast5m must be rendered');
// Status dots must use CSS variables, not hardcoded hex (#1682 adversarial r1).
// Regression: hex literals (#3fbf3f / #e4a800 / #e04848) bypass theming.
assert.ok(html.includes('background:var(--status-green)') || html.includes('background: var(--status-green)'),
'green dot must use var(--status-green): ' + html);
assert.ok(html.includes('background:var(--status-yellow)') || html.includes('background: var(--status-yellow)'),
'yellow dot must use var(--status-yellow): ' + html);
assert.ok(html.includes('background:var(--status-red)') || html.includes('background: var(--status-red)'),
'red dot must use var(--status-red): ' + html);
assert.ok(!/#3fbf3f|#e4a800|#e04848/i.test(html),
'panel must not emit hardcoded hex colors for status dots: ' + html);
});
test('renderPanel never echoes a plaintext password (defense-in-depth)', () => {
// The panel only renders what the server sends. If the server fails
// to mask, the panel must NOT introduce its own leak — and a smoke
// test here would catch a regression that adds a debug data-* with
// the unmasked URL.
const container = { innerHTML: '' };
const now = 1_700_000_000_000;
// Already-masked input — the server-side regex is tested in Go.
Panel.renderPanel(container, { sources: [
{ name: 'local', broker: 'mqtt://obsuser:****@host:1883', connected: true, lastPacketUnix: now / 1000 - 5,
packetsTotal: 1, packetsLast5m: 1, disconnectCount: 0 }
]}, now);
assert.ok(!/hunter2|password=|p4ssw0rd/i.test(container.innerHTML),
'plaintext password substrings must not appear in rendered HTML');
});
test('renderPanel handles empty source list with placeholder text', () => {
const container = { innerHTML: '' };
Panel.renderPanel(container, { sources: [] }, Date.now());
assert.ok(container.innerHTML.includes('No MQTT sources'),
`empty state should render placeholder; got: ${container.innerHTML}`);
});
test('mount fetches /api/mqtt/status and renders a row from the response', async () => {
const container = { innerHTML: '' };
const now = Date.now();
const fakePayload = {
sources: [{
name: 'local',
broker: 'mqtt://obsuser:****@broker.example.com:1883',
connected: true,
lastPacketUnix: Math.floor(now / 1000) - 5,
packetsTotal: 3,
packetsLast5m: 3,
disconnectCount: 0
}]
};
let fetchedURL = null;
const fakeFetch = (url) => {
fetchedURL = url;
return Promise.resolve({ json: () => Promise.resolve(fakePayload) });
};
const teardown = Panel.mount(container, { fetchImpl: fakeFetch, intervalMs: 60_000 });
// mount's initial tick is async; spin until container is populated.
await new Promise((resolve) => setTimeout(resolve, 50));
teardown();
assert.strictEqual(fetchedURL, '/api/mqtt/status', 'mount must hit /api/mqtt/status');
assert.ok(container.innerHTML.includes('mqtt://obsuser:****@broker.example.com:1883'),
`panel did not render after fetch; html=${container.innerHTML}`);
});
(async () => {
// Tiny sleep so the async test above resolves before the process exits.
await new Promise((r) => setTimeout(r, 100));
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})();