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) } } }