Files
meshcore-analyzer/cmd/server/mqtt_status_test.go
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

143 lines
5.2 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMqttStatus_MasksBrokerPassword (#1043) asserts the /api/mqtt/status
// handler never leaks the broker password embedded in a mqtt:// URL.
// Operators viewing the API response (or the Observers panel that
// consumes it) must see `****` in place of the inline credential.
//
// Test shape: write a stub ingestor stats file with one source whose
// broker URL contains a plaintext password, invoke the handler, assert
// the JSON response (a) contains the username + host, (b) does NOT
// contain the password substring.
func TestMqttStatus_MasksBrokerPassword(t *testing.T) {
const password = "hunter2supersecret"
const rawBroker = "mqtt://obsuser:" + password + "@broker.example.com:1883"
tmp := t.TempDir()
statsPath := filepath.Join(tmp, "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
// Stub stats file: one MQTT source with a credentialed broker URL.
stub := map[string]any{
"sampledAt": "2026-06-12T12:30:00Z",
"source_statuses": []map[string]any{{
"name": "local",
"broker": rawBroker,
"connected": true,
"lastPacketUnix": 1717977000,
"connectCount": 1,
"disconnectCount": 0,
"packetsTotal": 42,
"packetsLast5m": 7,
}},
}
data, err := json.Marshal(stub)
if err != nil {
t.Fatalf("marshal stub: %v", err)
}
if err := os.WriteFile(statsPath, data, 0o600); err != nil {
t.Fatalf("write stub: %v", err)
}
srv := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/mqtt/status", nil)
rec := httptest.NewRecorder()
srv.handleMqttStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
t.Logf("response body: %s", body)
if strings.Contains(body, password) {
t.Errorf("response leaks broker password %q in body: %s", password, body)
}
// Sanity: the response still identifies the source by name + host.
if !strings.Contains(body, "broker.example.com") {
t.Errorf("response missing broker host: %s", body)
}
if !strings.Contains(body, "obsuser") {
t.Errorf("response missing broker username: %s", body)
}
// Mask token must be present so operators can tell credentials were
// redacted vs the broker URL never having a password to begin with.
if !strings.Contains(body, "****") {
t.Errorf("response missing redaction marker '****': %s", body)
}
}
// TestMqttStatus_EmptyWhenNoStatsFile asserts the handler returns an empty
// list (200 OK) when the ingestor stats file is missing — the UI panel
// renders a "no data yet" state in that case.
func TestMqttStatus_EmptyWhenNoStatsFile(t *testing.T) {
tmp := t.TempDir()
t.Setenv("CORESCOPE_INGESTOR_STATS", filepath.Join(tmp, "does-not-exist.json"))
srv := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/mqtt/status", nil)
rec := httptest.NewRecorder()
srv.handleMqttStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp MqttStatusResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v; body=%s", err, rec.Body.String())
}
if len(resp.Sources) != 0 {
t.Errorf("Sources len = %d, want 0", len(resp.Sources))
}
}
// TestMaskBrokerURL_Patterns is a unit table-driven test for the masking
// helper. Kept separate from the handler test so a regression in the
// regex localizes immediately.
func TestMaskBrokerURL_Patterns(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"plain mqtt no creds", "mqtt://broker.example.com:1883", "mqtt://broker.example.com:1883"},
{"mqtt with creds", "mqtt://u:secret@broker.example.com:1883", "mqtt://u:****@broker.example.com:1883"},
{"mqtts with creds", "mqtts://u:secret@broker.example.com:8883", "mqtts://u:****@broker.example.com:8883"},
{"tcp with creds", "tcp://u:p@host:1883", "tcp://u:****@host:1883"},
{"ssl with creds", "ssl://u:p@host:8883", "ssl://u:****@host:8883"},
{"ws with creds", "ws://u:p@host:8080/mqtt", "ws://u:****@host:8080/mqtt"},
{"wss with creds", "wss://u:p@host:443/mqtt", "wss://u:****@host:443/mqtt"},
{"uppercase scheme", "MQTT://u:p@host:1883", "MQTT://u:****@host:1883"},
{"empty", "", ""},
{"long password", "mqtt://obsuser:hunter2supersecretXYZ123@host:1883", "mqtt://obsuser:****@host:1883"},
{"no scheme bare host", "host:1883", "host:1883"},
// Adversarial r1 review (#1682): password contains @. The previous
// regex-only impl matched only up to the FIRST @, exposing "ss" as
// part of the path: "mqtt://user:****@ss@host". url.Parse handles
// this correctly because Go interprets the LAST @ as the userinfo
// boundary.
{"password with single @", "mqtt://user:p@ss@host:1883", "mqtt://user:****@host:1883"},
{"password with multiple @", "mqtt://user:p@ss@wo@host:1883", "mqtt://user:****@host:1883"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := maskBrokerURL(c.in)
if got != c.want {
t.Errorf("maskBrokerURL(%q) = %q, want %q", c.in, got, c.want)
}
// Inline secret must never survive.
if c.in != c.want && strings.Contains(got, "secret") {
t.Errorf("output still contains 'secret': %q", got)
}
})
}
}