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