Files
internet_use_control/internal/mqtt/link_test.go
Sam Rolfe e018c3bfa8 Phase 5.1: kids' Android client (Accessibility block overlay + tamper alert)
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>
2026-10-13 20:17:22 +11:00

111 lines
3.2 KiB
Go

package mqtt
import (
"context"
"log/slog"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// fakes for the link's collaborators.
type fakeDevices struct{ dev store.Device }
func (f fakeDevices) DeviceByConfigTopic(context.Context, string) (*store.Device, error) {
d := f.dev
return &d, nil
}
type fakeReports struct{ presence bool }
func (f *fakeReports) SetPresence(_ context.Context, _ int64, online bool, _ time.Time, _ string) (bool, error) {
f.presence = online
return true, nil
}
func (f *fakeReports) InsertTelemetry(context.Context, int64, string, []store.TelemetryEntry) (int, error) {
return 0, nil
}
type fakeSource struct{}
func (fakeSource) ListDevices(context.Context) ([]store.Device, error) { return nil, nil }
func (fakeSource) EffectiveState(context.Context, store.Device, time.Time) (store.EffectiveState, error) {
return store.EffectiveState{}, nil
}
// fakeMessage satisfies paho.Message for a received report.
type fakeMessage struct {
topic string
payload []byte
}
func (m fakeMessage) Duplicate() bool { return false }
func (m fakeMessage) Qos() byte { return 1 }
func (m fakeMessage) Retained() bool { return true }
func (m fakeMessage) Topic() string { return m.topic }
func (m fakeMessage) MessageID() uint16 { return 0 }
func (m fakeMessage) Payload() []byte { return m.payload }
func (m fakeMessage) Ack() {}
func newTestLink(t *testing.T, dev store.Device, reports *fakeReports, onTamper func(context.Context, store.Device, Tamper)) *Link {
t.Helper()
l, err := New(Options{
Broker: "tcp://broker.invalid:1883",
Log: slog.New(slog.DiscardHandler),
Devices: fakeDevices{dev: dev},
Source: fakeSource{},
Reports: reports,
OnTamper: onTamper,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// The paho client itself is never used by handleStatusMessage.
l.client = nil
return l
}
func TestStatusTamperReachesTheSink(t *testing.T) {
dev := store.Device{ID: 1, Name: "Harry's phone", Topic: "restrict/harry_phone_1/config"}
reports := &fakeReports{}
var got Tamper
var calls int
l := newTestLink(t, dev, reports, func(_ context.Context, d store.Device, tamper Tamper) {
calls++
got = tamper
if d.ID != dev.ID {
t.Errorf("device id = %d, want %d", d.ID, dev.ID)
}
})
payload := `{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed"}}`
l.handleStatusMessage(nil, fakeMessage{topic: "restrict/harry_phone_1/status", payload: []byte(payload)})
if calls != 1 {
t.Fatalf("OnTamper calls = %d, want 1", calls)
}
if got.Kind != "accessibility_disabled" {
t.Errorf("kind = %q", got.Kind)
}
if !reports.presence {
t.Error("presence was not recorded alongside the tamper")
}
}
func TestPlainStatusDoesNotTamper(t *testing.T) {
dev := store.Device{ID: 2, Name: "Finn's phone", Topic: "restrict/finn_phone_2/config"}
reports := &fakeReports{}
l := newTestLink(t, dev, reports, func(context.Context, store.Device, Tamper) {
t.Error("OnTamper called for a plain status")
})
l.handleStatusMessage(nil, fakeMessage{topic: "restrict/finn_phone_2/status", payload: []byte(`{"online":true}`)})
if !reports.presence {
t.Error("presence was not recorded")
}
}