package web import ( "fmt" "net/http" "net/url" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) func createTestDevice(t *testing.T, app *testApp) *store.Device { t.Helper() 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)) } return &devs[0] } func TestScheduleEditorSavesAndBlocksBySchedule(t *testing.T) { app := newTestApp(t) dev := createTestDevice(t, app) // The empty grid renders. rec := app.do(t, http.MethodGet, fmt.Sprintf("/devices/%d/schedule", dev.ID), nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Weekly schedule") { t.Fatalf("GET schedule = %d", rec.Code) } // Save a window that covers now, whatever the local time is. now := time.Now() weekday := (int(now.Weekday()) + 6) % 7 // Monday based start := now.Add(-1 * time.Hour).Format("15:04") end := now.Add(1 * time.Hour).Format("15:04") rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ "csrf": {app.csrf}, fmt.Sprintf("start_%d_0", weekday): {start}, fmt.Sprintf("end_%d_0", weekday): {end}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("POST schedule = %d, want 303; body=%s", rec.Code, rec.Body.String()) } schedules, err := app.store.ListSchedules(t.Context(), dev.ID) if err != nil { t.Fatalf("ListSchedules: %v", err) } if len(schedules) != 1 || schedules[0].Start != start || schedules[0].End != end { t.Fatalf("schedules = %+v, want one %s-%s", schedules, start, end) } // The dashboard reflects the schedule-driven block, with the reason. rec = app.do(t, http.MethodGet, "/", nil) if body := rec.Body.String(); !strings.Contains(body, "by schedule") { t.Errorf("dashboard should say the device is blocked by schedule:\n%s", body) } // An invalid window is rejected and nothing is stored. rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ "csrf": {app.csrf}, "start_0_0": {"not-a-time"}, "end_0_0": {"10:00"}, }) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "HH:MM") { t.Errorf("invalid window = %d, want the editor with an error", rec.Code) } schedules, _ = app.store.ListSchedules(t.Context(), dev.ID) if len(schedules) != 1 { t.Errorf("an invalid submit changed the schedules: %+v", schedules) } // Clear all removes the window. rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ "csrf": {app.csrf}, "clear": {"1"}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("clear = %d, want 303", rec.Code) } schedules, _ = app.store.ListSchedules(t.Context(), dev.ID) if len(schedules) != 0 { t.Errorf("schedules after clear = %+v, want none", schedules) } // Every change is audited. audit, err := app.store.ListAudit(t.Context(), 20) if err != nil { t.Fatalf("ListAudit: %v", err) } var updates int for _, e := range audit { if e.Action == "schedule.updated" { updates++ } } if updates != 2 { t.Errorf("schedule.updated audit entries = %d, want 2", updates) } } func TestListsEditorAddAndRemove(t *testing.T) { app := newTestApp(t) dev := createTestDevice(t, app) base := fmt.Sprintf("/devices/%d/lists", dev.ID) rec := app.do(t, http.MethodGet, base, nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Blocklist and allowlist") { t.Fatalf("GET lists = %d", rec.Code) } // Allowlist Spotify and block other apps. for _, form := range []url.Values{ {"csrf": {app.csrf}, "table": {"allow"}, "kind": {"app"}, "value": {"com.spotify.music"}}, {"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"}}, {"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"roblox.exe"}}, } { rec = app.do(t, http.MethodPost, base, form) if rec.Code != http.StatusSeeOther { t.Fatalf("POST list entry = %d, want 303; body=%s", rec.Code, rec.Body.String()) } } rec = app.do(t, http.MethodGet, base, nil) body := rec.Body.String() for _, want := range []string{"com.spotify.music", "com.youtube.android", "roblox.exe", "Allowed", "Blocked"} { if !strings.Contains(body, want) { t.Errorf("lists page missing %q", want) } } // A duplicate is refused with a message, not a second row. rec = app.do(t, http.MethodPost, base, url.Values{ "csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"}, }) if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "err=") { t.Errorf("duplicate = %d %q, want a redirect with an error", rec.Code, rec.Header().Get("Location")) } // Remove one blocked app. blocked, err := app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist) if err != nil { t.Fatalf("ListEntries: %v", err) } if len(blocked) != 2 { t.Fatalf("blocklist = %+v, want 2", blocked) } rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/lists/%d/delete", dev.ID, blocked[0].ID), url.Values{"csrf": {app.csrf}, "table": {"block"}}) if rec.Code != http.StatusSeeOther { t.Fatalf("delete = %d, want 303", rec.Code) } blocked, _ = app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist) if len(blocked) != 1 || blocked[0].Value != "roblox.exe" { t.Errorf("blocklist after delete = %+v", blocked) } // The allowlist is untouched. allow, _ := app.store.ListEntries(t.Context(), dev.ID, store.TableAllowlist) if len(allow) != 1 || allow[0].Value != "com.spotify.music" { t.Errorf("allowlist = %+v", allow) } // The audit log records the adds and the remove. audit, err := app.store.ListAudit(t.Context(), 20) if err != nil { t.Fatalf("ListAudit: %v", err) } var added, removed int for _, e := range audit { switch e.Action { case "list.added": added++ case "list.removed": removed++ } } if added != 3 || removed != 1 { t.Errorf("audit added=%d removed=%d, want 3 and 1", added, removed) } // Cross-device delete is a 404. other := createTestDeviceNamed(t, app, "Finn PC", "Finn", "windows") rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/lists/%d/delete", other.ID, blocked[0].ID), url.Values{"csrf": {app.csrf}, "table": {"block"}}) if rec.Code != http.StatusNotFound { t.Errorf("cross-device delete = %d, want 404", rec.Code) } } func createTestDeviceNamed(t *testing.T, app *testApp, name, child, platform string) *store.Device { t.Helper() rec := app.do(t, http.MethodPost, "/devices", url.Values{ "csrf": {app.csrf}, "name": {name}, "child": {child}, "platform": {platform}, }) 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) } return &devs[len(devs)-1] }