package web import ( "context" "encoding/json" "io" "log/slog" "net/http/httptest" "strings" "testing" "time" "robot-market/test-mqtt-5/internal/config" "robot-market/test-mqtt-5/internal/events" ) func testViewer() *Viewer { c := config.Defaults() return New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second) } func TestHostOriginAndSecurityHeaders(t *testing.T) { v := testViewer() cases := []struct { name, host, origin string want int }{ {"local", "127.0.0.1:8080", "", 200}, {"same origin", "127.0.0.1:8080", "http://127.0.0.1:8080", 200}, {"foreign host", "evil.example", "", 403}, {"foreign origin", "127.0.0.1:8080", "http://evil.example", 403}, {"null origin", "127.0.0.1:8080", "null", 403}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest("GET", "http://127.0.0.1/", nil) r.Host = tc.host if tc.origin != "" { r.Header.Set("Origin", tc.origin) } w := httptest.NewRecorder() v.Handler().ServeHTTP(w, r) if w.Code != tc.want { t.Fatalf("got %d, want %d", w.Code, tc.want) } if w.Code == 200 { for key, want := range map[string]string{"Content-Security-Policy": "default-src 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"} { if !strings.Contains(w.Header().Get(key), want) { t.Fatalf("missing %s", key) } } } }) } } func TestAssetsUseTextOnlyRendering(t *testing.T) { script, err := assets.ReadFile("assets/app.js") if err != nil { t.Fatal(err) } if !strings.Contains(string(script), ".textContent") || strings.Contains(string(script), ".innerHTML") || strings.Contains(string(script), "insertAdjacentHTML") { t.Fatal("viewer must render event data with text-only DOM sinks") } } func TestSnapshotEscapesHostileValuesAndSeparatesCompatibility(t *testing.T) { v := testViewer() hostile := "" v.store.Add(events.Event{Kind: events.MessageReceived, Direction: events.In, Outcome: hostile, DeviceID: "sim-001", Topic: "robot-market/test/v1/sim-001/status"}) v.store.Add(events.Event{Kind: events.ConnectionUp, Direction: events.Internal, Outcome: "session_present=false; protocol=5", ClientID: "observer"}) r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/snapshot", nil) r.Host = "127.0.0.1:8080" w := httptest.NewRecorder() v.Handler().ServeHTTP(w, r) if w.Code != 200 || strings.Contains(w.Body.String(), hostile) || !strings.Contains(w.Body.String(), `\u003cscript\u003e`) { t.Fatalf("hostile content not JSON-escaped: %s", w.Body.String()) } var data Snapshot if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil { t.Fatal(err) } if data.Connection != "connected" || data.Compatibility[0].Status != "passed" || data.Compatibility[1].Status != "not_tested" || data.Compatibility[2].Status != "not_tested" { t.Fatal("connection/compatibility evidence conflated") } } func TestSSEReplaysGapAndRejectsBadCursor(t *testing.T) { v := testViewer() for i := 0; i < 10; i++ { v.store.Add(events.Event{Kind: events.MessageReceived}) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=1", nil).WithContext(ctx) r.Host = "127.0.0.1:8080" w := httptest.NewRecorder() done := make(chan struct{}) go func() { v.Handler().ServeHTTP(w, r); close(done) }() time.Sleep(10 * time.Millisecond) cancel() <-done body := w.Body.String() if w.Code != 200 || !strings.Contains(body, "event: gap") || !strings.Contains(body, `"reset_id":2`) || !strings.Contains(body, "id: 3") { t.Fatalf("replay gap missing: %s", body) } bad := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=bad", nil) bad.Host = "127.0.0.1:8080" out := httptest.NewRecorder() v.Handler().ServeHTTP(out, bad) if out.Code != 400 { t.Fatal("bad cursor accepted") } } func TestSSEViewerLimitAndDisconnectCleanup(t *testing.T) { c := config.Defaults() c.Web.MaxViewers = 1 v := New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second) request, stop := context.WithCancel(context.Background()) defer stop() req := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil).WithContext(request) req.Host = "127.0.0.1:8080" response := httptest.NewRecorder() done := make(chan struct{}) go func() { v.Handler().ServeHTTP(response, req); close(done) }() deadline := time.Now().Add(time.Second) for v.ActiveViewers() != 1 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } second := httptest.NewRecorder() secondRequest := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil) secondRequest.Host = "127.0.0.1:8080" v.Handler().ServeHTTP(second, secondRequest) if second.Code != 503 { t.Fatal("viewer limit not enforced") } stop() <-done deadline = time.Now().Add(time.Second) for v.ActiveViewers() != 0 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } if v.ActiveViewers() != 0 { t.Fatal("viewer disconnect was not cleaned up") } } func TestReportedScenariosDoNotClaimHardwareCompatibility(t *testing.T) { r := &events.ScenarioResult{Feature: "session expiry", Environment: "Go clients", Endpoint: "tls://test:8884", Version: "MQTT 5", Status: "passed", Observation: "session absent after expiry", Time: time.Now().UTC()} v := testViewer() v.store.Add(events.Event{Kind: "scenario_result", Scenario: r}) r.Feature = "mutated" list, counts := v.store.Snapshot() snap := makeSnapshot(list, counts) if len(snap.Compatibility) != 4 || snap.Compatibility[3].Feature != "session expiry" || snap.Compatibility[2].Status != "not_tested" || !strings.Contains(snap.Compatibility[3].Evidence, "Reported Go clients") { t.Fatal("reported evidence or hardware distinction lost") } list[0].Scenario.Feature = "mutated snapshot" again, _ := v.store.Snapshot() if again[0].Scenario.Feature != "session expiry" { t.Fatal("scenario aliases retained history") } } func TestHealthUsesCurrentMQTTReadiness(t *testing.T) { v := testViewer() ready := false v.SetReadiness(func() bool { return ready }) for _, want := range []int{503, 200, 503} { ready = want == 200 // No connection event retained: current readiness must still work. r := httptest.NewRequest("GET", "http://127.0.0.1:8080/healthz", nil) w := httptest.NewRecorder() v.Handler().ServeHTTP(w, r) if w.Code != want { t.Fatalf("health status %d, want %d", w.Code, want) } } }