Files
internet_use_control/internal/web/devices_test.go
Sam Rolfe 14b304e0b0 Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design
- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:37:43 +11:00

199 lines
5.5 KiB
Go

package web
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
type testApp struct {
handler http.Handler
store *store.Store
cookies []*http.Cookie
csrf string
}
func newTestApp(t *testing.T) *testApp {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "control.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("auth.New: %v", err)
}
cfg := &config.Config{
AdminUsername: "sam",
Version: "test",
AppEnv: "test",
DBPath: ":memory:",
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
if err != nil {
t.Fatalf("web.New: %v", err)
}
rec := httptest.NewRecorder()
am.Issue(rec)
cookies := rec.Result().Cookies()
var csrf string
for _, c := range cookies {
if c.Name == "control_csrf" {
csrf = c.Value
}
}
return &testApp{handler: srv.Handler(), store: st, cookies: cookies, csrf: csrf}
}
func (a *testApp) do(t *testing.T, method, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req := httptest.NewRequest(method, path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for _, c := range a.cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func TestDashboardDeviceLifecycle(t *testing.T) {
app := newTestApp(t)
// Empty dashboard renders and offers the add form.
rec := app.do(t, http.MethodGet, "/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No devices yet") {
t.Error("empty dashboard should show the empty state")
}
// Add a device.
rec = app.do(t, http.MethodPost, "/devices", url.Values{
"csrf": {app.csrf},
"name": {"Harry's Pixel"},
"child": {"Harry"},
"platform": {"android"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
devs, err := app.store.ListDevices(t.Context())
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
if len(devs) != 1 {
t.Fatalf("devices = %d, want 1", len(devs))
}
dev := &devs[0]
if dev.Name != "Harry's Pixel" || dev.Child != "Harry" || dev.Platform != "android" {
t.Errorf("device = %+v", dev)
}
// The card is on the dashboard.
rec = app.do(t, http.MethodGet, "/", nil)
body := rec.Body.String()
for _, want := range []string{"Harry&#39;s Pixel", "Harry", "Android", "Unlocked", "restrict/harry_s_pixel_1/config"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
// Lock it for 30 minutes.
rec = app.do(t, http.MethodPost, "/devices/1/lock", url.Values{
"csrf": {app.csrf}, "minutes": {"30"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("lock = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
dev, err = app.store.GetDevice(t.Context(), dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if !dev.Locked || dev.Until == nil {
t.Fatalf("device not locked: %+v", dev)
}
rec = app.do(t, http.MethodGet, "/", nil)
body = rec.Body.String()
if !strings.Contains(body, "Locked") || !strings.Contains(body, "min left") {
t.Error("dashboard should show the locked state and time left")
}
// Edit page loads with the current values.
rec = app.do(t, http.MethodGet, "/devices/1/edit", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Harry&#39;s Pixel") {
t.Errorf("edit page = %d", rec.Code)
}
// Unlock.
rec = app.do(t, http.MethodPost, "/devices/1/unlock", url.Values{"csrf": {app.csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("unlock = %d, want 303", rec.Code)
}
dev, err = app.store.GetDevice(t.Context(), dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if dev.Locked || dev.Until != nil {
t.Errorf("device still locked: %+v", dev)
}
// Missing CSRF is rejected.
rec = app.do(t, http.MethodPost, "/devices/1/lock", url.Values{"minutes": {"30"}})
if rec.Code != http.StatusForbidden {
t.Errorf("lock without csrf = %d, want 403", rec.Code)
}
// Unknown device is a 404.
if rec := app.do(t, http.MethodPost, "/devices/999/lock", url.Values{"csrf": {app.csrf}}); rec.Code != http.StatusNotFound {
t.Errorf("lock unknown device = %d, want 404", rec.Code)
}
// The audit log records the whole lifecycle.
audit, err := app.store.ListAudit(t.Context(), 10)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
if len(audit) != 3 {
t.Fatalf("audit entries = %d, want 3 (%+v)", len(audit), audit)
}
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("GET / unauthenticated = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("redirect = %q, want /login", loc)
}
}