import Database from 'better-sqlite3'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { migrateLoginDevices, migrateModeratorRole, migrateRememberedSessions, migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js'; import { runMigrations } from '../../src/lib/server/db/migration-runner.js'; import { migrations } from '../../src/lib/server/db/migrations.js'; let handle: Database.Database; const temporaryDirectories: string[] = []; const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!; beforeEach(() => { handle = new Database(':memory:'); handle.pragma('foreign_keys = ON'); runMigrations(handle, migrations.slice(0, migration.version - 1)); handle.exec(` INSERT INTO auth_users VALUES ('admin','Administrator','Admin','admin-password-hash','admin',0,0,10,20,30), ('operator','Operator','Op','operator-password-hash','operator',0,1,40,50,NULL), ('viewer','Viewer','View','viewer-password-hash','viewer',1,0,60,70,80); INSERT INTO auth_sessions VALUES ('admin-token-hash','admin',10,1000,100), ('operator-token-hash','operator',20,2000,200); INSERT INTO auth_rate_limits VALUES ('limit-key',100,3); INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES ('admin','user.created','viewer','{"role":"viewer"}',100); INSERT INTO servers(name,last_reply_snippet) VALUES ('saved','keep diagnostic'); `); }); afterEach(() => { handle.close(); for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); function rows(table: string) { // Compare the original session fields across migrations that add policy columns. const columns = table === 'auth_sessions' ? 'token_hash,user_id,created_at,expires_at,last_seen_at' : '*'; return handle.prepare(`SELECT ${columns} FROM ${table} ORDER BY rowid`).all(); } describe('super viewer role migration', () => { it('preserves existing accounts, sessions, audit, rate limits and fleet data while extending the role constraint', () => { const tables = ['auth_users', 'auth_sessions', 'auth_audit', 'auth_rate_limits', 'servers']; const original = tables.map(rows); expect(() => handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'") ).toThrow('CHECK constraint failed'); runMigrations(handle, migrations); expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length); expect(tables.map(rows)).toEqual(original); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(handle.pragma('foreign_key_check')).toEqual([]); expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([ expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' }) ]); handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'"); expect(handle.prepare("SELECT role FROM auth_users WHERE id='viewer'").get()).toEqual({ role: 'super_viewer' }); expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow( 'CHECK constraint failed' ); expect(() => handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'") ).toThrow('UNIQUE constraint failed'); expect(() => handle.exec("UPDATE auth_sessions SET user_id='missing'")).toThrow( 'FOREIGN KEY constraint failed' ); handle.exec("DELETE FROM auth_users WHERE id='operator'"); expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([ { user_id: 'admin' } ]); }); it('does not rebuild an already upgraded table or remove super viewers when replayed', () => { runMigrations(handle, migrations); handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'"); const users = rows('auth_users'); const sessions = rows('auth_sessions'); const schemaVersion = handle.pragma('schema_version', { simple: true }); migrateSuperViewerRole(handle); handle.pragma(`user_version = ${migration.version - 1}`); runMigrations(handle, migrations); expect(rows('auth_users')).toEqual(users); expect(rows('auth_sessions')).toEqual(sessions); expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); }); it('rejects an unsafe direct rebuild and rolls back a failed reference check before retrying', () => { const users = rows('auth_users'); const sessions = rows('auth_sessions'); expect(() => migrateSuperViewerRole(handle)).toThrow('foreign keys to be disabled'); expect(rows('auth_users')).toEqual(users); expect(rows('auth_sessions')).toEqual(sessions); handle.pragma('foreign_keys = OFF'); handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100)"); handle.pragma('foreign_keys = ON'); expect(() => runMigrations(handle, migrations)).toThrow('left broken references'); expect(handle.pragma('user_version', { simple: true })).toBe(migration.version - 1); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(rows('auth_users')).toEqual(users); expect(rows('auth_sessions')).toHaveLength(sessions.length + 1); expect(() => handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'") ).toThrow('CHECK constraint failed'); expect( handle .prepare("SELECT name FROM sqlite_master WHERE name='auth_users_super_viewer'") .get() ).toBeUndefined(); handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'"); runMigrations(handle, migrations); expect(rows('auth_users')).toEqual(users); expect(rows('auth_sessions')).toEqual(sessions); expect(handle.pragma('foreign_key_check')).toEqual([]); }); }); describe('remembered session migration', () => { it('preserves existing sessions as short sessions and safely replays without clearing remembered devices', () => { const upgrade = migrations.find((entry) => entry.name === 'remembered browser sessions')!; runMigrations(handle, migrations.slice(0, upgrade.version - 1)); const sessions = rows('auth_sessions'); const users = rows('auth_users'); runMigrations(handle, migrations); expect(rows('auth_sessions')).toEqual(sessions); expect(rows('auth_users')).toEqual(users); expect(handle.prepare('SELECT remembered FROM auth_sessions').all()).toEqual([ { remembered: 0 }, { remembered: 0 } ]); expect(() => handle.exec('UPDATE auth_sessions SET remembered=2')).toThrow( 'CHECK constraint failed' ); handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'"); const remembered = handle.prepare('SELECT * FROM auth_sessions').all(); const schemaVersion = handle.pragma('schema_version', { simple: true }); migrateRememberedSessions(handle); handle.pragma(`user_version = ${upgrade.version - 1}`); runMigrations(handle, migrations); expect(handle.prepare('SELECT * FROM auth_sessions').all()).toEqual(remembered); expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); expect(handle.pragma('foreign_key_check')).toEqual([]); }); }); describe('known login device migration', () => { it('preserves existing authentication data and cascades device removal when an account is deleted', () => { const upgrade = migrations.find((entry) => entry.name === 'known login devices')!; runMigrations(handle, migrations.slice(0, upgrade.version - 1)); handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'"); const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers']; const original = tables.map(rows); const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); runMigrations(handle, migrations); expect(tables.map(rows)).toEqual(original); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); expect(rows('auth_login_devices')).toEqual([]); expect(handle.pragma('foreign_key_list(auth_login_devices)')).toEqual([ expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' }) ]); expect(handle.pragma('index_info(auth_login_devices_user)')).toEqual([ expect.objectContaining({ name: 'user_id' }) ]); expect(handle.pragma('index_info(auth_login_devices_expires_at)')).toEqual([ expect.objectContaining({ name: 'expires_at' }) ]); handle.exec(` INSERT INTO auth_login_devices VALUES ('admin-device-hash','admin',100,3000), ('operator-device-hash','operator',200,4000); `); expect(() => handle.exec("INSERT INTO auth_login_devices VALUES ('missing-device','missing',0,1)") ).toThrow('FOREIGN KEY constraint failed'); expect(() => handle.exec("INSERT INTO auth_login_devices VALUES ('admin-device-hash','admin',0,1)") ).toThrow('UNIQUE constraint failed'); handle.exec("DELETE FROM auth_users WHERE id='operator'"); expect(rows('auth_login_devices')).toEqual([ { token_hash: 'admin-device-hash', user_id: 'admin', created_at: 100, expires_at: 3000 } ]); expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([ { user_id: 'admin' } ]); expect(handle.pragma('foreign_key_check')).toEqual([]); }); it('keeps device hashes and existing authentication data when replayed or reopened', () => { const upgrade = migrations.find((entry) => entry.name === 'known login devices')!; runMigrations(handle, migrations); handle.exec("INSERT INTO auth_login_devices VALUES ('device-hash','admin',100,3000)"); const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'auth_login_devices']; const original = tables.map(rows); const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); const schemaVersion = handle.pragma('schema_version', { simple: true }); migrateLoginDevices(handle); handle.pragma(`user_version = ${upgrade.version - 1}`); runMigrations(handle, migrations); expect(tables.map(rows)).toEqual(original); expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); const directory = mkdtempSync(join(tmpdir(), 'electric-cherry-auth-schema-')); temporaryDirectories.push(directory); const databasePath = join(directory, 'auth.sqlite'); writeFileSync(databasePath, handle.serialize()); handle.close(); handle = new Database(databasePath); handle.pragma('foreign_keys = ON'); runMigrations(handle, migrations); expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length); expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); expect(tables.map(rows)).toEqual(original); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); expect(handle.pragma('foreign_key_check')).toEqual([]); }); }); describe('moderator role migration', () => { it('preserves all account roles and remembered sessions while extending the role constraint', () => { const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; runMigrations(handle, migrations.slice(0, upgrade.version - 1)); handle.exec(` UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'; INSERT INTO auth_users VALUES ('super','Super','Super','super-password-hash','super_viewer',0,0,10,20,NULL); INSERT INTO auth_sessions VALUES ('super-token-hash','super',10,1000,100,1); `); const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers']; const original = tables.map(rows); const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); expect(() => handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'") ).toThrow('CHECK constraint failed'); runMigrations(handle, migrations); expect(tables.map(rows)).toEqual(original); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(handle.pragma('foreign_key_check')).toEqual([]); expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([ expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' }) ]); handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'"); const users = rows('auth_users'); const schemaVersion = handle.pragma('schema_version', { simple: true }); migrateModeratorRole(handle); expect(rows('auth_users')).toEqual(users); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow( 'CHECK constraint failed' ); expect(() => handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'") ).toThrow('UNIQUE constraint failed'); handle.exec("DELETE FROM auth_users WHERE id='super'"); expect(handle.prepare("SELECT * FROM auth_sessions WHERE user_id='super'").all()).toEqual( [] ); }); it('rejects an unsafe rebuild and rolls back the upgrade if existing references are broken', () => { const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; runMigrations(handle, migrations.slice(0, upgrade.version - 1)); const users = rows('auth_users'); const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); expect(() => migrateModeratorRole(handle)).toThrow('foreign keys to be disabled'); expect(rows('auth_users')).toEqual(users); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); handle.pragma('foreign_keys = OFF'); handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100,1)"); handle.pragma('foreign_keys = ON'); expect(() => runMigrations(handle, migrations)).toThrow('left broken references'); expect(handle.pragma('user_version', { simple: true })).toBe(upgrade.version - 1); expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); expect(rows('auth_users')).toEqual(users); expect( handle.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_moderator'").get() ).toBeUndefined(); handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'"); runMigrations(handle, migrations); expect(rows('auth_users')).toEqual(users); expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( sessions ); expect(handle.pragma('foreign_key_check')).toEqual([]); }); });