- 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>
282 lines
8.4 KiB
Go
282 lines
8.4 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)
|
|
}
|
|
}
|
|
}
|