mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
115 lines
5.6 KiB
TypeScript
115 lines
5.6 KiB
TypeScript
/**
|
|
* JournalEntryRowsController (#plugins): extra rows plugins render under a journal
|
|
* entry via the journalEntryProvider hook. Mirrors the place-details gate (entry ->
|
|
* journey -> canAccessJourney + the Journey addon) and the map-markers hardening:
|
|
* fail-safe + server-side normalization — row count cap, label/value length caps,
|
|
* a URL-scheme allowlist (no click-XSS).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { entryJourney, canAccessJourney, isAddonEnabled, pluginsEnabled } = vi.hoisted(() => ({
|
|
entryJourney: vi.fn((entryId: number) => (entryId === 7 ? { journey_id: 3 } : undefined)),
|
|
canAccessJourney: vi.fn((journeyId: number, userId: number) => (journeyId === 3 && userId === 5 ? { id: 3 } : null)),
|
|
isAddonEnabled: vi.fn(() => true),
|
|
pluginsEnabled: vi.fn(() => true),
|
|
}));
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: { prepare: () => ({ get: (entryId: number) => entryJourney(entryId) }) },
|
|
canAccessTrip: vi.fn(),
|
|
}));
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
vi.mock('../../../src/services/journeyService', () => ({ canAccessJourney }));
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
|
|
import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller';
|
|
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
|
|
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
|
|
|
// 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),
|
|
invokeHook: vi.fn(async (id: string) => invoke(id)),
|
|
} as unknown as PluginRuntimeService;
|
|
return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn), addonsStub), runtime };
|
|
}
|
|
const row = (over: Record<string, unknown> = {}) => ({ label: 'Distance', value: '12 km', ...over });
|
|
|
|
describe('JournalEntryRowsController', () => {
|
|
beforeEach(() => {
|
|
pluginsEnabled.mockReturnValue(true);
|
|
isAddonEnabled.mockReturnValue(true);
|
|
canAccessJourney.mockReturnValue({ id: 3 } as never);
|
|
});
|
|
|
|
it('gates: runtime disabled / journey addon off return [] without touching the runtime', async () => {
|
|
pluginsEnabled.mockReturnValue(false);
|
|
let x = controller(() => [row()]);
|
|
expect(await x.c.get('7', req(5))).toEqual({ providers: [] });
|
|
expect(x.runtime.providersOf).not.toHaveBeenCalled();
|
|
pluginsEnabled.mockReturnValue(true);
|
|
|
|
isAddonEnabled.mockReturnValue(false);
|
|
x = controller(() => [row()]);
|
|
expect(await x.c.get('7', req(5))).toEqual({ providers: [] });
|
|
expect(x.runtime.providersOf).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('gates: bad entryId / no user / unknown entry / no journey access all return []', async () => {
|
|
expect((await controller(() => [row()]).c.get('abc', req(5))).providers).toEqual([]);
|
|
expect((await controller(() => [row()]).c.get('7', req(undefined))).providers).toEqual([]);
|
|
expect((await controller(() => [row()]).c.get('999', req(5))).providers).toEqual([]); // entry not found
|
|
canAccessJourney.mockReturnValue(null as never);
|
|
expect((await controller(() => [row()]).c.get('7', req(5))).providers).toEqual([]); // no access
|
|
});
|
|
|
|
it('keeps valid rows, groups them per plugin, passes the entry + acting user to the hook', async () => {
|
|
const { c, runtime } = controller(() => [row({ url: 'https://ok.example' }), row({ label: 'Steps', value: '9000', url: 'mailto:x@y.z' })]);
|
|
const res = await c.get('7', req(5));
|
|
expect(res.providers).toEqual([{
|
|
pluginId: 'p1',
|
|
items: [
|
|
{ label: 'Distance', value: '12 km', url: 'https://ok.example' },
|
|
{ label: 'Steps', value: '9000', url: 'mailto:x@y.z' },
|
|
],
|
|
}]);
|
|
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'journalEntryProvider', 'getRows', [7], 5, 5000);
|
|
});
|
|
|
|
it('drops javascript:/empty/unparseable urls, caps lengths, drops label-less rows and non-objects', async () => {
|
|
const { c } = controller(() => [
|
|
row({ label: 'L'.repeat(200), value: 'V'.repeat(500), url: 'javascript:alert(1)' }),
|
|
row({ url: '' }),
|
|
row({ url: 'not a url' }),
|
|
{ label: '', value: 'orphan' }, // no label
|
|
{ value: 42 }, // still no label
|
|
null, // non-object
|
|
]);
|
|
const items = (await c.get('7', req(5))).providers[0].items;
|
|
expect(items).toHaveLength(3);
|
|
expect(items[0].label.length).toBe(60);
|
|
expect(items[0].value!.length).toBe(200);
|
|
expect(items[0].url).toBeUndefined(); // click-XSS scheme dropped
|
|
expect(items[1].url).toBeUndefined();
|
|
expect(items[2].url).toBeUndefined();
|
|
expect(items[2].value).toBe('12 km');
|
|
});
|
|
|
|
it('caps at 12 rows per provider, omits empty providers, skips a failing one, tolerates non-arrays', async () => {
|
|
const many = Array.from({ length: 15 }, (_, i) => row({ label: `r${i}` }));
|
|
const { c } = controller((id) => {
|
|
if (id === 'bad') throw new Error('boom');
|
|
if (id === 'empty') return [{ value: 'no label' }]; // normalizes to nothing
|
|
if (id === 'garbage') return 'not an array';
|
|
return many;
|
|
}, ['good', 'bad', 'empty', 'garbage']);
|
|
const res = await c.get('7', req(5));
|
|
expect(res.providers).toHaveLength(1); // only 'good' survives
|
|
expect(res.providers[0].items).toHaveLength(12);
|
|
});
|
|
});
|