package alert import ( "context" "log/slog" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) type fakeStore struct { devices []store.Device eff map[int64]store.EffectiveState states map[int64]store.AlertState alerts []*store.Alert nextID int64 } func newFakeStore(devices ...store.Device) *fakeStore { return &fakeStore{ devices: devices, eff: map[int64]store.EffectiveState{}, states: map[int64]store.AlertState{}, } } func (f *fakeStore) ListDevices(context.Context) ([]store.Device, error) { return f.devices, nil } func (f *fakeStore) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) { return f.eff[dev.ID], nil } func (f *fakeStore) AlertStates(context.Context) (map[int64]store.AlertState, error) { out := make(map[int64]store.AlertState, len(f.states)) for k, v := range f.states { out[k] = v } return out, nil } func (f *fakeStore) SetAlertState(_ context.Context, deviceID int64, locked, offline bool) error { f.states[deviceID] = store.AlertState{Locked: locked, Offline: offline} return nil } func (f *fakeStore) CreateAlert(_ context.Context, deviceID *int64, kind, message string) (*store.Alert, error) { f.nextID++ a := &store.Alert{ID: f.nextID, DeviceID: deviceID, Kind: kind, Message: message} f.alerts = append(f.alerts, a) return a, nil } func (f *fakeStore) MarkAlertDelivered(_ context.Context, id int64, errMsg string) error { for _, a := range f.alerts { if a.ID == id { a.Delivered = errMsg == "" a.Error = errMsg } } return nil } type fakeNotifier struct { configured bool sent []string err error } func (f *fakeNotifier) Configured() bool { return f.configured } func (f *fakeNotifier) SendType(_ context.Context, _, body, _ string) error { if f.err != nil { return f.err } f.sent = append(f.sent, body) return nil } func newEngine(st *fakeStore, n *fakeNotifier, now time.Time) *Engine { e := New(st, n, slog.New(slog.DiscardHandler), 30*time.Minute, time.UTC) e.Now = func() time.Time { return now } return e } func TestSeedThenLimitTransition(t *testing.T) { now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) until := now.Add(30 * time.Minute) fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"}) fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: false, Reason: ""} fn := &fakeNotifier{configured: true} e := newEngine(fs, fn, now) // First observation seeds the state and raises nothing. e.Evaluate(context.Background()) if len(fs.alerts) != 0 { t.Fatalf("seed raised %d alerts, want 0", len(fs.alerts)) } if st := fs.states[1]; st.Locked { t.Fatalf("seeded state = %#v, want unlocked", st) } // A schedule turns on: exactly one limit alert. fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until} e.Evaluate(context.Background()) if len(fs.alerts) != 1 { t.Fatalf("alerts = %d, want 1", len(fs.alerts)) } if fs.alerts[0].Kind != store.AlertLimit { t.Errorf("kind = %q, want limit", fs.alerts[0].Kind) } if !fs.alerts[0].Delivered { t.Error("alert was not marked delivered") } if len(fn.sent) != 1 { t.Fatalf("notifier sent %d, want 1", len(fn.sent)) } // No new alert while the condition stays true. e.Evaluate(context.Background()) if len(fs.alerts) != 1 { t.Fatalf("repeat evaluation raised %d alerts, want 1", len(fs.alerts)) } } func TestOfflineTransition(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) old := now.Add(-2 * time.Hour) fs := newFakeStore(store.Device{ID: 2, Name: "Finn's computer", Child: "Finn", Online: true}) fs.eff[2] = store.EffectiveState{DeviceID: 2} fn := &fakeNotifier{configured: true} e := newEngine(fs, fn, now) e.Evaluate(context.Background()) // seed online fs.devices[0].Online = false fs.devices[0].LastSeen = &old e.Evaluate(context.Background()) if len(fs.alerts) != 1 || fs.alerts[0].Kind != store.AlertOffline { t.Fatalf("alerts = %#v, want one offline alert", fs.alerts) } if len(fn.sent) != 1 { t.Fatalf("notifier sent %d, want 1", len(fn.sent)) } } func TestNeverSeenDeviceIsNotOffline(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) fs := newFakeStore(store.Device{ID: 3, Name: "New device", Child: "Harry", Online: false}) fs.eff[3] = store.EffectiveState{DeviceID: 3} e := newEngine(fs, &fakeNotifier{configured: true}, now) e.Evaluate(context.Background()) // seed e.Evaluate(context.Background()) // still no last_seen if len(fs.alerts) != 0 { t.Fatalf("alerts = %d, want 0 for a never-seen device", len(fs.alerts)) } } func TestUndeliveredWhenNotConfigured(t *testing.T) { now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) until := now.Add(30 * time.Minute) fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"}) fs.eff[1] = store.EffectiveState{DeviceID: 1} fn := &fakeNotifier{configured: false} e := newEngine(fs, fn, now) e.Evaluate(context.Background()) fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until} e.Evaluate(context.Background()) if len(fs.alerts) != 1 { t.Fatalf("alerts = %d, want 1", len(fs.alerts)) } if fs.alerts[0].Delivered { t.Error("alert marked delivered with an unconfigured notifier") } } func TestRaiseTamperDeliversOnce(t *testing.T) { now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"}) fn := &fakeNotifier{configured: true} e := newEngine(fs, fn, now) dev := store.Device{ID: 1, Name: "Harry's phone"} e.Raise(context.Background(), dev, store.AlertTamper, TamperMessage(dev, "accessibility_disabled", "service turned off")) if len(fs.alerts) != 1 { t.Fatalf("alerts = %d, want 1", len(fs.alerts)) } a := fs.alerts[0] if a.Kind != store.AlertTamper { t.Errorf("kind = %q, want %q", a.Kind, store.AlertTamper) } if !a.Delivered { t.Error("tamper alert was not delivered") } if len(fn.sent) != 1 || !strings.Contains(fn.sent[0], "Harry's phone") { t.Errorf("delivered bodies = %v", fn.sent) } } func TestTamperMessagePhrases(t *testing.T) { dev := store.Device{ID: 1, Name: "Finn's computer"} for kind, want := range map[string]string{ "accessibility_disabled": "the Accessibility Service was disabled", "overlay_revoked": "the overlay permission was revoked", "force_stopped": "the app was force stopped", "something_new": "the client enforcement was bypassed", } { got := TamperMessage(dev, kind, "") if !strings.Contains(got, want) { t.Errorf("TamperMessage(%q) = %q, want it to contain %q", kind, got, want) } } }