Phase 6: schedules, blocklists, allowlists (editors + effective state)
- 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>
This commit is contained in:
222
internal/web/schedules_test.go
Normal file
222
internal/web/schedules_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user