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>
This commit is contained in:
434
internal/store/devices.go
Normal file
434
internal/store/devices.go
Normal file
@@ -0,0 +1,434 @@
|
||||
// Device registry and desired state (AGENTS.md §7 devices/state, §11 Phase 2).
|
||||
//
|
||||
// The Control Service is the only writer of device config (SYSTEM_MAP.md).
|
||||
// Every mutation appends to the audit log (FR-15), and nothing is ever
|
||||
// hard-deleted (§10 rule 10).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a device id does not exist.
|
||||
var ErrNotFound = errors.New("device not found")
|
||||
|
||||
// Platform values allowed by the devices.platform CHECK constraint.
|
||||
const (
|
||||
PlatformAndroid = "android"
|
||||
PlatformWindows = "windows"
|
||||
)
|
||||
|
||||
// ValidPlatform reports whether p is one of the supported platforms.
|
||||
func ValidPlatform(p string) bool {
|
||||
return p == PlatformAndroid || p == PlatformWindows
|
||||
}
|
||||
|
||||
// Device is one registered device plus its desired state.
|
||||
type Device struct {
|
||||
ID int64
|
||||
Name string
|
||||
Child string
|
||||
Platform string
|
||||
Topic string
|
||||
Online bool
|
||||
LastSeen *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// Desired state, from the state table.
|
||||
Locked bool
|
||||
Until *time.Time
|
||||
StateAt time.Time
|
||||
}
|
||||
|
||||
// AuditEntry is one row of the append-only audit log (FR-15).
|
||||
type AuditEntry struct {
|
||||
ID int64
|
||||
Actor string
|
||||
Action string
|
||||
DeviceID *int64
|
||||
DeviceName string
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// selectDevice is the shared projection for reading a device with its state.
|
||||
const selectDevice = `
|
||||
SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen,
|
||||
d.created_at, d.updated_at,
|
||||
COALESCE(s.locked, 0), s.until, s.updated_at
|
||||
FROM devices d
|
||||
LEFT JOIN state s ON s.device_id = d.id`
|
||||
|
||||
func scanDevice(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*Device, error) {
|
||||
var (
|
||||
dev Device
|
||||
online int
|
||||
lastSeen, until, stateAt sql.NullString
|
||||
createdAt, updatedAt sql.NullString
|
||||
)
|
||||
if err := row.Scan(
|
||||
&dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online,
|
||||
&lastSeen, &createdAt, &updatedAt,
|
||||
&dev.Locked, &until, &stateAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dev.Online = online != 0
|
||||
dev.LastSeen = parseTimePtr(lastSeen)
|
||||
dev.Until = parseTimePtr(until)
|
||||
if t := parseTime(createdAt.String); !t.IsZero() {
|
||||
dev.CreatedAt = t
|
||||
}
|
||||
if t := parseTime(updatedAt.String); !t.IsZero() {
|
||||
dev.UpdatedAt = t
|
||||
}
|
||||
if t := parseTime(stateAt.String); !t.IsZero() {
|
||||
dev.StateAt = t
|
||||
}
|
||||
return &dev, nil
|
||||
}
|
||||
|
||||
// ListDevices returns every registered device with its desired state, in
|
||||
// registration order. Expired locks are already cleared by ExpireLocks.
|
||||
func (s *Store) ListDevices(ctx context.Context) ([]Device, error) {
|
||||
rows, err := s.db.QueryContext(ctx, selectDevice+` ORDER BY d.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Device, 0, 8)
|
||||
for rows.Next() {
|
||||
dev, err := scanDevice(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan device: %w", err)
|
||||
}
|
||||
out = append(out, *dev)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDevice returns one device with its state, or ErrNotFound.
|
||||
func (s *Store) GetDevice(ctx context.Context, id int64) (*Device, error) {
|
||||
dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
// CreateDevice inserts a device and its initial (unlocked) state row, and
|
||||
// appends one audit entry. The topic is derived from the name and the new id,
|
||||
// so it is stable across renames.
|
||||
func (s *Store) CreateDevice(ctx context.Context, name, child, platform, actor string) (*Device, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
child = strings.TrimSpace(child)
|
||||
platform = strings.ToLower(strings.TrimSpace(platform))
|
||||
|
||||
if name == "" {
|
||||
return nil, errors.New("device name is required")
|
||||
}
|
||||
if len(name) > 80 {
|
||||
return nil, errors.New("device name must be 80 characters or fewer")
|
||||
}
|
||||
if child == "" {
|
||||
return nil, errors.New("child is required")
|
||||
}
|
||||
if len(child) > 40 {
|
||||
return nil, errors.New("child must be 40 characters or fewer")
|
||||
}
|
||||
if !ValidPlatform(platform) {
|
||||
return nil, errors.New(`platform must be "android" or "windows"`)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// The topic needs the new id, so insert with a placeholder then set it.
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO devices (name, child, platform, topic) VALUES (?, ?, ?, '')`,
|
||||
name, child, platform)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device id: %w", err)
|
||||
}
|
||||
topic := fmt.Sprintf("restrict/%s_%d/config", slug(name), id)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE devices SET topic = ? WHERE id = ?`, topic, id); err != nil {
|
||||
return nil, fmt.Errorf("set topic: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO state (device_id, locked, until) VALUES (?, 0, NULL)`, id); err != nil {
|
||||
return nil, fmt.Errorf("insert state: %w", err)
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor, "device.created", &id,
|
||||
fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit device: %w", err)
|
||||
}
|
||||
return s.GetDevice(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateDevice renames or reassigns a device. The topic and id never change, so
|
||||
// a device's MQTT identity is stable. It appends one audit entry.
|
||||
func (s *Store) UpdateDevice(ctx context.Context, id int64, name, child, platform, actor string) (*Device, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
child = strings.TrimSpace(child)
|
||||
platform = strings.ToLower(strings.TrimSpace(platform))
|
||||
|
||||
if name == "" {
|
||||
return nil, errors.New("device name is required")
|
||||
}
|
||||
if len(name) > 80 {
|
||||
return nil, errors.New("device name must be 80 characters or fewer")
|
||||
}
|
||||
if child == "" {
|
||||
return nil, errors.New("child is required")
|
||||
}
|
||||
if len(child) > 40 {
|
||||
return nil, errors.New("child must be 40 characters or fewer")
|
||||
}
|
||||
if !ValidPlatform(platform) {
|
||||
return nil, errors.New(`platform must be "android" or "windows"`)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE devices SET name = ?, child = ?, platform = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
name, child, platform, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update device: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update device: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := s.Audit(ctx, actor, "device.updated", &id,
|
||||
fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDevice(ctx, id)
|
||||
}
|
||||
|
||||
// SetDeviceLock writes the desired lock state for one device and appends one
|
||||
// audit entry in the same transaction. When locked is false, until is ignored.
|
||||
func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until *time.Time, actor, action string) (*Device, error) {
|
||||
if !locked {
|
||||
until = nil
|
||||
}
|
||||
if locked && until != nil {
|
||||
t := until.UTC()
|
||||
until = &t
|
||||
}
|
||||
|
||||
if _, err := s.GetDevice(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set state: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var untilArg any
|
||||
detail := "unlocked"
|
||||
if locked {
|
||||
detail = "locked"
|
||||
if until != nil {
|
||||
untilArg = until.Format(time.RFC3339)
|
||||
detail = "locked until " + until.Format(time.RFC3339)
|
||||
} else {
|
||||
detail = "locked (no end time)"
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO state (device_id, locked, until, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
locked = excluded.locked,
|
||||
until = excluded.until,
|
||||
updated_at = datetime('now')`,
|
||||
id, boolToInt(locked), untilArg); err != nil {
|
||||
return nil, fmt.Errorf("set state: %w", err)
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit state: %w", err)
|
||||
}
|
||||
return s.GetDevice(ctx, id)
|
||||
}
|
||||
|
||||
// ExpireLocks unlocks every device whose timed lock has passed and appends one
|
||||
// audit entry per device. It returns how many locks it cleared. It is safe to
|
||||
// call often.
|
||||
func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT device_id FROM state WHERE locked = 1 AND until IS NOT NULL AND until <= ?`, now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire locks: %w", err)
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("scan expired lock: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("expire locks: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
if _, err := s.SetDeviceLock(ctx, id, false, nil, "system", "state.expired"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
|
||||
// ListAudit returns the newest audit entries first (FR-15).
|
||||
func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.id, a.actor, a.action, a.device_id, COALESCE(d.name, ''),
|
||||
COALESCE(a.detail, ''), a.created_at
|
||||
FROM audit a
|
||||
LEFT JOIN devices d ON d.id = a.device_id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AuditEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
e AuditEntry
|
||||
deviceID sql.NullInt64
|
||||
created sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &deviceID,
|
||||
&e.DeviceName, &e.Detail, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan audit: %w", err)
|
||||
}
|
||||
if deviceID.Valid {
|
||||
id := deviceID.Int64
|
||||
e.DeviceID = &id
|
||||
}
|
||||
e.CreatedAt = parseTime(created.String)
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list audit: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// insertAudit appends one audit row using the given executor (a transaction or
|
||||
// the database handle), so callers can audit atomically with a state change.
|
||||
func insertAudit(ctx context.Context, ex interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}, actor, action string, deviceID *int64, detail string) error {
|
||||
var id any
|
||||
if deviceID != nil {
|
||||
id = *deviceID
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx,
|
||||
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
|
||||
actor, action, id, detail); err != nil {
|
||||
return fmt.Errorf("audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseTime accepts the RFC3339 strings this package writes and the
|
||||
// "YYYY-MM-DD HH:MM:SS" strings SQLite's datetime() writes.
|
||||
func parseTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseTimePtr(ns sql.NullString) *time.Time {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
t := parseTime(ns.String)
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// slug makes a short ASCII token for an MQTT topic from a device name.
|
||||
func slug(name string) string {
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
default:
|
||||
if !lastUnderscore && b.Len() > 0 {
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "_")
|
||||
if out == "" {
|
||||
return "device"
|
||||
}
|
||||
return out
|
||||
}
|
||||
216
internal/store/devices_test.go
Normal file
216
internal/store/devices_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,14 +112,7 @@ func migrationFiles() ([]migration, error) {
|
||||
|
||||
// Audit appends one row to the append-only audit log.
|
||||
func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
|
||||
actor, action, deviceID, detail,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
return insertAudit(ctx, s.db, actor, action, deviceID, detail)
|
||||
}
|
||||
|
||||
// CountDevices returns the number of registered devices (0 in Phase 1).
|
||||
|
||||
Reference in New Issue
Block a user