Files
TREK/server/tests/unit/nest/permissions.service.test.ts
T
jubnl 132eb2035f fix(server): fix the quirks preserved by the permissions and auditLog DI migrations
Permissions: stored levels are validated at load (corrupt/out-of-range rows
fall back to the default consistently across all readers), the load-error
swallow is narrowed to missing-table (anything else logs), and an
all-skipped save no longer opens a transaction or flushes the cache.
The module-scoped cache stays by design (shared DI+bridge invalidation),
and the checkPermission 'admin' case stays — it is reachable (correct deny
for non-admins on admin-level actions), not dead code.

Audit: data/logs creation is lazy (no import-time disk side effect; the
LOG_LEVEL freeze remains — tests/setup.ts timing contract), the log helpers
gate on a real severity threshold (error < warn < info < debug; prod
default 'info' unchanged), file-IO failures leave a console.error trace
instead of vanishing in bare catches, and resolveUserEmail treats only
null/undefined as anonymous so a real id 0 resolves via the DB.
2026-07-27 22:34:41 +02:00

280 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the DI-native PermissionsService — PERM-SVC-001 through
* PERM-SVC-023 (001–013 moved 1:1 from the legacy
* tests/unit/services/permissions.test.ts, which had no case IDs — the IDs are
* introduced with the move; 014–016 pin the cache semantics the real DB now
* makes testable; 017–020 pin the permissions.bridge delegation and the
* module-scoped cache shared across the DI and bridge instances; 013 + 021–023
* pin the quirk-fix pass: load-time level validation, the narrowed load-error
* swallow, and the no-op-save early return). Uses a real in-memory SQLite DB
* so the app_settings SQL is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
// The service logs unexpected load failures via the plain audit logger.
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { logError } from '../../../src/nest/audit/audit-log.logger';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService, PERMISSION_ACTIONS } from '../../../src/nest/permissions/permissions.service';
import {
checkPermission as bridgeCheckPermission,
getPermissionLevel as bridgeGetPermissionLevel,
getAllPermissions as bridgeGetAllPermissions,
savePermissions as bridgeSavePermissions,
invalidatePermissionsCache as bridgeInvalidatePermissionsCache,
} from '../../../src/nest/permissions/permissions.bridge';
const svc = new PermissionsService(new DatabaseService(testDb));
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'perm_%'").run();
svc.invalidatePermissionsCache();
});
afterAll(() => {
testDb.close();
});
// ── checkPermission ───────────────────────────────────────────────────────────
describe('checkPermission — admin bypass', () => {
it('PERM-SVC-001: admin always passes regardless of permission level', () => {
for (const action of PERMISSION_ACTIONS) {
expect(svc.checkPermission(action.key, 'admin', 1, 1, false)).toBe(true);
expect(svc.checkPermission(action.key, 'admin', 99, 1, false)).toBe(true);
}
});
});
describe('checkPermission — everybody level', () => {
it('PERM-SVC-002: trip_create (everybody) allows any authenticated user', () => {
expect(svc.checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
});
});
describe('checkPermission — trip_owner level', () => {
const ownerId = 10;
const memberId = 20;
it('PERM-SVC-003: trip owner passes trip_owner check', () => {
expect(svc.checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true);
});
it('PERM-SVC-004: member fails trip_owner check', () => {
expect(svc.checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false);
});
it('PERM-SVC-005: non-member non-owner fails trip_owner check', () => {
expect(svc.checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false);
});
});
describe('checkPermission — trip_member level', () => {
const ownerId = 10;
const memberId = 20;
const outsiderId = 30;
it('PERM-SVC-006: trip owner passes trip_member check', () => {
expect(svc.checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true);
});
it('PERM-SVC-007: trip member passes trip_member check', () => {
expect(svc.checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true);
});
it('PERM-SVC-008: outsider fails trip_member check', () => {
expect(svc.checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false);
});
});
// ── getPermissionLevel ────────────────────────────────────────────────────────
describe('getPermissionLevel — defaults', () => {
it('PERM-SVC-009: returns default level for known actions (no DB overrides)', () => {
const defaults: Record<string, string> = {
trip_create: 'everybody',
trip_delete: 'trip_owner',
day_edit: 'trip_member',
budget_edit: 'trip_member',
};
for (const [key, expected] of Object.entries(defaults)) {
expect(svc.getPermissionLevel(key)).toBe(expected);
}
});
it('PERM-SVC-010: returns trip_owner for unknown action key', () => {
expect(svc.getPermissionLevel('nonexistent_action')).toBe('trip_owner');
});
});
// ── savePermissions ───────────────────────────────────────────────────────────
describe('savePermissions — invalid input is silently skipped', () => {
it('PERM-SVC-011: returns skipped array containing invalid action key, writes no row', () => {
const result = svc.savePermissions({ nonexistent_action: 'trip_member' });
expect(result.skipped).toContain('nonexistent_action');
const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all();
expect(rows).toEqual([]);
});
it('PERM-SVC-012: returns skipped array when level is not in allowedLevels for the action', () => {
// trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid
const result = svc.savePermissions({ trip_delete: 'trip_member' });
expect(result.skipped).toContain('trip_delete');
const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all();
expect(rows).toEqual([]);
});
});
describe('corrupt stored levels', () => {
it('PERM-SVC-013: an unrecognized stored level is ignored — every reader falls back to the default', () => {
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'unknown_level');
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_delete', '');
svc.invalidatePermissionsCache();
// Since the quirk fix the corrupt rows never enter the cache, so
// getPermissionLevel, getAllPermissions and checkPermission agree on the
// default instead of the old display-default/deny-in-check split.
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
expect(svc.getAllPermissions().trip_edit).toBe('trip_owner');
expect(svc.checkPermission('trip_edit', 'user', 10, 10, false)).toBe(true); // owner passes the default
expect(svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); // member still denied
expect(svc.getPermissionLevel('trip_delete')).toBe('trip_owner');
});
it('PERM-SVC-021: a stored level outside the action\'s allowedLevels is ignored too', () => {
// trip_edit only allows trip_owner/trip_member — a raw 'everybody' row must not widen it.
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'everybody');
svc.invalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
expect(svc.checkPermission('trip_edit', 'user', 10, 30, false)).toBe(false);
});
});
describe('load failures', () => {
it('PERM-SVC-022: a missing app_settings table stays silent and serves defaults; other DB failures are logged', () => {
const bareDb = new Database(':memory:');
const bareSvc = new PermissionsService(new DatabaseService(bareDb));
bareSvc.invalidatePermissionsCache();
expect(bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner');
expect(logError).not.toHaveBeenCalled();
// An unexpected failure (closed connection) must leave a trace.
bareDb.close();
bareSvc.invalidatePermissionsCache();
expect(bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner');
expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Permissions load failed: /));
svc.invalidatePermissionsCache(); // don't leak the bare-DB cache to later tests
});
it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', () => {
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member');
const result = svc.savePermissions({ bogus: 'trip_member', trip_delete: 'trip_member' });
expect(result.skipped).toEqual(['bogus', 'trip_delete']);
// No valid entries → no transaction and no cache flush: the raw row above
// stays invisible until an explicit invalidation.
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
svc.invalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member');
});
});
// ── cache semantics (real DB) ─────────────────────────────────────────────────
describe('stored overrides + cache', () => {
it('PERM-SVC-014: stored perm_ row overrides the default after invalidation', () => {
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member');
svc.invalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member');
// A plain member now passes what defaults to a trip_owner-only action.
expect(svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true);
});
it('PERM-SVC-015: savePermissions persists the row and self-invalidates the cache', () => {
// Prime the cache with the defaults first.
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
const result = svc.savePermissions({ trip_edit: 'trip_member' });
expect(result.skipped).toEqual([]);
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('perm_trip_edit') as { value: string };
expect(row.value).toBe('trip_member');
// No manual invalidation — savePermissions did it.
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member');
});
it('PERM-SVC-016: the cache memoizes until invalidated', () => {
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
// Raw SQL write bypasses savePermissions' self-invalidation → stale value served.
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member');
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
svc.invalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member');
});
});
// ── permissions.bridge delegation ─────────────────────────────────────────────
describe('permissions.bridge delegation', () => {
it('PERM-SVC-017: checkPermission delegates through the bridge instance', () => {
expect(bridgeCheckPermission('trip_create', 'user', null, 42, false)).toBe(true);
expect(bridgeCheckPermission('trip_delete', 'user', 10, 20, true)).toBe(false);
});
it('PERM-SVC-018: savePermissions via the bridge writes the row and reports skips', () => {
const result = bridgeSavePermissions({ trip_edit: 'trip_member', bogus: 'trip_member' });
expect(result.skipped).toEqual(['bogus']);
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('perm_trip_edit') as { value: string };
expect(row.value).toBe('trip_member');
});
it('PERM-SVC-019: getPermissionLevel/getAllPermissions delegate through the bridge', () => {
expect(bridgeGetPermissionLevel('trip_create')).toBe('everybody');
const all = bridgeGetAllPermissions();
expect(all.trip_delete).toBe('trip_owner');
expect(Object.keys(all)).toHaveLength(PERMISSION_ACTIONS.length);
});
it('PERM-SVC-020: the cache is module-scoped — shared by the DI and bridge instances', () => {
// Save through the DI instance; the bridge instance sees it immediately.
svc.savePermissions({ trip_edit: 'trip_member' });
expect(bridgeGetPermissionLevel('trip_edit')).toBe('trip_member');
// Raw SQL write, then invalidate through the BRIDGE — the DI instance
// must serve the fresh value (backup-restore relies on exactly this).
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_owner');
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached
bridgeInvalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
});
});