Files
shd.build/src/lib/server/auth/authority-change.test.ts
T

183 lines
6.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const mocks = vi.hoisted(() => ({
audit: vi.fn(),
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: mocks.audit,
}));
import { applyAuthorityChange } from './authority-change';
import type { AuthUser } from './session';
const operator = {
id: 'operator-1',
aal: 2,
authLevel: 2,
aal2VerifiedAt: new Date().toISOString(),
} as AuthUser;
async function createDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE users(id TEXT PRIMARY KEY, role TEXT NOT NULL);
CREATE TABLE roles(id TEXT PRIMARY KEY);
CREATE TABLE permissions(id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE role_permissions(role_id TEXT, permission_id TEXT);
CREATE TABLE user_roles(user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
CREATE TABLE deleted_accounts(user_id TEXT PRIMARY KEY);
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE mfa_totp_factors(id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
CREATE TABLE mfa_recovery_codes(id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
INSERT INTO users VALUES('operator-1','user'),('target-1','user');
INSERT INTO roles VALUES('role-admin'),('role-user');
INSERT INTO permissions VALUES('permission-system-admin','system.admin'),('permission-users-view','users.view');
INSERT INTO role_permissions VALUES('role-admin','permission-system-admin'),('role-user','permission-users-view');
INSERT INTO user_roles VALUES('operator-1','role-admin');
INSERT INTO mfa_totp_factors VALUES('operator-factor','operator-1','active');
INSERT INTO sessions VALUES('target-session','target-1');
`);
return database;
}
const access = (roleIds: string[], permissionIds: string[]) => ({
roleIds,
permissionIds,
});
describe('shared authority changes', () => {
let database: Database;
beforeEach(async () => {
database = await createDatabase();
mocks.audit.mockReset();
mocks.audit.mockResolvedValue(undefined);
});
afterEach(async () => database.close());
it('cannot revert an administrator grant by removing the final active authority', async () => {
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'operator-1',
...access([], []),
expectedCurrent: access(['role-admin'], []),
}),
).rejects.toMatchObject({ status: 409 });
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('operator-1');
expect(role.role_id).toBe('role-admin');
});
it('requires an MFA-ready target when reversion restores administrator authority', async () => {
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toMatchObject({ status: 400 });
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('target-1');
expect(role).toBeUndefined();
});
it('requires recent AAL2 authentication for administrator reversion', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
await expect(
applyAuthorityChange({
database,
operator: {
...operator,
aal2VerifiedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
} as never,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toMatchObject({ status: 403 });
});
it('revokes sessions and appends reversion audit evidence in the transaction', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
const result = await applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
audit: {
actorId: 'operator-1',
category: 'audit_control',
action: 'audit.reverted',
entityId: 'event-1',
before: access([], []),
after: {
originalEventId: 'event-1',
restored: access(['role-admin'], []),
},
},
});
expect(result.after).toEqual(access(['role-admin'], []));
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get(),
).toMatchObject({ count: 0 });
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'audit.reverted',
entityId: 'event-1',
database,
}),
);
});
it('rolls back authority and session changes when audit append fails', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
mocks.audit.mockRejectedValueOnce(new Error('audit failed'));
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toThrow('audit failed');
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('target-1');
const sessions = await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get();
expect(role).toBeUndefined();
expect(Number(sessions.count)).toBe(1);
});
});