Add android/, a normal Gradle/Kotlin project with its own wrapper. It blocks by interruption: an Accessibility Service watches the foreground package and a full-screen overlay covers any listed app while the device is locked (FR-16). No Device Owner, no factory reset, no managed profile. The client subscribes to the retained restrict/<device>/config topic on the 8883 TLS plane, keeps the last config on disk so a broker-less device keeps its rules (FR-14), and reports tampering on its own status topic (FR-17): a disabled service, a revoked overlay permission, or a force-stop seen on the next start. The Control Service now parses the tamper field and raises an Apprise alert, so it reaches Sam. The block path and the tamper alert are proven on the headless pixel35 emulator on .27; see android/README.md and android/tools/emu-proof.sh. Co-Authored-By: Paperclip <noreply@paperclip.ing>
220 lines
6.6 KiB
Go
220 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|