Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-11 23:30:40 -04:00
parent 253d47300c
commit 4ea291d305
48 changed files with 1684 additions and 933 deletions
+119
View File
@@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { 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 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());
function rows(table: string) {
return handle.prepare(`SELECT * 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([]);
});
});