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:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -1,9 +1,12 @@
// Per-device allow and block lists, and device lookup by MQTT topic
// (AGENTS.md §7 blocklist/allowlist, §11 Phase 3).
// (AGENTS.md §4 FR-3/FR-4, §7 blocklist/allowlist, §11 Phase 6).
//
// The editors for these lists arrive in Phase 6. Phase 3 already publishes them
// in the device config, so a device that is onboarded later gets the real lists
// without another protocol change. Migration 001 created both tables.
// A list entry is one value tagged with a kind: "app" (an Android package name
// or a Windows process name) or "site" (a hostname). The editor on a device
// page edits the entries scoped to that device. A row with a NULL device_id
// applies to every device (a group entry, §7); the data model supports it and
// the effective state merges it, but the Phase 6 editor only manages
// device-scoped rows.
package store
import (
@@ -12,27 +15,96 @@ import (
"errors"
"fmt"
"strings"
"time"
)
// DeviceLists returns the site/app values that stay allowed and the values that
// are blocked for one device. A row with a NULL device_id applies to every
// device (§7). Rows are returned in insertion order, de-duplicated.
// List table names. They are package constants, never caller input, so they are
// safe to interpolate into a query.
const (
TableBlocklist = "blocklist"
TableAllowlist = "allowlist"
)
// List kind values allowed by the blocklist/allowlist CHECK constraint.
const (
ListKindApp = "app"
ListKindSite = "site"
)
// ListEntry is one blocklist or allowlist row.
type ListEntry struct {
ID int64
DeviceID *int64 // nil = applies to every device
Kind string
Value string
CreatedAt time.Time
}
// ValidListKind reports whether kind is "app" or "site".
func ValidListKind(kind string) bool {
return kind == ListKindApp || kind == ListKindSite
}
// listTable maps a public constant to the real table name, rejecting anything
// else so a caller can never select an arbitrary table.
func listTable(table string) (string, error) {
switch table {
case TableBlocklist:
return "blocklist", nil
case TableAllowlist:
return "allowlist", nil
default:
return "", fmt.Errorf("unknown list %q", table)
}
}
// normalizeListValue trims a value and rejects blanks, over-long values and
// control characters. Hostnames are lower-cased because DNS is
// case-insensitive; app identifiers keep their case.
func normalizeListValue(kind, value string) (string, error) {
if !ValidListKind(kind) {
return "", errors.New(`kind must be "app" or "site"`)
}
value = strings.TrimSpace(value)
if value == "" {
return "", errors.New("value is required")
}
if len(value) > 200 {
return "", errors.New("value must be 200 characters or fewer")
}
for _, r := range value {
if r < 0x20 || r == 0x7f {
return "", errors.New("value must not contain control characters")
}
}
if kind == ListKindSite {
value = strings.ToLower(value)
}
return value, nil
}
// DeviceLists returns the values that stay allowed and the values that are
// blocked for one device. A row with a NULL device_id applies to every device
// (§7). Rows are returned in insertion order, de-duplicated.
func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) {
if allow, err = s.listValues(ctx, "allowlist", id); err != nil {
if allow, err = s.listValues(ctx, TableAllowlist, id); err != nil {
return nil, nil, err
}
if block, err = s.listValues(ctx, "blocklist", id); err != nil {
if block, err = s.listValues(ctx, TableBlocklist, id); err != nil {
return nil, nil, err
}
return allow, block, nil
}
func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) {
// The table name is a package constant, never caller input.
rows, err := s.db.QueryContext(ctx,
`SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
name, err := listTable(table)
if err != nil {
return nil, fmt.Errorf("list %s: %w", table, err)
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT value FROM `+name+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
if err != nil {
return nil, fmt.Errorf("list %s: %w", name, err)
}
defer rows.Close()
@@ -41,24 +113,160 @@ func (s *Store) listValues(ctx context.Context, table string, id int64) ([]strin
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("scan %s: %w", table, err)
return nil, fmt.Errorf("scan %s: %w", name, err)
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, dup := seen[v]; dup {
key := v
if _, dup := seen[key]; dup {
continue
}
seen[v] = struct{}{}
seen[key] = struct{}{}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list %s: %w", table, err)
return nil, fmt.Errorf("list %s: %w", name, err)
}
return out, nil
}
// ListEntries returns one device's blocklist or allowlist rows, oldest first.
func (s *Store) ListEntries(ctx context.Context, deviceID int64, table string) ([]ListEntry, error) {
name, err := listTable(table)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, kind, value, created_at
FROM `+name+` WHERE device_id = ?
ORDER BY id`, deviceID)
if err != nil {
return nil, fmt.Errorf("list entries: %w", err)
}
defer rows.Close()
out := make([]ListEntry, 0, 8)
for rows.Next() {
var (
e ListEntry
devID sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&e.ID, &devID, &e.Kind, &e.Value, &created); err != nil {
return nil, fmt.Errorf("scan entry: %w", err)
}
if devID.Valid {
id := devID.Int64
e.DeviceID = &id
}
e.CreatedAt = parseTime(created.String)
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list entries: %w", err)
}
return out, nil
}
// AddListEntry appends one blocklist or allowlist value for a device and
// appends one audit entry in the same transaction. A value that is already
// present for the device (same kind and value, case-insensitively) is refused,
// so repeated submits do not pile up.
func (s *Store) AddListEntry(ctx context.Context, deviceID int64, table, kind, value, actor string) (*ListEntry, error) {
name, err := listTable(table)
if err != nil {
return nil, err
}
value, err = normalizeListValue(kind, value)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("add entry: %w", err)
}
defer func() { _ = tx.Rollback() }()
var exists int
err = tx.QueryRowContext(ctx,
`SELECT 1 FROM `+name+` WHERE device_id = ? AND kind = ? AND lower(value) = lower(?) LIMIT 1`,
deviceID, kind, value).Scan(&exists)
if err == nil {
return nil, fmt.Errorf("%s %q is already on the list", kind, value)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("check entry: %w", err)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO `+name+` (device_id, kind, value) VALUES (?, ?, ?)`, deviceID, kind, value)
if err != nil {
return nil, fmt.Errorf("insert entry: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("entry id: %w", err)
}
listName := "allowlist"
if table == TableBlocklist {
listName = "blocklist"
}
if err := insertAudit(ctx, tx, actor, "list.added", &deviceID,
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit entry: %w", err)
}
return &ListEntry{ID: id, DeviceID: &deviceID, Kind: kind, Value: value}, nil
}
// DeleteListEntry removes one device-scoped row and appends one audit entry.
// A row that belongs to another device is not touched: the id and device_id
// must both match, so a guessed id cannot edit someone else's list.
func (s *Store) DeleteListEntry(ctx context.Context, deviceID, entryID int64, table, actor string) error {
name, err := listTable(table)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("delete entry: %w", err)
}
defer func() { _ = tx.Rollback() }()
var kind, value string
err = tx.QueryRowContext(ctx,
`SELECT kind, value FROM `+name+` WHERE id = ? AND device_id = ?`,
entryID, deviceID).Scan(&kind, &value)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("read entry: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM `+name+` WHERE id = ?`, entryID); err != nil {
return fmt.Errorf("delete entry: %w", err)
}
listName := "allowlist"
if table == TableBlocklist {
listName = "blocklist"
}
if err := insertAudit(ctx, tx, actor, "list.removed", &deviceID,
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete: %w", err)
}
return nil
}
// DeviceByConfigTopic finds a device from a topic the device wrote to, for
// example "restrict/harry_phone_2/status". Only the Control Service reads the
// topic table, so a topic the service does not know is ignored.

428
internal/store/schedules.go Normal file
View File

@@ -0,0 +1,428 @@
// Weekly schedules and the effective desired state (AGENTS.md §4 FR-2/FR-4,
// §7 schedules, §11 Phase 6).
//
// A schedule row is a blocking window for one device: the weekday it starts on
// and a start/end wall-clock time. Restrictions are ON during the window. The
// effective state the Control Service publishes combines the manual lock in the
// state table with whatever window is active now, plus the allow/block lists
// (lists.go). The device enforces that: when locked, block every blocklist
// entry except the allowlist entries.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// Weekday numbers in the schedules table are Monday based (0 = Monday, 6 =
// Sunday, AGENTS.md §7). Go's time.Weekday is Sunday based, so every conversion
// goes through weekdayMon0 / time.WeekdayFor.
const (
Monday = 0
Sunday = 6
)
// ScheduleWindow is one weekly blocking window. Start and End are "HH:MM" local
// wall-clock. When End is earlier than Start the window crosses midnight and
// belongs to the weekday it starts on: Monday 20:00-07:00 ends Tuesday 07:00.
// Start and End must differ.
type ScheduleWindow struct {
Weekday int
Start string
End string
}
// Schedule is a stored window.
type Schedule struct {
ID int64
DeviceID int64
Weekday int
Start string
End string
CreatedAt time.Time
}
// EffectiveState is the desired state for one device at one instant: whether it
// is blocked, until when, why, and the lists the device applies while blocked.
// It is what the Control Service publishes retained on the device config topic.
type EffectiveState struct {
DeviceID int64
Locked bool
Until *time.Time
// Reason is "manual" (a lock from the dashboard), "schedule" (an active
// weekly window) or "" (not blocked).
Reason string
// Allow and Block are the configured list values, always non-nil.
Allow []string
Block []string
}
// WeekdayName returns the English name for a Monday-based weekday.
func WeekdayName(weekday int) string {
if weekday < Monday || weekday > Sunday {
return ""
}
return time.Weekday((weekday + 1) % 7).String()
}
// weekdayMon0 converts a Go weekday (Sunday = 0) to the Monday-based number the
// schedules table uses.
func weekdayMon0(t time.Time) int {
return (int(t.Weekday()) + 6) % 7
}
// NormalizeWindow validates and canonicalises one window. It is the only
// accepted shape for a schedule row; the editor and the store both call it.
func NormalizeWindow(w ScheduleWindow) (ScheduleWindow, error) {
if w.Weekday < Monday || w.Weekday > Sunday {
return w, fmt.Errorf("weekday must be 0 (Monday) to 6 (Sunday)")
}
start, err := parseClock(w.Start)
if err != nil {
return w, fmt.Errorf("start: %w", err)
}
end, err := parseClock(w.End)
if err != nil {
return w, fmt.Errorf("end: %w", err)
}
if start == end {
return w, errors.New("start and end must differ")
}
w.Start = formatClock(start)
w.End = formatClock(end)
return w, nil
}
// parseClock reads "HH:MM" or "H:MM" and returns minutes after midnight.
func parseClock(s string) (int, error) {
s = strings.TrimSpace(s)
parts := strings.Split(s, ":")
if len(parts) != 2 {
return 0, fmt.Errorf("time %q must be HH:MM", s)
}
h, err := zeroPadInt(parts[0])
if err != nil {
return 0, fmt.Errorf("time %q must be HH:MM", s)
}
m, err := zeroPadInt(parts[1])
if err != nil {
return 0, fmt.Errorf("time %q must be HH:MM", s)
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, fmt.Errorf("time %q is out of range", s)
}
return h*60 + m, nil
}
func zeroPadInt(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, errors.New("empty number")
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errors.New("not a number")
}
n = n*10 + int(r-'0')
}
return n, nil
}
func formatClock(minutes int) string {
return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
}
// ListSchedules returns a device's windows, Monday first and then by start time.
func (s *Store) ListSchedules(ctx context.Context, deviceID int64) ([]Schedule, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, weekday, start_time, end_time, created_at
FROM schedules WHERE device_id = ?
ORDER BY weekday, start_time, id`, deviceID)
if err != nil {
return nil, fmt.Errorf("list schedules: %w", err)
}
defer rows.Close()
out := make([]Schedule, 0, 8)
for rows.Next() {
var (
sc Schedule
created string
)
if err := rows.Scan(&sc.ID, &sc.DeviceID, &sc.Weekday, &sc.Start, &sc.End, &created); err != nil {
return nil, fmt.Errorf("scan schedule: %w", err)
}
sc.CreatedAt = parseTime(created)
out = append(out, sc)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list schedules: %w", err)
}
return out, nil
}
// ReplaceSchedules swaps every window for a device in one transaction and
// appends one audit entry. Windows are validated first, so an invalid edit
// changes nothing. An empty slice clears the device's windows.
func (s *Store) ReplaceSchedules(ctx context.Context, deviceID int64, windows []ScheduleWindow, actor string) error {
if _, err := s.GetDevice(ctx, deviceID); err != nil {
return err
}
normalized := make([]ScheduleWindow, 0, len(windows))
for i, w := range windows {
n, err := NormalizeWindow(w)
if err != nil {
return fmt.Errorf("window %d: %w", i+1, err)
}
normalized = append(normalized, n)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("replace schedules: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM schedules WHERE device_id = ?`, deviceID); err != nil {
return fmt.Errorf("clear schedules: %w", err)
}
for _, w := range normalized {
if _, err := tx.ExecContext(ctx,
`INSERT INTO schedules (device_id, weekday, start_time, end_time) VALUES (?, ?, ?, ?)`,
deviceID, w.Weekday, w.Start, w.End); err != nil {
return fmt.Errorf("insert schedule: %w", err)
}
}
if err := insertAudit(ctx, tx, actor, "schedule.updated", &deviceID, ScheduleSummary(normalized)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit schedules: %w", err)
}
return nil
}
// ScheduleSummary renders windows for the audit log and the editor.
func ScheduleSummary(windows []ScheduleWindow) string {
if len(windows) == 0 {
return "cleared (no windows)"
}
var b strings.Builder
for i, w := range windows {
if i > 0 {
b.WriteString("; ")
}
fmt.Fprintf(&b, "%s %s-%s", WeekdayName(w.Weekday), w.Start, w.End)
}
return b.String()
}
// ScheduleActive reports whether any window covers now, and if so the instant
// the last covering window ends. now carries the schedule timezone, so the
// caller decides whether "20:00" means Sydney or UTC.
func ScheduleActive(schedules []Schedule, now time.Time) (bool, *time.Time) {
weekday := weekdayMon0(now)
minutes := now.Hour()*60 + now.Minute()
active := false
var until time.Time
for _, sc := range schedules {
start, err := parseClock(sc.Start)
if err != nil {
continue // a bad row is ignored, never a crash
}
end, err := parseClock(sc.End)
if err != nil {
continue
}
var covers bool
var coversUntil time.Time
switch {
case start < end:
// Same-day window.
if sc.Weekday == weekday && minutes >= start && minutes < end {
covers = true
coversUntil = atClock(now, end)
}
default:
// Overnight window: it belongs to the day it starts on.
if sc.Weekday == weekday && minutes >= start {
covers = true
coversUntil = atClock(now.AddDate(0, 0, 1), end)
} else if sc.Weekday == (weekday+6)%7 && minutes < end {
covers = true
coversUntil = atClock(now, end)
}
}
if covers {
active = true
if coversUntil.After(until) {
until = coversUntil
}
}
}
if !active {
return false, nil
}
return true, &until
}
// atClock returns the given wall-clock time on now's calendar day.
func atClock(now time.Time, minutes int) time.Time {
return time.Date(now.Year(), now.Month(), now.Day(),
minutes/60, minutes%60, 0, 0, now.Location())
}
// EffectiveState computes the desired state for one device at now: a manual
// lock, an active schedule window, or neither. While blocked, the configured
// allowlist is honoured: the device blocks Block except Allow (FR-4).
//
// A manual lock with no end time ("until I unlock") never lifts, so it wins over
// a schedule end. Otherwise Until is the latest of the manual end and the
// covering window's end.
func (s *Store) EffectiveState(ctx context.Context, dev Device, now time.Time) (EffectiveState, error) {
schedules, err := s.ListSchedules(ctx, dev.ID)
if err != nil {
return EffectiveState{}, err
}
allow, block, err := s.DeviceLists(ctx, dev.ID)
if err != nil {
return EffectiveState{}, err
}
if allow == nil {
allow = []string{}
}
if block == nil {
block = []string{}
}
eff := EffectiveState{DeviceID: dev.ID, Allow: allow, Block: block}
scheduled, schedUntil := ScheduleActive(schedules, now)
switch {
case dev.Locked:
eff.Locked = true
eff.Reason = "manual"
eff.Until = dev.Until
// A manual lock with no end is indefinite; a schedule end must not
// shorten it. With an end, keep whichever lifts later.
if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) {
eff.Until = schedUntil
}
case scheduled:
eff.Locked = true
eff.Reason = "schedule"
eff.Until = schedUntil
}
return eff, nil
}
// NextScheduleBoundary returns the earliest future schedule start or end across
// every device. The Control Service republishes config at that instant so a
// window turns on and off on time with nobody touching anything (FR-2). It
// returns nil when no window is scheduled.
func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time.Time, error) {
rows, err := s.db.QueryContext(ctx, `SELECT weekday, start_time, end_time FROM schedules`)
if err != nil {
return nil, fmt.Errorf("next schedule boundary: %w", err)
}
defer rows.Close()
var schedules []Schedule
for rows.Next() {
var sc Schedule
if err := rows.Scan(&sc.Weekday, &sc.Start, &sc.End); err != nil {
return nil, fmt.Errorf("scan schedule: %w", err)
}
schedules = append(schedules, sc)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("next schedule boundary: %w", err)
}
best := NextWindowBoundary(schedules, now)
// A manual lock that expires is a boundary too, so the loop lifts it from
// the device promptly instead of waiting for the next interval.
lockRows, err := s.db.QueryContext(ctx,
`SELECT until FROM state WHERE locked = 1 AND until IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("next lock boundary: %w", err)
}
defer lockRows.Close()
for lockRows.Next() {
var until sql.NullString
if err := lockRows.Scan(&until); err != nil {
return nil, fmt.Errorf("scan lock until: %w", err)
}
t := parseTime(until.String)
if t.IsZero() || !t.After(now) {
continue
}
if best == nil || t.Before(*best) {
c := t
best = &c
}
}
if err := lockRows.Err(); err != nil {
return nil, fmt.Errorf("next lock boundary: %w", err)
}
return best, nil
}
// NextWindowBoundary returns the earliest future start or end among the given
// windows, or nil when there is none. now carries the schedule timezone.
func NextWindowBoundary(schedules []Schedule, now time.Time) *time.Time {
type window struct {
weekday, start, end int
}
var windows []window
for _, sc := range schedules {
start, err := parseClock(sc.Start)
if err != nil {
continue
}
end, err := parseClock(sc.End)
if err != nil {
continue
}
if sc.Weekday < Monday || sc.Weekday > Sunday {
continue
}
windows = append(windows, window{weekday: sc.Weekday, start: start, end: end})
}
if len(windows) == 0 {
return nil
}
loc := now.Location()
base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
var best *time.Time
consider := func(cand time.Time) {
if cand.After(now) && (best == nil || cand.Before(*best)) {
c := cand
best = &c
}
}
// Yesterday through next week: a window that started yesterday can end
// today, so its end is still a future boundary.
for d := -1; d <= 7; d++ {
day := base.AddDate(0, 0, d)
for _, w := range windows {
if weekdayMon0(day) != w.weekday {
continue
}
consider(day.Add(time.Duration(w.start) * time.Minute))
end := day.Add(time.Duration(w.end) * time.Minute)
if w.end <= w.start {
end = end.AddDate(0, 0, 1) // overnight
}
consider(end)
}
}
return best
}

View File

@@ -0,0 +1,429 @@
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)
}
}
}