327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
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([]);
|
|
});
|
|
});
|