Adds migrations/0002_oauth_tables.sql per design.md §4.2: clients, auth_codes, access_tokens, refresh_tokens. Cascading foreign keys guarantee that revoking a client tears down every dependent row, and that a refresh token can never outlive its access token. Storage choices: - Broker access/refresh tokens stored as hex-encoded SHA-256 hashes; plaintext leaves the broker exactly once (when handed to the MCP client). Lookups by hash are O(log n) via the PK index. - Forgejo tokens stored cleartext (subprocess spawning needs them). At-rest protection is the volume permissions + optional encrypted volume; application-layer encryption is tracked as backlog item -sd4. - Timestamps are unix epoch INTEGERs, set by the application — keeps deadline comparisons trivial and lets phase 5c inject a test clock. - Tables are not STRICT to stay consistent with the phase-1 broker_meta table; converting both is a future cleanup if we want it. Tests verify column sets via PRAGMA table_info, expected indexes are present, the FK CASCADE works in both directions (client → tokens, and access_token → refresh_token), and the oauth_schema_version marker is written. Existing migration-count assertions parameterised on embeddedMigrationCount so adding a third migration only needs that constant bumped. Closes forgejo-mcp-broker-cpb. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
3.9 KiB
Go
154 lines
3.9 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/store"
|
|
)
|
|
|
|
func tempStorePath(t *testing.T) string {
|
|
t.Helper()
|
|
return filepath.Join(t.TempDir(), "broker.db")
|
|
}
|
|
|
|
// embeddedMigrationCount is the number of *.sql files shipped under
|
|
// internal/store/migrations/. Bump it when adding a migration so the
|
|
// idempotency tests stay accurate.
|
|
const embeddedMigrationCount = 2
|
|
|
|
func TestOpen_FreshDB_AppliesMigrations(t *testing.T) {
|
|
ctx := t.Context()
|
|
s, err := store.Open(ctx, tempStorePath(t))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
// broker_meta should exist and carry the schema_version row.
|
|
var version string
|
|
row := s.DB().QueryRowContext(ctx,
|
|
`SELECT value FROM broker_meta WHERE key = ?`, "schema_version")
|
|
if err := row.Scan(&version); err != nil {
|
|
t.Fatalf("reading schema_version: %v", err)
|
|
}
|
|
if version != "1" {
|
|
t.Errorf("schema_version = %q, want %q", version, "1")
|
|
}
|
|
|
|
// schema_migrations should have recorded every embedded migration.
|
|
var count int
|
|
row = s.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`)
|
|
if err := row.Scan(&count); err != nil {
|
|
t.Fatalf("counting schema_migrations: %v", err)
|
|
}
|
|
if count != embeddedMigrationCount {
|
|
t.Errorf("schema_migrations row count = %d, want %d", count, embeddedMigrationCount)
|
|
}
|
|
}
|
|
|
|
func TestOpen_ReopenIsIdempotent(t *testing.T) {
|
|
ctx := t.Context()
|
|
path := tempStorePath(t)
|
|
|
|
s1, err := store.Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("first Open: %v", err)
|
|
}
|
|
if err := s1.Close(); err != nil {
|
|
t.Fatalf("first Close: %v", err)
|
|
}
|
|
|
|
s2, err := store.Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("second Open: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
// Still exactly one applied migration.
|
|
var count int
|
|
if err := s2.DB().QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
|
|
t.Fatalf("counting schema_migrations: %v", err)
|
|
}
|
|
if count != embeddedMigrationCount {
|
|
t.Errorf("after reopen, schema_migrations count = %d, want %d", count, embeddedMigrationCount)
|
|
}
|
|
}
|
|
|
|
func TestOpen_NonexistentParent(t *testing.T) {
|
|
// Config.Validate normally catches this, but the store must still fail
|
|
// clearly when called with an unreachable path.
|
|
ctx := t.Context()
|
|
path := filepath.Join(t.TempDir(), "does", "not", "exist", "broker.db")
|
|
s, err := store.Open(ctx, path)
|
|
if err == nil {
|
|
_ = s.Close()
|
|
t.Fatal("Open should fail for nonexistent parent directory")
|
|
}
|
|
}
|
|
|
|
func TestPing_AfterOpen(t *testing.T) {
|
|
ctx := t.Context()
|
|
s, err := store.Open(ctx, tempStorePath(t))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
if err := s.Ping(ctx); err != nil {
|
|
t.Errorf("Ping after Open failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPing_AfterClose(t *testing.T) {
|
|
ctx := t.Context()
|
|
s, err := store.Open(ctx, tempStorePath(t))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if err := s.Ping(ctx); err == nil {
|
|
t.Error("Ping after Close should fail")
|
|
}
|
|
}
|
|
|
|
func TestPing_CanceledContext(t *testing.T) {
|
|
ctx := t.Context()
|
|
s, err := store.Open(ctx, tempStorePath(t))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
cctx, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
if err := s.Ping(cctx); err == nil {
|
|
t.Error("Ping with canceled context should fail")
|
|
} else if !errors.Is(err, context.Canceled) {
|
|
// PingContext should surface the cancellation — not a hard requirement
|
|
// since driver behavior varies slightly, so log rather than fail.
|
|
t.Logf("Ping error not context.Canceled (ok): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPath_ReturnsAbsolute(t *testing.T) {
|
|
ctx := t.Context()
|
|
// Use a relative path to confirm Store.Path returns an absolute one.
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
|
|
s, err := store.Open(ctx, "broker.db")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
if !filepath.IsAbs(s.Path()) {
|
|
t.Errorf("Path = %q, want absolute", s.Path())
|
|
}
|
|
}
|