Files
TREK/server/tests/unit/plugins/view-contributions.controller.test.ts
T
Maurice fe35e26313 refactor(trip-membership): domains ask TripAccessService who may see a trip
Thirty services, controllers, MCP tools and RPC handlers outside the trips
domain injected TripsRepository for one call, findAccessible. They now
inject TripAccessService from the trip-membership leaf, in the same
constructor slot under the same field name; it answers with the same
access row (or undefined) and binds the id exactly as given, so each
caller keeps its own coercion and its own 404, 403 or tool error.
2026-10-10 20:16:00 +02:00

122 lines
6.6 KiB
TypeScript

/**
* ViewContributionsController (#plugins, UI-contribution registry): host-rendered
* columns/actions plugins add into a native planner view. Mirrors
* place-details.controller (gate + fail-safe) and additionally proves the mandatory
* hardening: 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(() => ({
canAccessTrip: vi.fn(async (tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { ViewContributionsController } from '../../../src/nest/plugins/contributions/view-contributions.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.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) => unknown, providers = ['p1']) {
const runtime = {
providersOf: vi.fn(() => providers),
tableContributions: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new ViewContributionsController(runtime, new TripAccessService({ findAccessible: canAccessTrip } as unknown as TripsRepository)), runtime };
}
const col = (over: Record<string, unknown> = {}) => ({ kind: 'column', entityId: 1, id: 'c1', label: 'X', ...over });
const act = (over: Record<string, unknown> = {}) => ({ kind: 'action', entityId: 1, id: 'a1', label: 'Go', target: { kind: 'frame', sub: '/ui' }, ...over });
describe('ViewContributionsController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockResolvedValue({ id: 1 } as never); });
it('gates: disabled / unknown view / no user / non-member all return [] (no plugin calls on the first two)', async () => {
pluginsEnabled.mockReturnValue(false);
let x = controller(() => [col()]);
expect(await x.c.get('reservations', '1', req(5))).toEqual({ contributions: [] });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
x = controller(() => [col()]);
expect(await x.c.get('bogus', '1', req(5))).toEqual({ contributions: [] }); // view not whitelisted
expect(x.runtime.providersOf).not.toHaveBeenCalled();
// costs/packing/files were added to the whitelist — they must be accepted (providers consulted).
for (const view of ['costs', 'packing', 'files']) {
const y = controller(() => [col()]);
expect((await y.c.get(view, '1', req(5))).contributions.length).toBe(1);
expect(y.runtime.providersOf).toHaveBeenCalledWith('tableContributor');
}
expect((await controller(() => [col()]).c.get('places', '1', req(undefined))).contributions).toEqual([]);
canAccessTrip.mockResolvedValue(undefined as never);
expect((await controller(() => [col()]).c.get('day', '1', req(5))).contributions).toEqual([]);
});
it('column: keeps http/https/mailto urls, DROPS javascript:/data:, caps lengths, defaults a bad tone', async () => {
const { c } = controller(() => [
col({ id: 'ok', label: 'Crowd', value: 'Quiet', url: 'https://x.test', tone: 'success', icon: 'Users' }),
col({ id: 'js', label: 'Evil', url: 'javascript:alert(1)' }),
col({ id: 'data', label: 'Evil2', url: 'data:text/html,x' }),
col({ id: 'mail', label: 'Mail', url: 'mailto:a@b.c' }),
col({ id: 'long', label: 'L'.repeat(200), value: 'V'.repeat(500), tone: 'nope' }),
]);
const out = (await c.get('reservations', '1', req(5))).contributions;
expect(out[0]).toEqual({ kind: 'column', pluginId: 'p1', entityId: 1, id: 'ok', label: 'Crowd', value: 'Quiet', url: 'https://x.test', icon: 'Users', tone: 'success' });
expect((out[1] as { url?: string }).url).toBeUndefined(); // javascript: rejected
expect((out[2] as { url?: string }).url).toBeUndefined(); // data: rejected
expect((out[3] as { url?: string }).url).toBe('mailto:a@b.c');
expect((out[4] as { label: string }).label).toHaveLength(64);
expect((out[4] as { value: string }).value).toHaveLength(256);
expect((out[4] as { tone: string }).tone).toBe('default');
});
it('action: keeps valid frame/route targets, drops malformed ones', async () => {
const { c } = controller(() => [
act({ id: 'frame', target: { kind: 'frame', sub: '/ui' } }),
act({ id: 'route', target: { kind: 'route', method: 'POST', sub: '/do' } }),
act({ id: 'badmethod', target: { kind: 'route', method: 'DELETE', sub: '/x' } }),
act({ id: 'nosub', target: { kind: 'frame' } }),
act({ id: 'badkind', target: { kind: 'nope', sub: '/x' } }),
]);
const out = (await c.get('reservations', '1', req(5))).contributions;
expect(out.map((o) => o.id)).toEqual(['frame', 'route']);
});
it('drops entries missing entityId/id/label, non-objects, and unknown kinds', async () => {
const { c } = controller(() => [
col({ entityId: undefined }),
col({ id: '' }),
col({ label: '' }),
{ kind: 'mystery', entityId: 1, id: 'x', label: 'y' },
'not-an-object',
null,
col({ id: 'good' }),
]);
const out = (await c.get('places', '1', req(5))).contributions;
expect(out.map((o) => o.id)).toEqual(['good']);
});
it('caps per-provider counts: <=20 columns and <=10 actions', async () => {
const many = [
...Array.from({ length: 30 }, (_, i) => col({ id: `c${i}` })),
...Array.from({ length: 15 }, (_, i) => act({ id: `a${i}` })),
];
const { c } = controller(() => many);
const out = (await c.get('day', '1', req(5))).contributions;
expect(out.filter((o) => o.kind === 'column')).toHaveLength(20);
expect(out.filter((o) => o.kind === 'action')).toHaveLength(10);
});
it('merges providers, skips one that throws, and passes (view, tripId) + user to the hook', async () => {
const { c, runtime } = controller((id) => { if (id === 'p2') throw new Error('slow'); return [col({ id: 'from-p1' })]; }, ['p1', 'p2']);
const out = (await c.get('reservations', '1', req(5))).contributions;
expect(out.map((o) => o.id)).toEqual(['from-p1']);
expect(runtime.tableContributions).toHaveBeenCalledWith('p1', 'reservations', 1, 5);
});
});