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>
309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
package mqtt
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
|
)
|
|
|
|
func TestTopicHelpers(t *testing.T) {
|
|
const config = "restrict/harry_phone_2/config"
|
|
if got := StatusTopic(config); got != "restrict/harry_phone_2/status" {
|
|
t.Errorf("StatusTopic = %q", got)
|
|
}
|
|
if got := TelemetryTopic(config); got != "restrict/harry_phone_2/telemetry" {
|
|
t.Errorf("TelemetryTopic = %q", got)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
topic string
|
|
suffix string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"restrict/harry_phone_2/status", SuffixStatus, config, true},
|
|
{"restrict/harry_phone_2/telemetry", SuffixTelemetry, config, true},
|
|
{"restrict/harry_phone_2/config", SuffixStatus, "", false},
|
|
{"restrict/status", SuffixStatus, "", false},
|
|
{"restrict/a/b/status", SuffixStatus, "", false},
|
|
{"other/harry_phone_2/status", SuffixStatus, "", false},
|
|
} {
|
|
got, ok := deviceFromTopic(tc.topic, tc.suffix)
|
|
if ok != tc.ok || got != tc.want {
|
|
t.Errorf("deviceFromTopic(%q, %q) = (%q, %v), want (%q, %v)",
|
|
tc.topic, tc.suffix, got, ok, tc.want, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigMarshal(t *testing.T) {
|
|
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
|
b, err := Config{
|
|
V: ConfigVersion,
|
|
DeviceID: 2,
|
|
Name: "harry_phone",
|
|
Platform: store.PlatformAndroid,
|
|
Locked: true,
|
|
Until: &until,
|
|
GeneratedAt: time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC),
|
|
}.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
|
|
// The lists must be present as arrays, never null, so a client can parse
|
|
// this without null checks.
|
|
if !strings.Contains(string(b), `"allow":[]`) || !strings.Contains(string(b), `"block":[]`) {
|
|
t.Errorf("payload has null lists: %s", b)
|
|
}
|
|
|
|
var back Config
|
|
if err := json.Unmarshal(b, &back); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if !back.Locked || back.Until == nil || !back.Until.Equal(until) {
|
|
t.Errorf("round trip lost the lock: %+v", back)
|
|
}
|
|
if back.V != ConfigVersion || back.DeviceID != 2 {
|
|
t.Errorf("round trip lost the identity: %+v", back)
|
|
}
|
|
}
|
|
|
|
func TestParseStatus(t *testing.T) {
|
|
if _, err := ParseStatus(nil); err == nil {
|
|
t.Error("expected an error for an empty payload")
|
|
}
|
|
if _, err := ParseStatus([]byte("{oops")); err == nil {
|
|
t.Error("expected an error for malformed JSON")
|
|
}
|
|
|
|
// An explicit offline (the will payload the broker publishes on a dropped
|
|
// connection) must read as offline.
|
|
off, err := ParseStatus([]byte(`{"online":false}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseStatus: %v", err)
|
|
}
|
|
if off.IsOnline() {
|
|
t.Error(`{"online":false} read as online`)
|
|
}
|
|
|
|
// A report with no "online" field means the device is talking.
|
|
on, err := ParseStatus([]byte(`{"app":"com.spotify.music"}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseStatus: %v", err)
|
|
}
|
|
if !on.IsOnline() {
|
|
t.Error("a report with no online field read as offline")
|
|
}
|
|
if on.App != "com.spotify.music" {
|
|
t.Errorf("App = %q", on.App)
|
|
}
|
|
|
|
at := on.Timestamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
if !at.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("Timestamp with no at field = %v, want the fallback", at)
|
|
}
|
|
at = Status{At: "2026-10-13T07:30:00Z"}.Timestamp(time.Now())
|
|
if at.UTC().Format(time.RFC3339) != "2026-10-13T07:30:00Z" {
|
|
t.Errorf("Timestamp = %v", at)
|
|
}
|
|
}
|
|
|
|
func TestTelemetryRows(t *testing.T) {
|
|
payload := []byte(`{
|
|
"day": "2026-10-13",
|
|
"apps": [{"name": "com.spotify.music", "seconds": 600}, {"name": "", "seconds": 5}],
|
|
"domains": [{"name": "youtube.com", "seconds": 120}]
|
|
}`)
|
|
report, err := ParseTelemetry(payload)
|
|
if err != nil {
|
|
t.Fatalf("ParseTelemetry: %v", err)
|
|
}
|
|
rows := report.Rows()
|
|
if len(rows) != 2 {
|
|
t.Fatalf("Rows = %+v, want 2 rows", rows)
|
|
}
|
|
// Apps first, then domains.
|
|
if rows[0].App != "com.spotify.music" || rows[0].Seconds != 600 {
|
|
t.Errorf("rows[0] = %+v", rows[0])
|
|
}
|
|
if rows[1].Domain != "youtube.com" || rows[1].Seconds != 120 {
|
|
t.Errorf("rows[1] = %+v", rows[1])
|
|
}
|
|
if got := report.DayOrDefault(time.Now()); got != "2026-10-13" {
|
|
t.Errorf("DayOrDefault = %q, want the reported day", got)
|
|
}
|
|
|
|
// The flat single-row form, and a bad day falling back to today.
|
|
flat, err := ParseTelemetry([]byte(`{"app":"roblox","seconds":60,"day":"yesterday"}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseTelemetry flat: %v", err)
|
|
}
|
|
flatRows := flat.Rows()
|
|
if len(flatRows) != 1 || flatRows[0].App != "roblox" {
|
|
t.Fatalf("flat Rows = %+v", flatRows)
|
|
}
|
|
now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
if got := flat.DayOrDefault(now); got != "2026-03-04" {
|
|
t.Errorf("DayOrDefault with a bad day = %q, want 2026-03-04", got)
|
|
}
|
|
|
|
// Negative totals are dropped, not stored.
|
|
bad, err := ParseTelemetry([]byte(`{"apps":[{"name":"x","seconds":-1}]}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseTelemetry: %v", err)
|
|
}
|
|
if len(bad.Rows()) != 0 {
|
|
t.Errorf("negative usage stored: %+v", bad.Rows())
|
|
}
|
|
|
|
if _, err := ParseTelemetry([]byte("not json")); err == nil {
|
|
t.Error("expected an error for malformed JSON")
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsIncompleteOptions(t *testing.T) {
|
|
if _, err := New(Options{Broker: "tcp://127.0.0.1:1883"}); err == nil {
|
|
t.Error("expected an error without a device lookup, source and sink")
|
|
}
|
|
_, err := New(Options{
|
|
Broker: "tls://broker:8883",
|
|
CAFile: "/nonexistent/ca.crt",
|
|
Devices: stubLookup{},
|
|
Source: stubSource{},
|
|
Reports: stubSink{},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected an error for a missing CA bundle")
|
|
}
|
|
if !strings.Contains(err.Error(), "ca.crt") {
|
|
t.Errorf("error = %v, want it to name the CA file", err)
|
|
}
|
|
}
|
|
|
|
type stubLookup struct{}
|
|
|
|
func (stubLookup) DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
type stubSource struct{}
|
|
|
|
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
|
|
|
func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
|
return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil
|
|
}
|
|
|
|
type stubSink struct{}
|
|
|
|
func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// stateSource returns a fixed effective state, to prove the published payload
|
|
// without a broker.
|
|
type stateSource struct {
|
|
eff store.EffectiveState
|
|
}
|
|
|
|
func (s stateSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
|
|
|
func (s stateSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
|
eff := s.eff
|
|
eff.DeviceID = dev.ID
|
|
return eff, nil
|
|
}
|
|
|
|
// TestBuildConfigAllowlistIsolation is the Phase 6 definition of done: with
|
|
// Spotify allowlisted while other apps are blocked during an active schedule
|
|
// window, the retained payload says Spotify is allowed and the others are
|
|
// blocked.
|
|
func TestBuildConfigAllowlistIsolation(t *testing.T) {
|
|
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
|
link, err := New(Options{
|
|
Broker: "tcp://127.0.0.1:1883",
|
|
Source: stateSource{eff: store.EffectiveState{
|
|
Locked: true,
|
|
Until: &until,
|
|
Reason: "schedule",
|
|
Allow: []string{"com.spotify.music"},
|
|
Block: []string{"com.youtube.android", "roblox.exe"},
|
|
}},
|
|
Devices: stubLookup{},
|
|
Reports: stubSink{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
payload, err := link.buildConfig(context.Background(), store.Device{
|
|
ID: 7, Name: "Harry's phone", Platform: store.PlatformAndroid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildConfig: %v", err)
|
|
}
|
|
|
|
var cfg Config
|
|
if err := json.Unmarshal(payload, &cfg); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if !cfg.Locked || cfg.Reason != "schedule" {
|
|
t.Errorf("payload = %s, want locked by schedule", payload)
|
|
}
|
|
if cfg.Until == nil || !cfg.Until.Equal(until) {
|
|
t.Errorf("until = %v, want %v", cfg.Until, until)
|
|
}
|
|
if len(cfg.Allow) != 1 || cfg.Allow[0] != "com.spotify.music" {
|
|
t.Errorf("allow = %v, want Spotify allowed", cfg.Allow)
|
|
}
|
|
if len(cfg.Block) != 2 {
|
|
t.Errorf("block = %v, want the other apps blocked", cfg.Block)
|
|
}
|
|
// The JSON is the contract the kids' clients read.
|
|
for _, want := range []string{
|
|
`"locked":true`, `"reason":"schedule"`,
|
|
`"allow":["com.spotify.music"]`,
|
|
} {
|
|
if !strings.Contains(string(payload), want) {
|
|
t.Errorf("payload %s missing %s", payload, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseStatusTamper(t *testing.T) {
|
|
payload := []byte(`{"online":true,"app":"com.android.settings",` +
|
|
`"tamper":{"kind":"accessibility_disabled","detail":"service turned off",` +
|
|
`"at":"2026-10-13T08:00:00Z"},"at":"2026-10-13T08:00:01Z"}`)
|
|
st, err := ParseStatus(payload)
|
|
if err != nil {
|
|
t.Fatalf("ParseStatus: %v", err)
|
|
}
|
|
if !st.HasTamper() {
|
|
t.Fatal("HasTamper = false, want true")
|
|
}
|
|
if st.Tamper.Kind != "accessibility_disabled" {
|
|
t.Errorf("kind = %q", st.Tamper.Kind)
|
|
}
|
|
if st.Tamper.Detail != "service turned off" {
|
|
t.Errorf("detail = %q", st.Tamper.Detail)
|
|
}
|
|
|
|
plain, err := ParseStatus([]byte(`{"online":false}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseStatus plain: %v", err)
|
|
}
|
|
if plain.HasTamper() {
|
|
t.Error("plain status reported a tamper")
|
|
}
|
|
}
|