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