mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
f0f69df67e
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become JourneyDomainService and JourneyShareService. Every SQL statement, error string and broadcast is the one that shipped. The two could never be split: the share service gates on isOwner, which lives in the domain service, so it injects it. JourneyDomainModule is a leaf carrying just the two services. Places, assignments and the plugin host each need a handful of journey functions; importing JourneyModule for that would drag MemoriesModule and both photo providers into their graphs. Same shape as TrekPhotosModule. The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is down to _shared.ts and registerTools() no longer fans out to anything. journey.bridge.ts keeps four exports, for the journey resources in src/mcp/resources.ts that register at import time. McpAccessMode is host-augmentable now. journey:share is neither read nor write, so the three share tools would have had to be opaque predicates - and predicates bypass the access policy entirely, invisible to the boot-time scope gate. A scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'. Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed PlacesService with five of eight arguments. tsconfig only includes src, so nothing typechecked it, and the missing dependencies were harmless until the journey skeleton hooks landed on the place write paths. journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25 cases for the skeleton lifecycle, the photo gallery and the preference row take the domain service from 73.9% to 82.8%.
115 lines
5.7 KiB
TypeScript
115 lines
5.7 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';
|
|
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
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, { canAccessJourney } as unknown as JourneyDomainService), 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);
|
|
});
|
|
});
|