Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user