f55747a281
Lands the bottom three layers of Phase 1: P1-08 internal/api: protocol_version + envelope + every WS message shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc). Wire-format tests pin the JSON shape so a rename here breaks tests instead of silently breaking the agent. P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite, embed.FS + a tiny version table for hand-rolled migrations. 0001_initial.sql covers every table from spec.md §5 plus enrollment_tokens and host_schedule_version. Typed accessors for users / sessions / enrollment / audit. WAL + foreign_keys + busy_timeout on by default. P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with per-message random nonce. Key file lifecycle (generate + refuse-to-overwrite, load with size validation). Optional additionalData binds ciphertext to the row that owns it. P1-04 internal/auth (partial — passwords + tokens; sessions middleware lands with the HTTP handlers): argon2id following RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify. HashToken stores SHA-256 of session/agent/enrollment tokens so a stolen DB doesn't hand over credentials. Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires it); CI + Dockerfile + README updated. Markdown lint diagnostics on tasks.md cleared. All packages tested. ~70 new tests pass in <1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 lines
2.3 KiB
Go
94 lines
2.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// openTestStore opens an isolated file-backed db in a t.TempDir.
|
|
// In-memory + shared-cache works too but file makes failures easier
|
|
// to inspect when a test panics.
|
|
func openTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s, err := Open(context.Background(), filepath.Join(dir, "rm.db"))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestOpenAppliesMigrations(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
|
|
row := s.DB().QueryRow(`SELECT MAX(version) FROM schema_version`)
|
|
var v int
|
|
if err := row.Scan(&v); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if v < 1 {
|
|
t.Fatalf("expected at least migration 1 applied, got %d", v)
|
|
}
|
|
|
|
// Spot-check a few tables exist with expected columns.
|
|
tables := []string{"users", "sessions", "hosts", "repos",
|
|
"credentials", "schedules", "jobs", "job_logs",
|
|
"snapshots", "alerts", "audit_log",
|
|
"enrollment_tokens", "host_schedule_version"}
|
|
for _, tbl := range tables {
|
|
row := s.DB().QueryRow(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl)
|
|
var got string
|
|
if err := row.Scan(&got); err != nil {
|
|
t.Errorf("table %q missing: %v", tbl, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "rm.db")
|
|
|
|
for i := 0; i < 3; i++ {
|
|
s, err := Open(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("open #%d: %v", i, err)
|
|
}
|
|
_ = s.Close()
|
|
}
|
|
|
|
s, err := Open(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("final open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
row := s.DB().QueryRow(`SELECT COUNT(*) FROM schema_version`)
|
|
var n int
|
|
if err := row.Scan(&n); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("re-running migrations should not insert duplicate rows; got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestForeignKeysEnforced(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
|
|
// Inserting a session with a non-existent user should fail because
|
|
// FKs are on. Without the pragma, SQLite silently accepts this.
|
|
_, err := s.DB().Exec(
|
|
`INSERT INTO sessions (id, user_id, created_at, expires_at)
|
|
VALUES (?, ?, datetime('now'), datetime('now','+1 hour'))`,
|
|
"sess1", "no-such-user")
|
|
if err == nil {
|
|
t.Fatal("expected FK violation, got nil")
|
|
}
|
|
}
|