diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..90f5f80 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import Database from 'better-sqlite3'; +import { runMigrateCli } from '../src/cli.ts'; + +function captureStreams() { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdoutBuf: Buffer[] = []; + const stderrBuf: Buffer[] = []; + stdout.on('data', (c) => stdoutBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + stderr.on('data', (c) => stderrBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + return { + stdout, + stderr, + get stdoutText() { + return Buffer.concat(stdoutBuf).toString('utf8'); + }, + get stderrText() { + return Buffer.concat(stderrBuf).toString('utf8'); + }, + }; +} + +describe('runMigrateCli', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-cli-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("command='migrate' calls applyMigrations and writes expected lines", () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + const caps = captureStreams(); + + const code = runMigrateCli({ + openDb: () => new Database(':memory:'), + migrationsDir: tmpDir, + command: 'migrate', + stdout: caps.stdout, + stderr: caps.stderr, + }); + + expect(code).toBe(0); + expect(caps.stdoutText).toContain('applied:1'); + expect(caps.stdoutText).toContain('migrations: 1 applied, 0 pending'); + }); + + it("command='status' emits pending then applied then pending again", () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + + const sharedDb = new Database(':memory:'); + const openDb = () => { + const proxy = new Proxy(sharedDb, { + get(target, prop) { + if (prop === 'close') return () => {}; + const v = (target as any)[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + return proxy as unknown as Database.Database; + }; + + const migrateCaps = captureStreams(); + const migrateCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'migrate', + stdout: migrateCaps.stdout, + stderr: migrateCaps.stderr, + }); + expect(migrateCode).toBe(0); + + const statusCaps = captureStreams(); + const statusCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'status', + stdout: statusCaps.stdout, + stderr: statusCaps.stderr, + }); + expect(statusCode).toBe(0); + expect(statusCaps.stdoutText).toContain('applied:0001'); + + writeFileSync(join(tmpDir, '0002_more.sql'), 'CREATE TABLE y (b INTEGER);'); + const statusCaps2 = captureStreams(); + const statusCode2 = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'status', + stdout: statusCaps2.stdout, + stderr: statusCaps2.stderr, + }); + expect(statusCode2).toBe(0); + expect(statusCaps2.stdoutText).toContain('pending:0002'); + + sharedDb.close(); + }); + + it("command='stamp' marks applied without running body", () => { + writeFileSync( + join(tmpDir, '0001_init.sql'), + 'CREATE TABLE only_if_applied (x INTEGER);', + ); + + const sharedDb = new Database(':memory:'); + const openDb = () => { + const proxy = new Proxy(sharedDb, { + get(target, prop) { + if (prop === 'close') return () => {}; + const v = (target as any)[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + return proxy as unknown as Database.Database; + }; + + const stampCaps = captureStreams(); + const code = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'stamp', + version: '0001', + stdout: stampCaps.stdout, + stderr: stampCaps.stderr, + }); + expect(code).toBe(0); + expect(stampCaps.stdoutText).toContain('stamped:0001'); + + const tableMissing = sharedDb + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`, + ) + .get(); + expect(tableMissing).toBeUndefined(); + + const noVersionCaps = captureStreams(); + const noVersionCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'stamp', + stdout: noVersionCaps.stdout, + stderr: noVersionCaps.stderr, + }); + expect(noVersionCode).toBe(1); + expect(noVersionCaps.stderrText).toContain('error:'); + + sharedDb.close(); + }); +}); diff --git a/tests/migrate.test.ts b/tests/migrate.test.ts new file mode 100644 index 0000000..0403d13 --- /dev/null +++ b/tests/migrate.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { + applyMigrations, + listMigrations, + readAppliedRows, + stampMigration, +} from '../src/migrate.ts'; + +const LEGACY_BOOTSTRAP_SQL = ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS login_attempts ( + username TEXT PRIMARY KEY, + failed_count INTEGER NOT NULL DEFAULT 0, + last_failed_at INTEGER, + locked_until INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until); + + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret_hash TEXT NOT NULL, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + allowed_grants TEXT NOT NULL, + allowed_scopes TEXT NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_used_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_oauth_clients_owner ON oauth_clients(owner_user_id); + + CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + code_challenge TEXT NOT NULL, + code_challenge_method TEXT NOT NULL DEFAULT 'S256', + expires_at INTEGER NOT NULL, + redeemed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_authorization_codes(client_id); + + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_at_expires ON oauth_access_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_at_owner ON oauth_access_tokens(client_id, user_id); + + CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + replaced_by_token_hash TEXT, + family_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_rt_expires ON oauth_refresh_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_rt_family ON oauth_refresh_tokens(family_id); + + CREATE TABLE IF NOT EXISTS mfa_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + secret_encrypted TEXT NOT NULL, + enrolled_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mfa_recovery_codes ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, code_hash) + ); + CREATE INDEX IF NOT EXISTS idx_mfa_recovery_user + ON mfa_recovery_codes(user_id) WHERE used_at IS NULL; + + CREATE TABLE IF NOT EXISTS mfa_attempts ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + failed_count INTEGER NOT NULL DEFAULT 0, + last_failed_at INTEGER, + locked_until INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_mfa_attempts_locked ON mfa_attempts(locked_until); + `; + +interface SchemaObject { + type: string; + name: string; + sql: string | null; +} + +function normalizeSql(sql: string | null): string | null { + if (sql === null) return null; + return sql.replace(/\s+/g, ' ').trim(); +} + +function snapshotSchema(db: Database.Database): SchemaObject[] { + const rows = db + .prepare( + `SELECT type, name, sql FROM sqlite_master + WHERE type IN ('table','index') + AND name NOT LIKE 'sqlite_%' + AND name != 'schema_migrations' + ORDER BY type, name`, + ) + .all() as SchemaObject[]; + return rows.map((r) => ({ ...r, sql: normalizeSql(r.sql) })); +} + +describe('migrate', () => { + let tmpDir: string; + let db: Database.Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-test-')); + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('fresh apply produces byte-equivalent schema to legacy bootstrap', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + + const legacyDb = new Database(':memory:'); + legacyDb.pragma('foreign_keys = ON'); + legacyDb.exec(LEGACY_BOOTSTRAP_SQL); + const legacySnapshot = snapshotSchema(legacyDb); + legacyDb.close(); + + const summary = applyMigrations(db, tmpDir); + expect(summary.applied).toBe(1); + expect(summary.alreadyApplied).toBe(0); + expect(summary.pending).toBe(0); + expect(summary.stamped).toEqual([]); + + const newSnapshot = snapshotSchema(db); + expect(newSnapshot).toEqual(legacySnapshot); + }); + + it('is idempotent on re-apply', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + applyMigrations(db, tmpDir); + const summary = applyMigrations(db, tmpDir); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect(summary.pending).toBe(0); + expect(summary.stamped).toEqual([]); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + }); + + it('aborts on checksum mismatch', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE original (x INTEGER);'); + applyMigrations(db, tmpDir); + + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE tampered (x INTEGER);'); + + expect(() => applyMigrations(db, tmpDir)).toThrow(/checksum mismatch.*0001_init/); + }); + + it('stamps genesis on pre-existing DB with users table', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect(warnings.some((w) => w.includes('genesis-stamp'))).toBe(true); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + + const files = listMigrations(tmpDir); + expect(rows[0].checksum).toBe(files[0].checksum); + }); + + it('rolls back when a migration fails partway', () => { + writeFileSync(join(tmpDir, '0001_ok.sql'), 'CREATE TABLE a (x INTEGER);'); + writeFileSync( + join(tmpDir, '0002_bad.sql'), + 'CREATE TABLE b (x INTEGER); CREATE TABLE INVALID_SYNTAX_HERE', + ); + + expect(() => applyMigrations(db, tmpDir)).toThrow(); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + + const aExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='a'`) + .get(); + expect(aExists).toBeDefined(); + + const bExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='b'`) + .get(); + expect(bExists).toBeUndefined(); + }); + + it('stampMigration marks version applied without running body', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE only_if_applied (x INTEGER);'); + const file = stampMigration(db, tmpDir, '0001'); + expect(file.version).toBe('0001'); + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + const tableMissing = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`) + .get(); + expect(tableMissing).toBeUndefined(); + }); + + it('stampMigration refuses double-stamp', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + stampMigration(db, tmpDir, '0001'); + expect(() => stampMigration(db, tmpDir, '0001')).toThrow(/already applied/); + }); + + it('rejects invalid migration filenames', () => { + writeFileSync(join(tmpDir, 'notamigration.sql'), 'SELECT 1;'); + expect(() => applyMigrations(db, tmpDir)).toThrow(/invalid migration filename/); + }); + + it('honors stampGenesis option even without users table', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + const summary = applyMigrations(db, tmpDir, { stampGenesis: true }); + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeUndefined(); + }); + + // New probe-table specs + + it('probe-table-not-present + stampGenesis=false runs first migration normally', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE users (id TEXT PRIMARY KEY);'); + const summary = applyMigrations(db, tmpDir, { stampGenesis: false }); + expect(summary.stamped).toEqual([]); + expect(summary.applied).toBe(1); + expect(summary.alreadyApplied).toBe(0); + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeDefined(); + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + }); + + it('genesisProbeTable=undefined defaults to users (authd back-compat)', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect( + warnings.some((w) => w.includes('genesis-stamp') && w.includes("'users'")), + ).toBe(true); + }); + + it("genesisProbeTable='sessions' stamps a buchinese-shaped DB", () => { + writeFileSync( + join(tmpDir, '0001_init.sql'), + `CREATE TABLE users (id TEXT PRIMARY KEY); CREATE TABLE sessions (id TEXT PRIMARY KEY);`, + ); + db.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + genesisProbeTable: 'sessions', + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeUndefined(); + + expect( + warnings.some((w) => w.includes('genesis-stamp') && w.includes("'sessions'")), + ).toBe(true); + }); +});