Files
Ryan 9a32f634d4
Test and deploy MQTT backend / test (push) Successful in 1m44s
Test and deploy MQTT backend / deploy (push) Successful in 14s
Use live MQTT readiness for server health checks
2026-10-08 14:51:32 +03:30

181 lines
6.4 KiB
Go

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 := "<script>alert(1)</script>"
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)
}
}
}