- Weekly schedule editor per device: a Monday-Sunday grid of blocking windows, including windows that cross midnight. - Blocklist/allowlist editor per device: app (Android package / Windows process) and site (hostname) entries, tagged by the device platform. - Effective desired state: a manual lock, an active schedule window, or neither. While blocked the allowlist is honoured (Spotify stays while other apps are blocked). Published retained with reason schedule/manual. - Boundary-aware republish loop so a window turns on and off on time, and manual lock expiry is cleared promptly. - CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so wall-clock windows are evaluated in the family zone, not the container. - Every change is written to the append-only audit log (FR-15). Co-Authored-By: Paperclip <noreply@paperclip.ing>
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
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]
|
|
}
|