181 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|