Phase 6: schedules, blocklists, allowlists (editors + effective state)

- 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>
This commit is contained in:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -196,8 +196,8 @@ type stubSource struct{}
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) {
return nil, 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{}
@@ -209,3 +209,73 @@ func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.
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)
}
}
}