feat(store): OAuth tables migration (forgejo-mcp-broker-cpb)

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>
This commit is contained in:
Ole-Morten Duesund 2026-04-27 13:28:12 +02:00
commit e4a7baa0bc
4 changed files with 333 additions and 6 deletions

View file

@ -14,6 +14,11 @@ func tempStorePath(t *testing.T) string {
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))
@ -33,14 +38,14 @@ func TestOpen_FreshDB_AppliesMigrations(t *testing.T) {
t.Errorf("schema_version = %q, want %q", version, "1")
}
// schema_migrations should have recorded the initial migration.
// 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 != 1 {
t.Errorf("schema_migrations row count = %d, want 1", count)
if count != embeddedMigrationCount {
t.Errorf("schema_migrations row count = %d, want %d", count, embeddedMigrationCount)
}
}
@ -68,8 +73,8 @@ func TestOpen_ReopenIsIdempotent(t *testing.T) {
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("counting schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("after reopen, schema_migrations count = %d, want 1", count)
if count != embeddedMigrationCount {
t.Errorf("after reopen, schema_migrations count = %d, want %d", count, embeddedMigrationCount)
}
}