fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed

This commit is contained in:
jubnl
2026-09-22 16:54:45 +02:00
parent 6fafcd72f2
commit 761cd14b2b
37 changed files with 1020 additions and 204 deletions
@@ -12,10 +12,16 @@
* from its 'everybody' default to 'admin', a role-'user' actor's check must
* come back `false`. Before the fix (dispatch outside any context, and
* loadPermissions serving defaults on any error) it came back `true` — a
* fail-open security regression. After BOTH this round's fixes — the wrapper
* here, and `loadPermissions` rethrowing a MikroORM `ValidationError` instead
* of degrading (permissions.service.ts) — an unwrapped dispatch fails CLOSED
* (a HOST_ERROR envelope), never silently `true`.
* fail-open security regression. Task 2's round fixed it two switches away
* from the choke point — the wrapper here, and `loadPermissions` rethrowing a
* MikroORM `ValidationError` instead of degrading (permissions.service.ts) —
* so an unwrapped dispatch failed CLOSED (a HOST_ERROR envelope) but only
* because BOTH switches held. task-6-fix-brief.md item 1 (task-6-review-
* template.md's Important 1 / M-B) moves the fail-closed decision TO the
* choke point itself: an absent `resolveOrm` now makes the dispatch THROW
* before it ever reaches `rpcHost.dispatch`, so the child gets no response at
* all — not even the old HOST_ERROR envelope — and the security property no
* longer depends on `loadPermissions`' rethrow holding too.
*
* Global context is disallowed on purpose (`{ allowGlobalContext: false }`),
* like `tests/unit/nest/database/request-context.test.ts` — the production
@@ -106,16 +112,16 @@ function makeSupervisor(resolveOrm?: () => { em: EntityManager } | undefined) {
}
describe('PluginSupervisor request context (D6, C3)', () => {
it('CTX-PLUGIN-001: unwrapped, the RPC path fails CLOSED on a cold cache — never the old fail-open `true`', async () => {
it('CTX-PLUGIN-001: without a resolveOrm thunk, the dispatch THROWS rather than running unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point, not just downstream)', async () => {
permissions.invalidatePermissionsCache();
const { supervisor, sup, sent } = makeSupervisor(); // no resolveOrm — the bug's exact reproduction
await supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } });
expect(sent).toHaveLength(1);
const res = sent[0];
expect(res.ok).toBe(false);
if (res.ok === false) expect(res.error.message).toMatch(/global (EntityManager|context)/i);
await expect(
supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } }),
).rejects.toThrow(/no ORM available/i);
// The dispatch never ran, so the child never got a response at all —
// not even the old fail-closed HOST_ERROR envelope.
expect(sent).toHaveLength(0);
});
it('CTX-PLUGIN-002: wrapped in withRequestContext, the same dispatch enforces the stored override — a "user" role is refused', async () => {
@@ -4,14 +4,25 @@
* an admin depends on: re-activating a plugin that died stays possible, and a
* crash-restart cycle doesn't leak the dead child's cron tasks.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
import { RpcRateLimiter, TokenBucket } from '../../../src/nest/plugins/host/rate-limit';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
// task-6-fix-brief.md item 1: onMessage's 'req' branch now THROWS without a
// resolveOrm thunk (no more silent unwrapped dispatch), so the one case below
// that dispatches a real 'req' message ("refuses a throttled call...") needs a
// real ORM even though nothing it exercises reaches a repository.
const ormDb = new Database(':memory:');
let ormHandle: TestOrm;
beforeAll(async () => { ormHandle = await createTestOrm(ormDb); });
afterAll(async () => { await ormHandle.close(); ormDb.close(); });
function makeSupervisor() {
const dispose = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {});
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {}, () => ormHandle.orm);
const spawn = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).spawn = spawn;