/** * 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 { 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) => 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, { canAccessTrip } as unknown as DatabaseService), runtime }; } const col = (over: Record = {}) => ({ kind: 'column', entityId: 1, id: 'c1', label: 'X', ...over }); const act = (over: Record = {}) => ({ 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); }); });