mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
7078ac7a4d
Client CI was red on this branch while dev was green, and no client file had changed. The 536 test files all passed; the job failed on three unhandled rejections, all `window is not defined` from MDashWidgets, attributed to whichever case happened to be running when they landed. The widget fetches api.frankfurter.dev directly and sets state from both handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so that request was never intercepted: it left the runner, came back after the environment was gone, and React tried to schedule an update against a torn down tree. Load dependent by construction, which is why dev happened to pass. Two halves, because either alone leaves the other broken. The widget now aborts in flight work on unmount and on refetch, and treats an abort as "no one is waiting" rather than as a failure to report. That matters outside the runner too: this is a third party call on a mobile screen a user can leave immediately. The handler set gains the third party endpoints the app calls directly rather than through /api, so no suite reaches the real host. An empty rate list is the shape the widget already handles. Also clears twenty more test files, each at its root cause. Two more were passing for the wrong reason: oidc.service.test.ts built AuthService with an AtlasService in the membership slot, undefined for userCleanup and mailer, and the mailer stub in the tokens slot, the same shift admin.service.test.ts had. And CAL-010 claims to serialise a flight byte for byte, but createReservation ignores reservation_time, so the row is written with a null time and CalendarService drops it: the recorded snapshot is truthful about the data the case builds and silent about the flight it advertises.
114 lines
5.8 KiB
TypeScript
114 lines
5.8 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';
|
|
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
|
|
|
const req = (method: string, params: Record<string, unknown>): RpcRequest => ({ k: 'req', id: 'x', method, params });
|
|
|
|
/**
|
|
* db.* is the cheapest handler to watch. The only service it needs is the per-user
|
|
* settings store behind `settings.get`, which none of these cases dispatch, so the stub
|
|
* carries just that read, typed off the real signature.
|
|
*/
|
|
const dbRegistry = () => {
|
|
const settings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
|
|
return createTestPluginRegistry([new DbRpc(settings as PluginUserSettingsService)]);
|
|
};
|
|
|
|
/** 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, {} as never, {} as never),
|
|
]);
|
|
};
|
|
|
|
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]);
|
|
});
|
|
});
|