mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
f72aba0c91
The last 20 wire methods move onto the decorators: db.* and the three unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the router is down to what was never domain-specific: build the granted subset of the dispatch map, dispatch, audit, map errors. PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With the handlers in the domains it has two dependencies left, so it is now PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router bound with this plugin as the caller, and the audit sink. The 18 host-to-plugin hook calls get the same treatment. They used to spell out the fn name and the timeout at each controller, which meant nothing checked that a hook:* grant on the consent screen had a consumer behind it, that the fn matched what the SDK documents, or that two call sites agreed on the budget. PluginHooks declares all 18 with @PluginHook and performs them, and the controllers call it by name. With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without a handler, or a hook without a consumer, now fails app boot instead of surfacing as a PERMISSION_DENIED nobody can explain.
106 lines
5.4 KiB
TypeScript
106 lines
5.4 KiB
TypeScript
/**
|
|
* The supervisor tags every host-bound request with `_inv`, resolves the acting user
|
|
* from it BEFORE dispatch, and then passes the request on untouched. That made `_inv`
|
|
* visible to every handler and, in effect, a reserved param name.
|
|
*
|
|
* dispatch() now strips it. These tests prove the strip is invisible: the same result,
|
|
* the same audit row, and the acting user still resolved from the host side.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
|
|
import type { RpcRequest, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
|
|
import { makeDeps } from '../../helpers/rpc-host-deps';
|
|
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
|
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
|
|
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
|
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
|
|
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
|
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
|
|
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
|
|
|
const req = (method: string, params: Record<string, unknown>): RpcRequest => ({ k: 'req', id: 'x', method, params });
|
|
|
|
/** db.* is the cheapest handler to watch, and it needs no services of its own. */
|
|
const dbRegistry = () => createTestPluginRegistry([new DbRpc()]);
|
|
|
|
/** trips.getById lives on the decorators now, so the audit cases bind it through them. */
|
|
const tripsRegistry = () => {
|
|
const db = {
|
|
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined),
|
|
prepare: () => ({ get: () => ({ id: 1, title: 'Japan' }), all: () => [] }),
|
|
} as unknown as DatabaseService;
|
|
const guards = new PluginGuards(db, {} as never, {} as never);
|
|
return createTestPluginRegistry([
|
|
new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards),
|
|
]);
|
|
};
|
|
|
|
describe('dispatch strips the supervisor _inv marker', () => {
|
|
it('RPCINV-001 the handler never sees _inv', async () => {
|
|
const deps = makeDeps();
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry());
|
|
await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [], _inv: 'req-7' }));
|
|
expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []);
|
|
});
|
|
|
|
it('RPCINV-002 the result is identical with and without the marker', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps(), dbRegistry());
|
|
const withMarker = (await host.dispatch(req('db.query', { sql: 'SELECT 1', _inv: 'req-7' }))) as RpcResponse;
|
|
const without = (await host.dispatch(req('db.query', { sql: 'SELECT 1' }))) as RpcResponse;
|
|
expect(withMarker.ok).toBe(true);
|
|
expect(withMarker.result).toEqual(without.result);
|
|
});
|
|
|
|
it('RPCINV-003 the audit row is unchanged, because auditResource reads named fields only', async () => {
|
|
const deps = makeDeps();
|
|
const audit = vi.fn();
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit }, tripsRegistry());
|
|
await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }), 42);
|
|
expect(audit).toHaveBeenCalledWith({
|
|
pluginId: 'p',
|
|
actingUserId: 42,
|
|
method: 'trips.getById',
|
|
resource: 'trip:1',
|
|
code: 'ok',
|
|
});
|
|
});
|
|
|
|
it('RPCINV-004 the acting user still comes from the host, not from the params', async () => {
|
|
const deps = makeDeps();
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps, tripsRegistry());
|
|
// No acting user bound: the trip read is refused even though _inv is present.
|
|
const res = await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }));
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('RPCINV-005 params without the marker are passed through untouched', async () => {
|
|
const deps = makeDeps();
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry());
|
|
await host.dispatch(req('db.exec', { sql: 'INSERT INTO t VALUES (1)', args: [1] }));
|
|
expect(deps.data.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)', [1]);
|
|
});
|
|
|
|
it('RPCINV-006 a handler reading the whole payload object no longer finds _inv in it', async () => {
|
|
// packing.setBagMembers is the one handler that reads the params object as a
|
|
// whole (asPayload(p).userIds) rather than a named field off it, which makes it
|
|
// the case where a stray _inv could actually change behaviour.
|
|
const setBagMembers = vi.fn(() => ({ bagId: 80, members: [3] }));
|
|
const packing = { setBagMembers } as unknown as PackingService;
|
|
const guards = {
|
|
requireActor: () => 42,
|
|
requireTripEdit: () => undefined,
|
|
} as unknown as PluginGuards;
|
|
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService;
|
|
const host = new PluginRpcHost(
|
|
'p',
|
|
new Set(['db:write:packing']),
|
|
makeDeps(),
|
|
createTestPluginRegistry([new PackingRpc(packing, realtime, guards)]),
|
|
);
|
|
await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [3], _inv: 'req-7' }), 42);
|
|
expect(setBagMembers).toHaveBeenCalledWith('1', '80', [3]);
|
|
});
|
|
});
|