Files
internet_use_control/internal/store/devices_test.go
Sam Rolfe 7c88d3f52b Phase 2: device registry, dashboard cards, manual lock/unlock, audit
- Device registry: add/edit name, child and platform, persisted in devices.
- Stable per-device topic derived from name + id, unchanged by renames.
- Desired state persisted in state; timed locks expire and are audited.
- Dashboard cards: name, child, platform, status dot, state and time left.
- Manual lock/unlock forms with CSRF; every change appends to audit (FR-15).
- Append-only activity list on the dashboard. No hard deletes (rule 10).
- Store and handler tests; version bumped to 0.2.0-phase2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:18:02 +11:00

217 lines
5.5 KiB
Go

package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
func testStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "control.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
return st
}
func TestCreateDevice(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev, err := st.CreateDevice(ctx, "Harry's Pixel", "Harry", "android", "sam")
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
if dev.ID == 0 {
t.Error("device id was not assigned")
}
if dev.Topic != "restrict/harry_s_pixel_1/config" {
t.Errorf("topic = %q, want restrict/harry_s_pixel_1/config", dev.Topic)
}
if dev.Locked {
t.Error("new device should start unlocked")
}
if dev.Until != nil {
t.Error("new device should have no lock end time")
}
// Creation is audited.
entries, err := st.ListAudit(ctx, 10)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
if len(entries) != 1 || entries[0].Action != "device.created" {
t.Errorf("audit = %+v, want one device.created", entries)
}
// A second device gets its own topic.
dev2, err := st.CreateDevice(ctx, "Finn PC", "Finn", "windows", "sam")
if err != nil {
t.Fatalf("CreateDevice second: %v", err)
}
if dev2.Topic == dev.Topic {
t.Errorf("topics must differ, both %q", dev.Topic)
}
}
func TestCreateDeviceValidation(t *testing.T) {
ctx := context.Background()
st := testStore(t)
for _, tc := range []struct {
name string
device string
child string
platform string
}{
{"empty name", " ", "Harry", "android"},
{"empty child", "Phone", " ", "android"},
{"bad platform", "Phone", "Harry", "linux"},
} {
if _, err := st.CreateDevice(ctx, tc.device, tc.child, tc.platform, "sam"); err == nil {
t.Errorf("%s: expected an error, got nil", tc.name)
}
}
}
func TestUpdateDeviceKeepsTopic(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev, err := st.CreateDevice(ctx, "Old Name", "Harry", "android", "sam")
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
updated, err := st.UpdateDevice(ctx, dev.ID, "New Name", "Finn", "windows", "sam")
if err != nil {
t.Fatalf("UpdateDevice: %v", err)
}
if updated.Name != "New Name" || updated.Child != "Finn" || updated.Platform != "windows" {
t.Errorf("updated = %+v", updated)
}
if updated.Topic != dev.Topic {
t.Errorf("topic changed on rename: %q -> %q", dev.Topic, updated.Topic)
}
if _, err := st.UpdateDevice(ctx, 999, "X", "Y", "android", "sam"); !errors.Is(err, ErrNotFound) {
t.Errorf("update missing device: err = %v, want ErrNotFound", err)
}
}
func TestSetDeviceLockAndAudit(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev, err := st.CreateDevice(ctx, "Harry Phone", "Harry", "android", "sam")
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
until := time.Now().Add(30 * time.Minute).UTC().Truncate(time.Second)
locked, err := st.SetDeviceLock(ctx, dev.ID, true, &until, "sam", "state.locked")
if err != nil {
t.Fatalf("SetDeviceLock: %v", err)
}
if !locked.Locked {
t.Fatal("device should be locked")
}
if locked.Until == nil || !locked.Until.Equal(until) {
t.Errorf("Until = %v, want %v", locked.Until, until)
}
// The state survives a fresh read.
again, err := st.GetDevice(ctx, dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if !again.Locked || again.Until == nil {
t.Errorf("state not persisted: %+v", again)
}
unlocked, err := st.SetDeviceLock(ctx, dev.ID, false, nil, "sam", "state.unlocked")
if err != nil {
t.Fatalf("unlock: %v", err)
}
if unlocked.Locked || unlocked.Until != nil {
t.Errorf("unlocked = %+v, want locked=false until=nil", unlocked)
}
entries, err := st.ListAudit(ctx, 10)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
var created, lock, unlock int
for _, e := range entries {
switch e.Action {
case "device.created":
created++
case "state.locked":
lock++
case "state.unlocked":
unlock++
}
}
if created != 1 || lock != 1 || unlock != 1 {
t.Errorf("audit counts created=%d locked=%d unlocked=%d", created, lock, unlock)
}
if _, err := st.SetDeviceLock(ctx, 424242, true, nil, "sam", "state.locked"); !errors.Is(err, ErrNotFound) {
t.Errorf("lock missing device: err = %v, want ErrNotFound", err)
}
}
func TestExpireLocks(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev, err := st.CreateDevice(ctx, "Harry Phone", "Harry", "android", "sam")
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
past := time.Now().Add(-time.Minute).UTC()
if _, err := st.SetDeviceLock(ctx, dev.ID, true, &past, "sam", "state.locked"); err != nil {
t.Fatalf("SetDeviceLock: %v", err)
}
n, err := st.ExpireLocks(ctx)
if err != nil {
t.Fatalf("ExpireLocks: %v", err)
}
if n != 1 {
t.Fatalf("ExpireLocks = %d, want 1", n)
}
dev, err = st.GetDevice(ctx, dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if dev.Locked || dev.Until != nil {
t.Errorf("expired lock not cleared: %+v", dev)
}
// A second sweep is a no-op.
if n, err := st.ExpireLocks(ctx); err != nil || n != 0 {
t.Errorf("second ExpireLocks = (%d, %v), want (0, nil)", n, err)
}
}
func TestSlug(t *testing.T) {
for in, want := range map[string]string{
"Harry's Pixel": "harry_s_pixel",
"Finn PC": "finn_pc",
" ": "device",
"***": "device",
"a__b": "a_b",
} {
if got := slug(in); got != want {
t.Errorf("slug(%q) = %q, want %q", in, got, want)
}
}
}