- 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>
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func mustCreateDevice(t *testing.T, st *Store, name, child, platform string) *Device {
|
|
t.Helper()
|
|
dev, err := st.CreateDevice(context.Background(), name, child, platform, "sam")
|
|
if err != nil {
|
|
t.Fatalf("CreateDevice: %v", err)
|
|
}
|
|
return dev
|
|
}
|
|
|
|
func TestNormalizeWindow(t *testing.T) {
|
|
ok, err := NormalizeWindow(ScheduleWindow{Weekday: Monday, Start: "9:5", End: "15:00"})
|
|
if err != nil {
|
|
t.Fatalf("NormalizeWindow: %v", err)
|
|
}
|
|
if ok.Start != "09:05" || ok.End != "15:00" {
|
|
t.Errorf("normalized = %+v, want 09:05-15:00", ok)
|
|
}
|
|
|
|
for _, tc := range []ScheduleWindow{
|
|
{Weekday: 7, Start: "09:00", End: "10:00"},
|
|
{Weekday: -1, Start: "09:00", End: "10:00"},
|
|
{Weekday: Monday, Start: "25:00", End: "10:00"},
|
|
{Weekday: Monday, Start: "09:00", End: "09:70"},
|
|
{Weekday: Monday, Start: "nine", End: "10:00"},
|
|
{Weekday: Monday, Start: "09:00", End: "09:00"},
|
|
{Weekday: Monday, Start: "", End: "10:00"},
|
|
} {
|
|
if _, err := NormalizeWindow(tc); err == nil {
|
|
t.Errorf("NormalizeWindow(%+v) = nil error, want an error", tc)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReplaceAndListSchedules(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := testStore(t)
|
|
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
|
|
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: 2, Start: "15:00", End: "16:00"}, // Wednesday
|
|
{Weekday: Monday, Start: "20:00", End: "07:00"},
|
|
}, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
|
|
got, err := st.ListSchedules(ctx, dev.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSchedules: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("schedules = %+v, want 2", got)
|
|
}
|
|
// Monday first, then Wednesday.
|
|
if got[0].Weekday != Monday || got[0].Start != "20:00" || got[0].End != "07:00" {
|
|
t.Errorf("schedule[0] = %+v", got[0])
|
|
}
|
|
if got[1].Weekday != 2 {
|
|
t.Errorf("schedule[1] = %+v", got[1])
|
|
}
|
|
|
|
// Replacing clears the old rows and is audited.
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, nil, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules clear: %v", err)
|
|
}
|
|
got, err = st.ListSchedules(ctx, dev.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSchedules: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("schedules after clear = %+v, want none", got)
|
|
}
|
|
|
|
audit, err := st.ListAudit(ctx, 10)
|
|
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)
|
|
}
|
|
|
|
// An invalid window changes nothing.
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: Monday, Start: "09:00", End: "10:00"},
|
|
{Weekday: Monday, Start: "bad", End: "10:00"},
|
|
}, "sam"); err == nil {
|
|
t.Fatal("expected an error for an invalid window")
|
|
}
|
|
got, _ = st.ListSchedules(ctx, dev.ID)
|
|
if len(got) != 0 {
|
|
t.Errorf("an invalid batch changed the schedules: %+v", got)
|
|
}
|
|
|
|
if err := st.ReplaceSchedules(ctx, 999, nil, "sam"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("ReplaceSchedules missing device: err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestScheduleActive(t *testing.T) {
|
|
// 2026-10-12 is a Monday, 2026-10-13 a Tuesday.
|
|
monday := func(h, m int) time.Time {
|
|
return time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)
|
|
}
|
|
tuesday := func(h, m int) time.Time {
|
|
return time.Date(2026, 10, 13, h, m, 0, 0, time.UTC)
|
|
}
|
|
|
|
sameDay := []Schedule{{Weekday: Monday, Start: "09:00", End: "15:00"}}
|
|
overnight := []Schedule{{Weekday: Monday, Start: "20:00", End: "07:00"}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
schedules []Schedule
|
|
now time.Time
|
|
active bool
|
|
until time.Time
|
|
}{
|
|
{"inside same-day", sameDay, monday(12, 0), true, monday(15, 0)},
|
|
{"before same-day", sameDay, monday(8, 59), false, time.Time{}},
|
|
{"at start", sameDay, monday(9, 0), true, monday(15, 0)},
|
|
{"at end is outside", sameDay, monday(15, 0), false, time.Time{}},
|
|
{"wrong day", sameDay, tuesday(12, 0), false, time.Time{}},
|
|
{"overnight before midnight", overnight, monday(21, 0), true, tuesday(7, 0)},
|
|
{"overnight after midnight", overnight, tuesday(2, 0), true, tuesday(7, 0)},
|
|
{"overnight after end", overnight, tuesday(7, 0), false, time.Time{}},
|
|
{"overnight wrong day", overnight, tuesday(21, 0), false, time.Time{}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
active, until := ScheduleActive(tc.schedules, tc.now)
|
|
if active != tc.active {
|
|
t.Fatalf("active = %v, want %v", active, tc.active)
|
|
}
|
|
if tc.active {
|
|
if until == nil || !until.Equal(tc.until) {
|
|
t.Errorf("until = %v, want %v", until, tc.until)
|
|
}
|
|
} else if until != nil {
|
|
t.Errorf("until = %v, want nil", until)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Two overlapping windows: the later end wins.
|
|
overlap := []Schedule{
|
|
{Weekday: Monday, Start: "09:00", End: "12:00"},
|
|
{Weekday: Monday, Start: "11:00", End: "13:00"},
|
|
}
|
|
if _, until := ScheduleActive(overlap, monday(11, 30)); until == nil || !until.Equal(monday(13, 0)) {
|
|
t.Errorf("overlapping until = %v, want 13:00", until)
|
|
}
|
|
}
|
|
|
|
// TestEffectiveStateAllowlistIsolation is the Phase 6 definition of done: with
|
|
// Spotify allowlisted while other apps are blocked and a window active, Spotify
|
|
// is allowed and the others are blocked.
|
|
func TestEffectiveStateAllowlistIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := testStore(t)
|
|
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
|
|
|
|
// 2026-10-13 is a Tuesday.
|
|
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: weekdayMon0(now), Start: "19:00", End: "21:00"},
|
|
}, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
if _, err := st.AddListEntry(ctx, dev.ID, TableAllowlist, ListKindApp, "com.spotify.music", "sam"); err != nil {
|
|
t.Fatalf("AddListEntry allow: %v", err)
|
|
}
|
|
for _, v := range []string{"com.youtube.android", "com.roblox.client"} {
|
|
if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, v, "sam"); err != nil {
|
|
t.Fatalf("AddListEntry block: %v", err)
|
|
}
|
|
}
|
|
blockSite, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindSite, "YouTube.com", "sam")
|
|
if err != nil {
|
|
t.Fatalf("AddListEntry block site: %v", err)
|
|
}
|
|
if blockSite.Value != "youtube.com" {
|
|
t.Errorf("site value = %q, want it lower-cased", blockSite.Value)
|
|
}
|
|
|
|
eff, err := st.EffectiveState(ctx, *dev, now)
|
|
if err != nil {
|
|
t.Fatalf("EffectiveState: %v", err)
|
|
}
|
|
if !eff.Locked || eff.Reason != "schedule" {
|
|
t.Fatalf("effective = %+v, want locked by schedule", eff)
|
|
}
|
|
if eff.Until == nil || !eff.Until.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("until = %v, want 21:00", eff.Until)
|
|
}
|
|
if len(eff.Allow) != 1 || eff.Allow[0] != "com.spotify.music" {
|
|
t.Errorf("allow = %v, want Spotify", eff.Allow)
|
|
}
|
|
if len(eff.Block) != 3 {
|
|
t.Errorf("block = %v, want the other apps and the site", eff.Block)
|
|
}
|
|
|
|
// Outside the window the device is not blocked, but the lists remain so the
|
|
// device can apply them the moment a window starts.
|
|
eff, err = st.EffectiveState(ctx, *dev, time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("EffectiveState: %v", err)
|
|
}
|
|
if eff.Locked || eff.Reason != "" {
|
|
t.Errorf("outside window = %+v, want unlocked", eff)
|
|
}
|
|
if len(eff.Allow) != 1 {
|
|
t.Errorf("allow = %v, want the list still present", eff.Allow)
|
|
}
|
|
|
|
// A manual lock is the reason, and "until I unlock" never lifts.
|
|
locked, err := st.SetDeviceLock(ctx, dev.ID, true, nil, "sam", "state.locked")
|
|
if err != nil {
|
|
t.Fatalf("SetDeviceLock: %v", err)
|
|
}
|
|
eff, err = st.EffectiveState(ctx, *locked, now)
|
|
if err != nil {
|
|
t.Fatalf("EffectiveState: %v", err)
|
|
}
|
|
if !eff.Locked || eff.Reason != "manual" || eff.Until != nil {
|
|
t.Errorf("manual lock = %+v, want locked manually with no end", eff)
|
|
}
|
|
|
|
// A manual lock with an end keeps the later of the two.
|
|
end := time.Date(2026, 10, 13, 22, 0, 0, 0, time.UTC)
|
|
locked, err = st.SetDeviceLock(ctx, dev.ID, true, &end, "sam", "state.locked")
|
|
if err != nil {
|
|
t.Fatalf("SetDeviceLock: %v", err)
|
|
}
|
|
eff, err = st.EffectiveState(ctx, *locked, now)
|
|
if err != nil {
|
|
t.Fatalf("EffectiveState: %v", err)
|
|
}
|
|
if eff.Until == nil || !eff.Until.Equal(end) {
|
|
t.Errorf("until = %v, want the later manual end %v", eff.Until, end)
|
|
}
|
|
}
|
|
|
|
func TestNextScheduleBoundary(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := testStore(t)
|
|
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
|
|
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: weekdayMon0(time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC)), Start: "19:00", End: "21:00"},
|
|
}, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
|
|
// Tuesday 20:30: the next boundary is the window end at 21:00.
|
|
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
|
|
next, err := st.NextScheduleBoundary(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("NextScheduleBoundary: %v", err)
|
|
}
|
|
if next == nil || !next.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("next = %v, want 2026-10-13 21:00", next)
|
|
}
|
|
|
|
// Tuesday 22:00: the next boundary is next week's start.
|
|
now = time.Date(2026, 10, 13, 22, 0, 0, 0, time.UTC)
|
|
next, err = st.NextScheduleBoundary(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("NextScheduleBoundary: %v", err)
|
|
}
|
|
if next == nil || !next.Equal(time.Date(2026, 10, 20, 19, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("next = %v, want 2026-10-20 19:00", next)
|
|
}
|
|
|
|
// An overnight window: the morning end is a boundary even though the start
|
|
// was yesterday.
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: weekdayMon0(time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)), Start: "20:00", End: "07:00"},
|
|
}, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
now = time.Date(2026, 10, 13, 2, 0, 0, 0, time.UTC) // Tuesday 02:00
|
|
next, err = st.NextScheduleBoundary(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("NextScheduleBoundary: %v", err)
|
|
}
|
|
if next == nil || !next.Equal(time.Date(2026, 10, 13, 7, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("overnight next = %v, want 2026-10-13 07:00", next)
|
|
}
|
|
|
|
// No schedules, no boundary.
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, nil, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
if next, err = st.NextScheduleBoundary(ctx, now); err != nil || next != nil {
|
|
t.Errorf("NextScheduleBoundary with none = (%v, %v), want (nil, nil)", next, err)
|
|
}
|
|
}
|
|
|
|
func TestNextBoundaryIncludesLockExpiry(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := testStore(t)
|
|
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
|
|
|
|
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
|
|
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
|
|
{Weekday: weekdayMon0(now), Start: "21:00", End: "22:00"},
|
|
}, "sam"); err != nil {
|
|
t.Fatalf("ReplaceSchedules: %v", err)
|
|
}
|
|
// A lock that expires before the next window is the next boundary.
|
|
until := now.Add(10 * time.Minute)
|
|
if _, err := st.SetDeviceLock(ctx, dev.ID, true, &until, "sam", "state.locked"); err != nil {
|
|
t.Fatalf("SetDeviceLock: %v", err)
|
|
}
|
|
next, err := st.NextScheduleBoundary(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("NextScheduleBoundary: %v", err)
|
|
}
|
|
if next == nil || !next.Equal(until) {
|
|
t.Fatalf("next = %v, want the lock expiry %v", next, until)
|
|
}
|
|
|
|
// An expired lock is not a boundary.
|
|
past := now.Add(-time.Minute)
|
|
if _, err := st.SetDeviceLock(ctx, dev.ID, true, &past, "sam", "state.locked"); err != nil {
|
|
t.Fatalf("SetDeviceLock: %v", err)
|
|
}
|
|
next, err = st.NextScheduleBoundary(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("NextScheduleBoundary: %v", err)
|
|
}
|
|
if next == nil || !next.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("next = %v, want the window start 21:00", next)
|
|
}
|
|
}
|
|
|
|
func TestListEntryCRUD(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := testStore(t)
|
|
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
|
|
other := mustCreateDevice(t, st, "Finn PC", "Finn", "windows")
|
|
|
|
entry, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, "com.youtube.android", "sam")
|
|
if err != nil {
|
|
t.Fatalf("AddListEntry: %v", err)
|
|
}
|
|
if entry.ID == 0 || entry.Value != "com.youtube.android" {
|
|
t.Errorf("entry = %+v", entry)
|
|
}
|
|
|
|
// A duplicate (case-insensitive) is refused.
|
|
if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, "COM.YouTube.Android", "sam"); err == nil {
|
|
t.Error("expected a duplicate to be refused")
|
|
}
|
|
|
|
// Each list is separate.
|
|
if _, err := st.AddListEntry(ctx, dev.ID, TableAllowlist, ListKindApp, "com.spotify.music", "sam"); err != nil {
|
|
t.Fatalf("AddListEntry allow: %v", err)
|
|
}
|
|
block, err := st.ListEntries(ctx, dev.ID, TableBlocklist)
|
|
if err != nil {
|
|
t.Fatalf("ListEntries: %v", err)
|
|
}
|
|
if len(block) != 1 || block[0].Value != "com.youtube.android" {
|
|
t.Errorf("block = %+v", block)
|
|
}
|
|
|
|
// A device sees only its own entries.
|
|
otherBlock, err := st.ListEntries(ctx, other.ID, TableBlocklist)
|
|
if err != nil {
|
|
t.Fatalf("ListEntries other: %v", err)
|
|
}
|
|
if len(otherBlock) != 0 {
|
|
t.Errorf("other device block = %+v, want none", otherBlock)
|
|
}
|
|
|
|
// Deleting through the wrong device does not touch the row.
|
|
if err := st.DeleteListEntry(ctx, other.ID, entry.ID, TableBlocklist, "sam"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("cross-device delete: err = %v, want ErrNotFound", err)
|
|
}
|
|
if err := st.DeleteListEntry(ctx, dev.ID, entry.ID, TableBlocklist, "sam"); err != nil {
|
|
t.Fatalf("DeleteListEntry: %v", err)
|
|
}
|
|
block, _ = st.ListEntries(ctx, dev.ID, TableBlocklist)
|
|
if len(block) != 0 {
|
|
t.Errorf("block after delete = %+v, want none", block)
|
|
}
|
|
|
|
// Invalid input is rejected before it reaches the table.
|
|
for _, tc := range []struct{ kind, value string }{
|
|
{"widget", "x"},
|
|
{ListKindApp, " "},
|
|
{ListKindApp, "with\nnewline"},
|
|
} {
|
|
if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, tc.kind, tc.value, "sam"); err == nil {
|
|
t.Errorf("AddListEntry(%q, %q) = nil error, want an error", tc.kind, tc.value)
|
|
}
|
|
}
|
|
if _, err := st.AddListEntry(ctx, dev.ID, "notes", ListKindApp, "x", "sam"); err == nil {
|
|
t.Error("expected an error for an unknown list table")
|
|
}
|
|
|
|
// The allow/block values used by the effective state are de-duplicated.
|
|
if allow, block, err := st.DeviceLists(ctx, dev.ID); err != nil {
|
|
t.Fatalf("DeviceLists: %v", err)
|
|
} else {
|
|
if len(allow) != 1 || allow[0] != "com.spotify.music" {
|
|
t.Errorf("allow = %v", allow)
|
|
}
|
|
if len(block) != 0 {
|
|
t.Errorf("block = %v", block)
|
|
}
|
|
}
|
|
}
|