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.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|