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'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) } }