Files
internet_use_control/internal/web/devices_test.go
Sam Rolfe 7c88d3f52b Phase 2: device registry, dashboard cards, manual lock/unlock, audit
- Device registry: add/edit name, child and platform, persisted in devices.
- Stable per-device topic derived from name + id, unchanged by renames.
- Desired state persisted in state; timed locks expire and are audited.
- Dashboard cards: name, child, platform, status dot, state and time left.
- Manual lock/unlock forms with CSRF; every change appends to audit (FR-15).
- Append-only activity list on the dashboard. No hard deletes (rule 10).
- Store and handler tests; version bumped to 0.2.0-phase2.

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

199 lines
5.4 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)))
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)
}
}