phase 1 foundations: api types, store, crypto, auth
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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user