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>
214 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|