183 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|