Files
internet_use_control/internal/store/overrides_test.go
Sam Rolfe 4f233a8012 Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)
Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11:
the shared device keycode and the versioned admin HTTP API the separate Kotlin
admin app will call.

- store: migration 004 adds state.grant_until and a settings table. New
  overrides.go records every override in `overrides` and verifies the shared
  keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also
  clears an expired grant, and NextScheduleBoundary includes the grant end so a
  device re-locks on time.
- effective state: a grant unlocks a device inside a schedule window; a manual
  lock clears a grant and a grant clears a manual lock. The retained config
  payload gains grant_until (additive, v stays 1).
- web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by
  CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device
  keycode endpoint POST /api/v1/device/override with per-address throttling.
  A state change is published retained before the API responds.
- cmd/control: `keycode` subcommand and start-up seeding from
  CONTROL_DEVICE_KEYCODE_HASH.
- deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done:
  an API lock reaches a real broker subscriber in well under a second.
- Version 0.6.0-phase8 (compose, Makefile, docs).

Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler
1 ms, request to retained publish accepted).

Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:27:46 +11:00

214 lines
7.0 KiB
Go

package store
import (
"context"
"errors"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// TestGrantOverridesScheduleAndReLocks is the Phase 8 grant contract: a grant
// unlocks a device inside an active schedule window, and the window re-applies
// the moment the grant ends.
func TestGrantOverridesScheduleAndReLocks(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) // Tuesday
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
{Weekday: weekdayMon0(now), Start: "19:00", End: "21:00"},
}, "sam"); err != nil {
t.Fatalf("ReplaceSchedules: %v", err)
}
// Before the grant the window blocks the device.
eff, err := st.EffectiveState(ctx, *dev, now)
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if !eff.Locked || eff.Reason != "schedule" {
t.Fatalf("before grant: %+v, want locked by schedule", eff)
}
// Grant time: the device is unlocked even though the window is active.
grantUntil := now.Add(15 * time.Minute)
granted, err := st.SetDeviceGrant(ctx, dev.ID, grantUntil, "admin-api", "override.granted")
if err != nil {
t.Fatalf("SetDeviceGrant: %v", err)
}
eff, err = st.EffectiveState(ctx, *granted, now)
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if eff.Locked || eff.Reason != "grant" {
t.Fatalf("during grant: %+v, want unlocked by grant", eff)
}
if eff.GrantUntil == nil || !eff.GrantUntil.Equal(grantUntil) {
t.Errorf("grant until = %v, want %v", eff.GrantUntil, grantUntil)
}
// The grant end is a republish boundary, so the device re-locks on time.
next, err := st.NextScheduleBoundary(ctx, now)
if err != nil {
t.Fatalf("NextScheduleBoundary: %v", err)
}
if next == nil || !next.Equal(grantUntil) {
t.Errorf("next boundary = %v, want the grant end %v", next, grantUntil)
}
// After the grant ends the schedule applies again.
eff, err = st.EffectiveState(ctx, *granted, grantUntil.Add(time.Minute))
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if !eff.Locked || eff.Reason != "schedule" {
t.Fatalf("after grant: %+v, want locked by schedule again", eff)
}
// The override history holds the grant.
overrides, err := st.ListOverrides(ctx, dev.ID, 10)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if len(overrides) != 1 || overrides[0].Action != "override.granted" || overrides[0].Actor != "admin-api" {
t.Fatalf("overrides = %+v, want one grant", overrides)
}
if overrides[0].Until == nil || !overrides[0].Until.Equal(grantUntil) {
t.Errorf("override until = %v, want %v", overrides[0].Until, grantUntil)
}
}
func TestLockClearsGrant(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Finn PC", "Finn", "windows")
until := time.Now().Add(time.Hour).UTC()
dev, err := st.SetDeviceGrant(ctx, dev.ID, until, "admin-api", "override.granted")
if err != nil {
t.Fatalf("SetDeviceGrant: %v", err)
}
if dev.GrantUntil == nil {
t.Fatal("grant was not stored")
}
dev, err = st.SetDeviceLock(ctx, dev.ID, true, nil, "admin-api", "state.locked")
if err != nil {
t.Fatalf("SetDeviceLock: %v", err)
}
if !dev.Locked || dev.GrantUntil != nil {
t.Fatalf("after lock: locked=%v grant=%v, want locked with no grant", dev.Locked, dev.GrantUntil)
}
// Both actions are in the override history.
overrides, err := st.ListOverrides(ctx, dev.ID, 10)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if len(overrides) != 2 {
t.Fatalf("overrides = %d, want 2", len(overrides))
}
}
func TestExpireClearsGrant(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
// Set an already-expired grant directly: SetDeviceGrant refuses the past,
// which is the right guard for callers, but the cleanup path must still work.
past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
if _, err := st.DB().ExecContext(ctx,
`UPDATE state SET grant_until = ? WHERE device_id = ?`, past, dev.ID); err != nil {
t.Fatalf("seed past grant: %v", err)
}
if _, err := st.ExpireLocks(ctx); err != nil {
t.Fatalf("ExpireLocks: %v", err)
}
got, err := st.GetDevice(ctx, dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if got.GrantUntil != nil {
t.Errorf("grant_until = %v after expiry, want nil", got.GrantUntil)
}
}
func TestSetDeviceGrantRejectsPastAndMissing(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
if _, err := st.SetDeviceGrant(ctx, dev.ID, time.Now().Add(-time.Minute), "admin-api", "override.granted"); err == nil {
t.Error("expected a past grant to be refused")
}
if _, err := st.SetDeviceGrant(ctx, 999, time.Now().Add(time.Hour), "admin-api", "override.granted"); !errors.Is(err, ErrNotFound) {
t.Errorf("missing device: err = %v, want ErrNotFound", err)
}
}
func TestDeviceKeycode(t *testing.T) {
ctx := context.Background()
st := testStore(t)
if _, err := st.KeycodeHash(ctx); !errors.Is(err, ErrNoKeycode) {
t.Fatalf("KeycodeHash with none: err = %v, want ErrNoKeycode", err)
}
if st.VerifyKeycode(ctx, "1234") {
t.Error("an unset keycode must never verify")
}
hash, err := bcrypt.GenerateFromPassword([]byte("2468"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
if err := st.SetKeycodeHash(ctx, string(hash), "admin"); err != nil {
t.Fatalf("SetKeycodeHash: %v", err)
}
if !st.VerifyKeycode(ctx, "2468") {
t.Error("correct keycode did not verify")
}
if st.VerifyKeycode(ctx, "8642") {
t.Error("wrong keycode verified")
}
if st.VerifyKeycode(ctx, "") {
t.Error("empty keycode verified")
}
// Invalid hashes are refused and do not replace the working one.
if err := st.SetKeycodeHash(ctx, "not-a-hash", "admin"); err == nil {
t.Error("expected an invalid hash to be refused")
}
if !st.VerifyKeycode(ctx, "2468") {
t.Error("the working keycode was replaced by an invalid one")
}
// Seeding never overwrites an existing code: a runtime change made with
// `control keycode` survives a restart even if the environment still holds
// the old value.
other, _ := bcrypt.GenerateFromPassword([]byte("1357"), bcrypt.MinCost)
if seeded, err := st.SeedKeycodeHash(ctx, string(other)); err != nil || seeded {
t.Fatalf("SeedKeycodeHash over existing = (%v, %v), want (false, nil)", seeded, err)
}
if !st.VerifyKeycode(ctx, "2468") || st.VerifyKeycode(ctx, "1357") {
t.Error("seeding must not overwrite a configured keycode")
}
// A fresh store takes the environment value once.
fresh := testStore(t)
seeded, err := fresh.SeedKeycodeHash(ctx, string(other))
if err != nil || !seeded {
t.Fatalf("SeedKeycodeHash on empty store = (%v, %v), want (true, nil)", seeded, err)
}
if !fresh.VerifyKeycode(ctx, "1357") {
t.Error("seeded keycode did not verify")
}
if seeded, err := fresh.SeedKeycodeHash(ctx, string(other)); err != nil || seeded {
t.Errorf("SeedKeycodeHash repeat = (%v, %v), want (false, nil)", seeded, err)
}
}