Files
TREK/server/tests/unit/plugins/trip-card-contributions.controller.test.ts
T
Maurice f72aba0c91 Finish the plugin RPC decorator rollout
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.
2026-08-09 17:38:56 +02:00

104 lines
5.6 KiB
TypeScript

/**
* TripCardContributionsController (#plugins, dashboard trip-card badges): host-rendered
* badges plugins add to the dashboard trip cards via the tripCardProvider hook. Proves
* the gate + fail-safe behaviour AND the mandatory hardening: per-tripId access checks,
* a badge for an un-requested/inaccessible trip is dropped, server-side normalization,
* a URL-scheme allowlist (no click-XSS), and length/count caps.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
// trips 1 and 2 are accessible to user 5; everything else is not.
canAccessTrip: vi.fn((tripId: number, userId: number) => (userId === 5 && (tripId === 1 || tripId === 2) ? { id: tripId } : undefined)),
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { TripCardContributionsController } from '../../../src/nest/plugins/trip-card-contributions.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
function controller(invoke: (id: string, args: unknown[]) => unknown, providers = ['p1']) {
const runtime = {
providersOf: vi.fn(() => providers),
tripCards: vi.fn(async (id: string, _h: string, _fn: string, args: unknown[]) => invoke(id, args)),
} as unknown as PluginHooks;
return { c: new TripCardContributionsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime };
}
const badge = (over: Record<string, unknown> = {}) => ({ tripId: 1, id: 'b1', label: 'Visa', ...over });
describe('TripCardContributionsController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); });
it('gates: disabled / no user / no accessible trip all return [] without consulting providers', async () => {
pluginsEnabled.mockReturnValue(false);
let x = controller(() => [badge()]);
expect(await x.c.get('1', req(5))).toEqual({ contributions: [] });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
x = controller(() => [badge()]);
expect(await x.c.get('1', req(undefined))).toEqual({ contributions: [] }); // no user
expect(x.runtime.providersOf).not.toHaveBeenCalled();
x = controller(() => [badge()]);
expect(await x.c.get('99', req(5))).toEqual({ contributions: [] }); // trip 99 not accessible
expect(x.runtime.providersOf).not.toHaveBeenCalled();
});
it('passes only the accessible, deduped tripIds to the hook once', async () => {
const { c, runtime } = controller(() => [badge()]);
await c.get('1,2,2,99,abc', req(5)); // 99 inaccessible, abc invalid, 2 duplicated
expect(runtime.tripCards).toHaveBeenCalledWith('p1', [1, 2], 5);
});
it('drops a badge whose tripId was not requested / is inaccessible', async () => {
const { c } = controller(() => [badge({ tripId: 1, id: 'ok' }), badge({ tripId: 2, id: 'ok2' }), badge({ tripId: 99, id: 'sneaky' })]);
const out = (await c.get('1,2', req(5))).contributions;
expect(out.map((o) => o.id)).toEqual(['ok', 'ok2']); // tripId 99 was never in the request → dropped
});
it('keeps http/https/mailto urls, DROPS javascript:/data:, caps lengths, defaults a bad tone', async () => {
const { c } = controller(() => [
badge({ id: 'ok', value: 'Approved', url: 'https://x.test', tone: 'success', icon: 'Check' }),
badge({ id: 'js', url: 'javascript:alert(1)' }),
badge({ id: 'mail', url: 'mailto:a@b.c' }),
badge({ id: 'long', label: 'L'.repeat(200), value: 'V'.repeat(500), tone: 'nope' }),
]);
const out = (await c.get('1', req(5))).contributions;
expect(out[0]).toEqual({ pluginId: 'p1', tripId: 1, id: 'ok', label: 'Visa', value: 'Approved', icon: 'Check', tone: 'success', url: 'https://x.test' });
expect((out[1] as { url?: string }).url).toBeUndefined();
expect((out[2] as { url?: string }).url).toBe('mailto:a@b.c');
expect((out[3] as { label: string }).label).toHaveLength(64);
expect((out[3] as { value: string }).value).toHaveLength(256);
expect((out[3] as { tone: string }).tone).toBe('default');
});
it('drops invalid entries and caps PER CARD (not globally) so every card keeps its badges', async () => {
const many = [
badge({ tripId: undefined }),
badge({ id: '' }),
badge({ label: '' }),
'not-an-object',
null,
// 50 badges all for trip 1 → capped at the per-card limit (4), NOT dropped globally
...Array.from({ length: 50 }, (_, i) => badge({ tripId: 1, id: `a${i}` })),
// and 50 for trip 2 → its own 4, proving the cap isn't a shared budget
...Array.from({ length: 50 }, (_, i) => badge({ tripId: 2, id: `b${i}` })),
];
const { c } = controller(() => many);
const out = (await c.get('1,2', req(5))).contributions;
expect(out.filter((o) => o.tripId === 1)).toHaveLength(4);
expect(out.filter((o) => o.tripId === 2)).toHaveLength(4); // trip 2 not starved by trip 1
});
it('merges providers and skips one that throws', async () => {
const { c } = controller((id) => { if (id === 'p2') throw new Error('slow'); return [badge({ id: 'from-p1' })]; }, ['p1', 'p2']);
const out = (await c.get('1', req(5))).contributions;
expect(out.map((o) => o.id)).toEqual(['from-p1']);
});
});