test: port migrate suite + add probe-table and CLI specs

15 vitest specs total:
- 9 lifted from authd's tests/db/migrate.test.ts. Specs that referenced
  the on-disk migrations dir now write fixtures into a per-test tmpdir
  (package ships no migrations dir of its own).
- 3 new specs covering the genesisProbeTable parameter (default, custom
  table name, opt-out via stampGenesis=false).
- 3 new specs covering runMigrateCli (migrate / status / stamp) using
  PassThrough stream capture.
This commit is contained in:
2026-05-12 02:16:29 -07:00
parent a4c994ca9a
commit 7a7c5adf92
2 changed files with 488 additions and 0 deletions

157
tests/cli.test.ts Normal file
View File

@@ -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();
});
});

331
tests/migrate.test.ts Normal file
View File

@@ -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);
});
});