mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
8bd831238d
The lease heartbeat and settle failures, the log sink and rotation fallbacks, the runtime env getters, the public API summary edges, the admin-only permission level and the WebSocket handler rejection had no spec, which took six domains under their coverage floor.
345 lines
17 KiB
TypeScript
345 lines
17 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native PermissionsService — PERM-SVC-001 through
|
||
* PERM-SVC-024 (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.
|
||
*
|
||
* Repository conversion (ORM Phase 3a, Task 2): PermissionsService no longer
|
||
* holds a DatabaseService — it is built from AppSettingsRepository +
|
||
* UnitOfWork, the same `t.repo(AppSettings)` pattern SettingsService's own
|
||
* unit test established. 022/024 (the two load-failure cases) now drive the
|
||
* failure through `vi.spyOn(appSettings, 'findByKeyPrefix')` instead of a
|
||
* hand-rolled DatabaseService/all() stub.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import { ValidationError } from '@mikro-orm/core';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
vi.mock('../../../src/db/database', async () => {
|
||
|
||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
};
|
||
return mock;
|
||
});
|
||
|
||
|
||
// 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 { db as testDb } from '../../../src/db/database';
|
||
import { logError } from '../../../src/nest/audit/audit-log.logger';
|
||
import { PermissionsService, PERMISSION_ACTIONS } from '../../../src/nest/permissions/permissions.service';
|
||
import {
|
||
getPermissionsCache,
|
||
invalidatePermissionsCache as invalidateSharedCache,
|
||
} from '../../../src/nest/permissions/permissions-cache';
|
||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||
import { deleteRows, findRows } from '../../helpers/factories/rows';
|
||
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
|
||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||
|
||
let svc: PermissionsService;
|
||
let t: TestOrm;
|
||
let appSettings: AppSettingsRepository;
|
||
|
||
beforeAll(async () => {
|
||
t = await createTestOrm(testDb);
|
||
appSettings = t.repo(AppSettings);
|
||
svc = new PermissionsService(appSettings, await createTestUnitOfWork(testDb));
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await deleteRows(t, AppSettings, { key: { $like: 'perm_%' } });
|
||
t.clear();
|
||
await svc.invalidatePermissionsCache();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await t.close();
|
||
testDb.close();
|
||
});
|
||
|
||
// ── checkPermission ───────────────────────────────────────────────────────────
|
||
|
||
describe('checkPermission — admin bypass', () => {
|
||
it('PERM-SVC-001: admin always passes regardless of permission level', async () => {
|
||
for (const action of PERMISSION_ACTIONS) {
|
||
expect(await svc.checkPermission(action.key, 'admin', 1, 1, false)).toBe(true);
|
||
expect(await 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', async () => {
|
||
expect(await svc.checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('checkPermission: admin level', () => {
|
||
it('PERM-SVC-026: trip_create narrowed to admin refuses every non-admin, the trip owner included', async () => {
|
||
await svc.savePermissions({ trip_create: 'admin' });
|
||
expect(await svc.checkPermission('trip_create', 'user', 42, 42, false)).toBe(false);
|
||
expect(await svc.checkPermission('trip_create', 'user', null, 42, true)).toBe(false);
|
||
expect(await svc.checkPermission('trip_create', 'admin', 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', async () => {
|
||
expect(await svc.checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true);
|
||
});
|
||
|
||
it('PERM-SVC-004: member fails trip_owner check', async () => {
|
||
expect(await svc.checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false);
|
||
});
|
||
|
||
it('PERM-SVC-005: non-member non-owner fails trip_owner check', async () => {
|
||
expect(await 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', async () => {
|
||
expect(await svc.checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true);
|
||
});
|
||
|
||
it('PERM-SVC-007: trip member passes trip_member check', async () => {
|
||
expect(await svc.checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true);
|
||
});
|
||
|
||
it('PERM-SVC-008: outsider fails trip_member check', async () => {
|
||
expect(await 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)', async () => {
|
||
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(await svc.getPermissionLevel(key)).toBe(expected);
|
||
}
|
||
});
|
||
|
||
it('PERM-SVC-010: returns trip_owner for unknown action key', async () => {
|
||
expect(await 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', async () => {
|
||
const result = await svc.savePermissions({ nonexistent_action: 'trip_member' });
|
||
expect(result.skipped).toContain('nonexistent_action');
|
||
const rows = await findRows(t, AppSettings, { key: { $like: 'perm_%' } });
|
||
expect(rows).toEqual([]);
|
||
});
|
||
|
||
it('PERM-SVC-012: returns skipped array when level is not in allowedLevels for the action', async () => {
|
||
// trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid
|
||
const result = await svc.savePermissions({ trip_delete: 'trip_member' });
|
||
expect(result.skipped).toContain('trip_delete');
|
||
const rows = await findRows(t, AppSettings, { key: { $like: 'perm_%' } });
|
||
expect(rows).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('corrupt stored levels', () => {
|
||
it('PERM-SVC-013: an unrecognized stored level is ignored — every reader falls back to the default', async () => {
|
||
await setAppSetting(t, 'perm_trip_edit', 'unknown_level');
|
||
await setAppSetting(t, 'perm_trip_delete', '');
|
||
await 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(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
expect((await svc.getAllPermissions()).trip_edit).toBe('trip_owner');
|
||
expect(await svc.checkPermission('trip_edit', 'user', 10, 10, false)).toBe(true); // owner passes the default
|
||
expect(await svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); // member still denied
|
||
expect(await svc.getPermissionLevel('trip_delete')).toBe('trip_owner');
|
||
});
|
||
|
||
it('PERM-SVC-021: a stored level outside the action\'s allowedLevels is ignored too', async () => {
|
||
// trip_edit only allows trip_owner/trip_member — a raw 'everybody' row must not widen it.
|
||
await setAppSetting(t, 'perm_trip_edit', 'everybody');
|
||
await svc.invalidatePermissionsCache();
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
expect(await svc.checkPermission('trip_edit', 'user', 10, 30, false)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('load failures', () => {
|
||
it('PERM-SVC-022: a "no such table" read failure is no longer swallowed — migrations guarantee the table exists, so it is logged like any other failure, and defaults are still served', async () => {
|
||
// Under the legacy better-sqlite3 path this exact message meant first-boot
|
||
// init racing the read, before app_settings existed, and was swallowed
|
||
// silently. Under the ORM, buildApp() runs migrations to completion
|
||
// before any request/MCP/WS/cron entrypoint can reach this service, so
|
||
// that race no longer exists — the message-specific swallow is dropped
|
||
// and this failure now takes the same "log and serve defaults" path as
|
||
// every other repository error. (task-6-review-template.md Minor 4: this
|
||
// case does not itself assert getPermissionsCache() stays null for the
|
||
// same branch — PERM-SVC-024 below does.)
|
||
const spy = vi
|
||
.spyOn(appSettings, 'findByKeyPrefix')
|
||
.mockRejectedValueOnce(new Error('no such table: app_settings'));
|
||
await svc.invalidatePermissionsCache();
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
expect(logError).toHaveBeenCalledWith('Permissions load failed: no such table: app_settings');
|
||
spy.mockRestore();
|
||
await svc.invalidatePermissionsCache(); // don't leak the failed-read (uncached) state
|
||
});
|
||
|
||
it('PERM-SVC-024: a failed read serves defaults without installing them, and a later read populates the cache', async () => {
|
||
const spy = vi
|
||
.spyOn(appSettings, 'findByKeyPrefix')
|
||
.mockRejectedValueOnce(new Error('database connection is closed'));
|
||
await svc.invalidatePermissionsCache();
|
||
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
expect(logError).toHaveBeenCalledWith('Permissions load failed: database connection is closed');
|
||
// Nothing installed: an admin's stricter stored level would otherwise stay
|
||
// invisible until somebody invalidated by hand.
|
||
expect(await getPermissionsCache()).toBe(null);
|
||
|
||
// The mock is exhausted after the one queued rejection — the next call
|
||
// falls through to the real (now-working) repository read.
|
||
await setAppSetting(t, 'perm_trip_edit', 'trip_member');
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member');
|
||
expect(await getPermissionsCache()).not.toBe(null);
|
||
spy.mockRestore();
|
||
await svc.invalidatePermissionsCache(); // don't leak this test's cache to later tests
|
||
});
|
||
|
||
it('PERM-SVC-025: a MikroORM ValidationError (context misuse) is a programming error and propagates, uncached — it does not get the "log and serve defaults" treatment', async () => {
|
||
// The concrete case this protects (task-2-review.md C3): checkPermission
|
||
// reached from a non-HTTP entrypoint outside any request context throws
|
||
// exactly this on a cold cache. Swallowing it and serving defaults was
|
||
// fail-open — an admin's tightened flag silently reverted to its looser
|
||
// default for as long as the misuse persisted. loadPermissions must rethrow
|
||
// instead of degrading.
|
||
const spy = vi
|
||
.spyOn(appSettings, 'findByKeyPrefix')
|
||
.mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
|
||
await svc.invalidatePermissionsCache();
|
||
|
||
await expect(svc.getPermissionLevel('trip_edit')).rejects.toThrow(ValidationError);
|
||
expect(logError).not.toHaveBeenCalled();
|
||
expect(await getPermissionsCache()).toBe(null);
|
||
|
||
spy.mockRestore();
|
||
await svc.invalidatePermissionsCache();
|
||
});
|
||
|
||
it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', async () => {
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache
|
||
await setAppSetting(t, 'perm_trip_edit', 'trip_member');
|
||
const result = await 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(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
await svc.invalidatePermissionsCache();
|
||
expect(await 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', async () => {
|
||
await setAppSetting(t, 'perm_trip_edit', 'trip_member');
|
||
await svc.invalidatePermissionsCache();
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member');
|
||
// A plain member now passes what defaults to a trip_owner-only action.
|
||
expect(await svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true);
|
||
});
|
||
|
||
it('PERM-SVC-015: savePermissions persists the row and self-invalidates the cache', async () => {
|
||
// Prime the cache with the defaults first.
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
const result = await svc.savePermissions({ trip_edit: 'trip_member' });
|
||
expect(result.skipped).toEqual([]);
|
||
expect(await readAppSetting(t, 'perm_trip_edit')).toBe('trip_member');
|
||
// No manual invalidation — savePermissions did it.
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member');
|
||
});
|
||
|
||
it('PERM-SVC-016: the cache memoizes until invalidated', async () => {
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
// A direct row write bypasses savePermissions' self-invalidation → stale value served.
|
||
await setAppSetting(t, 'perm_trip_edit', 'trip_member');
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
await svc.invalidatePermissionsCache();
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member');
|
||
});
|
||
});
|
||
|
||
// ── module-scoped cache across instances ──────────────────────────────────────
|
||
|
||
describe('module-scoped permissions cache', () => {
|
||
let secondInstance: PermissionsService;
|
||
|
||
// A nested beforeAll, not a top-level await in the describe callback (the
|
||
// legacy file's shape): `appSettings` comes from the file-level beforeAll
|
||
// above, which vitest guarantees has already run by the time any nested
|
||
// beforeAll here executes, but would still be undefined during collection.
|
||
beforeAll(async () => {
|
||
secondInstance = new PermissionsService(appSettings, await createTestUnitOfWork(testDb));
|
||
});
|
||
|
||
it('PERM-SVC-017: checkPermission agrees across independently built instances', async () => {
|
||
expect(await secondInstance.checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
|
||
expect(await secondInstance.checkPermission('trip_delete', 'user', 10, 20, true)).toBe(false);
|
||
});
|
||
|
||
it('PERM-SVC-020: the cache is module-scoped — shared by every service instance', async () => {
|
||
// Save through the DI instance; a second instance sees it immediately
|
||
// (checkPermission for a plain member flips with the stored level).
|
||
await svc.savePermissions({ trip_edit: 'trip_member' });
|
||
expect(await secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true);
|
||
// A direct row write, then invalidate through permissions-cache, the plain
|
||
// function backup.impl.ts calls after a restore. Both service instances
|
||
// must serve the fresh value afterwards.
|
||
await setAppSetting(t, 'perm_trip_edit', 'trip_owner');
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached
|
||
await invalidateSharedCache();
|
||
expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
|
||
expect(await secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false);
|
||
});
|
||
});
|