Release branch for 4.0.0.
This commit is contained in:
jubnl
2026-08-27 13:22:39 +02:00
committed by GitHub
parent e60427f813
commit 77dddd5e8a
3355 changed files with 403744 additions and 67215 deletions
@@ -0,0 +1,266 @@
/**
* The refusal paths of the five addon-gated domains: no bound user, invalid input,
* and the service saying no.
*
* The happy paths and the addon gates live in addon-gates.rpc.test.ts and the domain
* suites; this file is about what happens when something is wrong, because those are
* the branches a move like this leaves untested by default.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const err = (r: unknown) => (r as RpcError).error;
/** A status-tagged error, the shape CollectionsService throws. */
function statusError(status: number, message: string) {
return Object.assign(new Error(message), { status });
}
function build(overrides: {
collab?: Record<string, unknown>;
atlas?: Record<string, unknown>;
vacay?: Record<string, unknown>;
journey?: Record<string, unknown>;
collections?: Record<string, unknown>;
} = {}) {
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as DatabaseService;
const guards = new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
);
const realtime = { broadcast: vi.fn() } as never;
const registry = createTestPluginRegistry([
new CollabRpc({ createNote: vi.fn(() => ({})), createPoll: vi.fn(() => ({})), votePoll: vi.fn(() => ({ error: null, poll: {} })), createMessage: vi.fn(() => ({ error: null, message: {} })), listNotes: vi.fn(() => []), listPolls: vi.fn(() => []), listMessages: vi.fn(() => []), ...overrides.collab } as never, realtime, guards),
new AtlasRpc({ listVisitedCountries: vi.fn(() => []), listManuallyVisitedRegions: vi.fn(() => []), bucketList: vi.fn(() => []), markCountry: vi.fn(), unmarkCountry: vi.fn(), markRegion: vi.fn(), unmarkRegion: vi.fn(), createBucketItem: vi.fn(() => ({})), deleteBucketItem: vi.fn(() => true), ...overrides.atlas } as never, guards),
new VacayRpc({ getPlanData: vi.fn(() => ({})), getActivePlanId: vi.fn(() => 1), toggleEntry: vi.fn(() => ({})), toggleCompanyHoliday: vi.fn(() => ({})), ...overrides.vacay } as never, guards),
new JournalRpc({ listJourneys: vi.fn(() => []), listEntries: vi.fn(() => []), createEntry: vi.fn(() => ({})), updateEntry: vi.fn(() => ({})), deleteEntry: vi.fn(() => true), createJourney: vi.fn(() => ({})), deleteJourney: vi.fn(() => true), ...overrides.journey } as never, guards),
// deletePlace is async in production, so its double returns a promise: an
// un-awaited call in the RPC handler would let a rejection sail past
// mapCollectionError as an unhandled rejection while the handler still replied
// {deleted: true}.
new CollectionsRpc({ listCollections: vi.fn(() => []), getCollection: vi.fn(() => ({})), createCollection: vi.fn(() => ({})), updateCollection: vi.fn(() => ({})), savePlace: vi.fn(() => ({})), copyToTrip: vi.fn(() => ({})), deletePlace: vi.fn(() => Promise.resolve(undefined)), ...overrides.collections } as never, guards),
]);
const granted = new Set([
'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal',
'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:write:vacay',
'db:read:collections', 'db:write:collections',
]);
return new PluginRpcHost('p', granted, makeDeps(), registry);
}
/** Every user-scoped method, with the refusal it owes a job or onLoad. */
const USERLESS: Array<[string, Record<string, unknown>, string]> = [
['journal.listMine', {}, 'journal reads require an authenticated user context'],
['journal.getEntries', { journeyId: 7 }, 'journal reads require an authenticated user context'],
['journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }, 'journal writes require an authenticated user context'],
['journal.updateEntry', { entryId: 1, input: {} }, 'journal writes require an authenticated user context'],
['journal.deleteEntry', { entryId: 1 }, 'journal writes require an authenticated user context'],
['journal.createJourney', { input: { title: 'J' } }, 'journal writes require an authenticated user context'],
['journal.deleteJourney', { journeyId: 7 }, 'journal writes require an authenticated user context'],
['atlas.visited', {}, 'atlas reads require an authenticated user context'],
['atlas.bucketList', {}, 'atlas reads require an authenticated user context'],
['atlas.markCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'],
['atlas.unmarkCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'],
['atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }, 'atlas writes require an authenticated user context'],
['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas writes require an authenticated user context'],
['atlas.createBucketItem', { input: { name: 'K' } }, 'atlas writes require an authenticated user context'],
['atlas.deleteBucketItem', { itemId: 1 }, 'atlas writes require an authenticated user context'],
['vacay.mine', {}, 'vacay reads require an authenticated user context'],
['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'],
['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'],
['collections.listMine', {}, 'collection reads require an authenticated user context'],
['collections.get', { id: 1 }, 'collection reads require an authenticated user context'],
['collections.create', { input: { name: 'C' } }, 'collection writes require an authenticated user context'],
['collections.update', { id: 1, input: { name: 'C' } }, 'collection writes require an authenticated user context'],
['collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }, 'collection writes require an authenticated user context'],
['collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }, 'collection writes require an authenticated user context'],
['collections.deletePlace', { placeId: 1 }, 'collection writes require an authenticated user context'],
['collab.createNote', { tripId: 1, input: { title: 'n' } }, 'collab note writes require an authenticated user context'],
['collab.createPoll', { tripId: 1, input: { question: 'q', options: ['a', 'b'] } }, 'collab poll writes require an authenticated user context'],
['collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }, 'collab poll writes require an authenticated user context'],
['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab message writes require an authenticated user context'],
];
describe('addon-gated domains refuse a job or onLoad', () => {
for (const [method, params, message] of USERLESS) {
it(`ADDONERR-userless-${method}`, async () => {
const res = await build().dispatch(req(method, params), undefined);
expect(err(res).code).toBe('RESOURCE_FORBIDDEN');
expect(err(res).message).toBe(message);
});
}
});
describe('addon-gated domains validate their input', () => {
it('ADDONERR-001 collab refuses a blank note title, a thin poll and an oversized message', async () => {
const host = build();
expect(err(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: ' ' } }), 42)).message).toBe('note title is required');
expect(err(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: ' ', options: ['a', 'b'] } }), 42)).message).toBe('poll question is required');
expect(err(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'q', options: ['a'] } }), 42)).message).toBe('a poll needs at least two options');
expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'x'.repeat(4001) }), 42)).message).toBe('message text is required (max 4000 chars)');
expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: ' ' }), 42)).message).toBe('message text is required (max 4000 chars)');
});
it('ADDONERR-002 the collab service reports its own failures as BAD_PARAMS', async () => {
const host = build({ collab: { votePoll: vi.fn(() => ({ error: 'poll is closed' })), createMessage: vi.fn(() => ({ error: 'reply target is gone' })) } });
expect(err(await host.dispatch(req('collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'poll is closed' });
expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'reply target is gone' });
});
it('ADDONERR-003 atlas rejects a code that is empty or too long', async () => {
const host = build();
expect(err(await host.dispatch(req('atlas.markCountry', { code: '' }), 42)).message).toBe('code must be a short code');
expect(err(await host.dispatch(req('atlas.markCountry', { code: 'TOOLONGCODE' }), 42)).message).toBe('code must be a short code');
expect(err(await host.dispatch(req('atlas.markRegion', { regionCode: 'JP-13', countryCode: '' }), 42)).message).toBe('countryCode must be a short code');
expect(err(await host.dispatch(req('atlas.createBucketItem', { input: { name: ' ' } }), 42)).message).toBe('bucket item name is required');
});
it('ADDONERR-004 a missing bucket item is refused, naming it', async () => {
const host = build({ atlas: { deleteBucketItem: vi.fn(() => false) } });
expect(err(await host.dispatch(req('atlas.deleteBucketItem', { itemId: 404 }), 42)).message).toBe('no bucket item 404 for this user');
});
it('ADDONERR-004b a duplicate bucket item is the caller\'s mistake, not an internal error (#1898)', async () => {
const host = build({
atlas: { createBucketItem: vi.fn(() => { throw new BucketItemExistsError(); }) },
});
expect(err(await host.dispatch(req('atlas.createBucketItem', { input: { name: 'Kyoto' } }), 42)))
.toMatchObject({ code: 'BAD_PARAMS', message: 'bucket item already exists' });
});
it('ADDONERR-005 vacay insists on an ISO date', async () => {
const host = build();
expect(err(await host.dispatch(req('vacay.toggleEntry', { date: '01.01.2027' }), 42)).message).toBe('date must be YYYY-MM-DD');
expect(err(await host.dispatch(req('vacay.toggleCompanyHoliday', { date: 42 }), 42)).message).toBe('date must be YYYY-MM-DD');
});
it('ADDONERR-006 journal needs an entry_date and a journal title', async () => {
const host = build();
expect(err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: {} }), 42)).message).toBe('entry_date is required');
expect(err(await host.dispatch(req('journal.createJourney', { input: { title: ' ' } }), 42)).message).toBe('journal title is required');
});
it('ADDONERR-007 an unreadable or uneditable journey is RESOURCE_FORBIDDEN', async () => {
const host = build({
journey: {
listEntries: vi.fn(() => null),
createEntry: vi.fn(() => null),
updateEntry: vi.fn(() => null),
deleteEntry: vi.fn(() => false),
deleteJourney: vi.fn(() => false),
},
});
expect(err(await host.dispatch(req('journal.getEntries', { journeyId: 7 }), 42)).message).toBe('no access to journey 7');
expect(err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }), 42)).message).toBe('no editable journey 7 for this user');
expect(err(await host.dispatch(req('journal.updateEntry', { entryId: 3, input: {} }), 42)).message).toBe('no editable journal entry 3 for this user');
expect(err(await host.dispatch(req('journal.deleteEntry', { entryId: 3 }), 42)).message).toBe('no editable journal entry 3 for this user');
expect(err(await host.dispatch(req('journal.deleteJourney', { journeyId: 7 }), 42)).message).toBe('no deletable journal 7 for this user');
});
it('ADDONERR-008 collections map the service status onto the RPC error taxonomy', async () => {
// 403/404 become RESOURCE_FORBIDDEN, 400/409 become BAD_PARAMS. Without this the
// plugin would see an opaque HOST_ERROR and could not tell the cases apart.
const forbidden = build({ collections: { createCollection: vi.fn(() => { throw statusError(403, 'not your collection'); }) } });
expect(err(await forbidden.dispatch(req('collections.create', { input: { name: 'C' } }), 42))).toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'not your collection' });
const missing = build({ collections: { updateCollection: vi.fn(() => { throw statusError(404, 'no such collection'); }) } });
expect(err(await missing.dispatch(req('collections.update', { id: 1, input: { name: 'C' } }), 42))).toMatchObject({ code: 'RESOURCE_FORBIDDEN' });
const conflict = build({ collections: { savePlace: vi.fn(() => { throw statusError(409, 'already saved'); }) } });
expect(err(await conflict.dispatch(req('collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'already saved' });
const badRequest = build({ collections: { copyToTrip: vi.fn(() => { throw statusError(400, 'nothing to copy'); }) } });
expect(err(await badRequest.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }), 42))).toMatchObject({ code: 'BAD_PARAMS' });
});
it('ADDONERR-009 an untagged collections failure stays a HOST_ERROR', async () => {
// Only the known statuses are translated; anything else keeps its own taxonomy
// rather than being dressed up as a permission problem.
const host = build({ collections: { deletePlace: vi.fn(() => { throw new Error('disk on fire'); }) } });
expect(err(await host.dispatch(req('collections.deletePlace', { placeId: 1 }), 42))).toMatchObject({ code: 'HOST_ERROR', message: 'disk on fire' });
});
it('ADDONERR-009b a refused collections delete is ForbiddenResource, not {deleted: true}', async () => {
// deletePlace is async, so a status-tagged rejection has to be awaited before the
// handler decides what to reply — an un-awaited call would let this rejection
// become an unhandled promise rejection while the RPC still answered {deleted: true}.
const host = build({ collections: { deletePlace: vi.fn(() => Promise.reject(statusError(403, 'not your collection'))) } });
const res = await host.dispatch(req('collections.deletePlace', { placeId: 1 }), 42);
expect(res.ok).toBe(false);
expect(err(res)).toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'not your collection' });
});
it('ADDONERR-010 collections reject a payload the shared schema refuses', async () => {
const host = build();
expect(err(await host.dispatch(req('collections.create', { input: {} }), 42)).code).toBe('BAD_PARAMS');
expect(err(await host.dispatch(req('collections.update', { id: 1, input: { name: 42 } }), 42)).code).toBe('BAD_PARAMS');
expect(err(await host.dispatch(req('collections.savePlace', { input: {} }), 42)).code).toBe('BAD_PARAMS');
expect(err(await host.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [] } }), 42)).code).toBe('BAD_PARAMS');
});
it('ADDONERR-010b an update or savePlace with only some fields still passes', async () => {
const updateCollection = vi.fn(() => ({}));
const host = build({ collections: { updateCollection } });
await host.dispatch(req('collections.update', { id: 1, input: { description: 'only this' } }), 42);
expect(updateCollection).toHaveBeenCalled();
});
it('ADDONERR-010c a journal entry update carries a non-object input through asPayload', async () => {
const updateEntry = vi.fn(() => ({ id: 1 }));
const host = build({ journey: { updateEntry } });
// asPayload wraps a non-object in { value }, which is the published contract.
await host.dispatch(req('journal.updateEntry', { entryId: 1, input: 'note' }), 42);
expect(updateEntry).toHaveBeenCalledWith(1, 42, { value: 'note' });
});
it('ADDONERR-010d a company holiday without a note passes undefined, not an empty string', async () => {
const toggleCompanyHoliday = vi.fn(() => ({}));
const host = build({ vacay: { toggleCompanyHoliday } });
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01' }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
});
it('ADDONERR-010e a collab message without a replyTo passes null', async () => {
const createMessage = vi.fn(() => ({ error: null, message: {} }));
const host = build({ collab: { createMessage } });
await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42);
expect(createMessage).toHaveBeenCalledWith('1', 42, 'hi', null);
await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi', replyTo: 5 }), 42);
expect(createMessage).toHaveBeenLastCalledWith('1', 42, 'hi', 5);
});
it('ADDONERR-010f listMessages passes the before cursor only when given', async () => {
const listMessages = vi.fn(() => []);
const host = build({ collab: { listMessages } });
await host.dispatch(req('collab.listMessages', { tripId: 1 }), 42);
expect(listMessages).toHaveBeenCalledWith(1, undefined);
await host.dispatch(req('collab.listMessages', { tripId: 1, before: 9 }), 42);
expect(listMessages).toHaveBeenLastCalledWith(1, 9);
});
it('ADDONERR-011 a region without a name falls back to its code', async () => {
const markRegion = vi.fn();
const host = build({ atlas: { markRegion } });
await host.dispatch(req('atlas.markRegion', { regionCode: 'jp-13', countryCode: 'jp' }), 42);
// Codes are upper-cased on the way in, and the name defaults to the code.
expect(markRegion).toHaveBeenCalledWith(42, 'JP-13', 'jp-13', 'JP');
});
});
@@ -0,0 +1,151 @@
/**
* The addon gates of the migrated plugin surface, checked as one table.
*
* These gates used to live inside the deps factory's closure rather than in the
* router, which made them the single most likely thing to lose in the decorator
* migration: a handler that calls its service directly still works, just without the
* addon check, and neither the compiler nor the router tests would notice. The plan
* calls it out as the most probable security regression of the whole rollout.
*
* So every addon-gated method gets the same two assertions: with the addon off it is
* refused with that addon's message, and the underlying service is never touched.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc';
import { CostsRpc } from '../../../src/nest/budget/costs.rpc';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
/** Records every service call, so "the service was never touched" is checkable. */
function spyService(calls: string[], name: string) {
return new Proxy(
{},
{
get: (_t, prop) => (...args: unknown[]) => {
calls.push(`${name}.${String(prop)}`);
// Shapes the handlers destructure on the happy path.
if (prop === 'votePoll' || prop === 'createMessage') return { error: null, poll: {}, message: {} };
if (prop === 'listEntries') return [];
if (prop === 'getActivePlanId') return 1;
// collections.deletePlace is async in production (it deletes a storage
// object); a promise-returning double here pins the fix that awaits it —
// an un-awaited call would leave an unhandled rejection unnoticed by every
// assertion below.
if (name === 'collections' && prop === 'deletePlace') return Promise.resolve(undefined);
if (String(prop).startsWith('delete')) return true;
if (String(prop).startsWith('list')) return [];
return { id: 1 };
},
},
) as never;
}
/** Everything granted, every trip accessible, every permission allowed, addons OFF. */
function build(addonOn: boolean) {
const calls: string[] = [];
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as DatabaseService;
const guards = new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => addonOn) } as unknown as AddonsService,
);
const registry = createTestPluginRegistry([
new CollabRpc(spyService(calls, 'collab'), { broadcast: vi.fn() } as never, guards),
new AtlasRpc(spyService(calls, 'atlas'), guards),
new VacayRpc(spyService(calls, 'vacay'), guards),
new JournalRpc(spyService(calls, 'journey'), guards),
new CollectionsRpc(spyService(calls, 'collections'), guards),
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
]);
const granted = new Set([
'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal',
'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:write:vacay',
'db:read:collections', 'db:write:collections', 'db:read:costs', 'db:write:costs',
]);
return { calls, host: new PluginRpcHost('p', granted, makeDeps(), registry) };
}
/** Every addon-gated method with a payload that reaches its gate. */
const GATED: Array<[string, Record<string, unknown>, string]> = [
['collab.listNotes', { tripId: 1 }, 'collab'],
['collab.listPolls', { tripId: 1 }, 'collab'],
['collab.listMessages', { tripId: 1 }, 'collab'],
['collab.createNote', { tripId: 1, input: { title: 'n' } }, 'collab'],
['collab.createPoll', { tripId: 1, input: { question: 'q?', options: ['a', 'b'] } }, 'collab'],
['collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }, 'collab'],
['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab'],
['journal.listMine', {}, 'journey'],
['journal.getEntries', { journeyId: 7 }, 'journey'],
['journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }, 'journey'],
['journal.updateEntry', { entryId: 1, input: {} }, 'journey'],
['journal.deleteEntry', { entryId: 1 }, 'journey'],
['journal.createJourney', { input: { title: 'J' } }, 'journey'],
['journal.deleteJourney', { journeyId: 7 }, 'journey'],
['atlas.visited', {}, 'atlas'],
['atlas.bucketList', {}, 'atlas'],
['atlas.markCountry', { code: 'JP' }, 'atlas'],
['atlas.unmarkCountry', { code: 'JP' }, 'atlas'],
['atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }, 'atlas'],
['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas'],
['atlas.createBucketItem', { input: { name: 'Kyoto' } }, 'atlas'],
['atlas.deleteBucketItem', { itemId: 1 }, 'atlas'],
['vacay.mine', {}, 'vacay'],
['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay'],
['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay'],
['collections.listMine', {}, 'collections'],
['collections.get', { id: 1 }, 'collections'],
['collections.create', { input: { name: 'C' } }, 'collections'],
['collections.update', { id: 1, input: { name: 'C' } }, 'collections'],
['collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }, 'collections'],
['collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }, 'collections'],
['collections.deletePlace', { placeId: 1 }, 'collections'],
['costs.getByTrip', { tripId: 1 }, 'costs'],
['costs.listMine', {}, 'costs'],
['costs.create', { tripId: 1, input: { name: 'H', price: 1 } }, 'costs'],
['costs.update', { tripId: 1, itemId: 1, input: { name: 'H' } }, 'costs'],
['costs.delete', { tripId: 1, itemId: 1 }, 'costs'],
];
describe('every addon-gated plugin method refuses when its addon is off', () => {
for (const [method, params, addon] of GATED) {
it(`ADDONGATE-${method} is refused with "the ${addon} addon is disabled"`, async () => {
const f = build(false);
const res = (await f.host.dispatch(req(method, params), 42)) as RpcError;
expect(res.ok).toBe(false);
expect(res.error.code).toBe('RESOURCE_FORBIDDEN');
expect(res.error.message).toBe(`the ${addon} addon is disabled`);
// The gate has to come before the service, not after it.
expect(f.calls).toEqual([]);
});
}
it('ADDONGATE-covers-every-gated-method', () => {
// Guards the table itself: if a later PR adds an addon-gated method without a row
// here, this number is the reminder.
expect(GATED).toHaveLength(37);
});
it('ADDONGATE-the-same-calls-succeed-with-the-addon-on', async () => {
const f = build(true);
for (const [method, params] of GATED) {
const res = await f.host.dispatch(req(method, params), 42);
expect(res.ok, `${method} should pass with the addon on`).toBe(true);
}
expect(f.calls.length).toBeGreaterThan(0);
});
});
@@ -14,16 +14,16 @@ const { pluginsEnabled } = vi.hoisted(() => ({
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip: vi.fn() }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { AtlasLayersController } from '../../../src/nest/plugins/atlas-layers.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { AtlasLayersController } from '../../../src/nest/plugins/contributions/atlas-layers.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.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),
invokeHook: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginRuntimeService;
atlasLayers: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new AtlasLayersController(runtime), runtime };
}
const layer = (over: Record<string, unknown> = {}) => ({ id: 'l1', countries: [{ code: 'FR' }], ...over });
@@ -53,7 +53,7 @@ describe('AtlasLayersController', () => {
countries: [{ code: 'FR', tone: 'success', label: 'Paris trip' }],
}]);
// user-scoped: empty args, the acting user is bound host-side
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'atlasLayerProvider', 'getLayers', [], 5, 5000);
expect(runtime.atlasLayers).toHaveBeenCalledWith('p1', 5);
});
it('uppercase-coerces codes, drops anything that is not ISO alpha-2, defaults a bad tone', async () => {
@@ -1,906 +0,0 @@
/**
* The production wiring that connects a plugin's capability host to the real
* privileged modules (#plugins, M1). Verifies the per-plugin data db is cached,
* a granted db:own call works through the wired host, and trip broadcasts are
* force-namespaced to plugin:{id}:{event}.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// A real in-memory core db so the metadata deps (inline SQL) and metaEntityTrip
// resolution run for real; trip 1 is owned by user 5. canAccessTrip is stubbed so
// user 5 (owner) can access trip 1 and user 6 cannot.
vi.mock('../../../src/db/database', () => {
const Database = require('better-sqlite3');
const d = new Database(':memory:');
d.exec(`
CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER);
CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT);
CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key));
CREATE TABLE packing_items (id INTEGER PRIMARY KEY, trip_id INTEGER, is_private INTEGER, owner_id INTEGER);
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
`);
d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run();
d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (70, 1, 0, 5)').run(); // public before an update
d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (71, 1, 1, 5)').run(); // private before an update
d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run();
d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run();
d.prepare('INSERT INTO users (id, role) VALUES (5, ?)').run('trip_owner');
d.prepare('INSERT INTO users (id, role) VALUES (6, ?)').run('user');
d.prepare("INSERT INTO users (id, role, email) VALUES (77, 'user', 'demo@trek.app')").run(); // a demo-account member for the DEMO_MODE upload guard
d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run(); // user 6 shares trip 1 with owner 5
d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 77)').run();
return { db: d, canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && (userId === 5 || userId === 6 || userId === 77) ? { id: 1, user_id: 5 } : undefined) };
});
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
// Addon gate — flip per test to exercise the "addon disabled" branch of the reads.
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) }));
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
vi.mock('../../../src/nest/budget/budget.service', () => ({
BudgetService: class {
async create(tid: string, input: Record<string, unknown>) { return { id: 1, trip_id: Number(tid), ...input }; }
async update(id: string, tid: string, input: Record<string, unknown>) {
return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input };
}
remove(id: string, _tid: string) { return id !== '404'; }
},
}));
// Edit permission — flip per test to exercise the gates.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn((..._a: any[]) => true as boolean) }));
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
// The core write services are delegated to; mock them so the create-rpc-host deps'
// wiring + error branches run without the full core schema. The error classes must
// be defined INSIDE the factory (vi.mock is hoisted above module-scope code).
vi.mock('../../../src/services/tripService', () => {
class NotFoundError extends Error {}
class ValidationError extends Error {}
return {
updateTrip: (tripId: number, _u: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
if (input.title === 'gone') throw new NotFoundError('no trip');
if (input.title === 'crash') throw new Error('unexpected');
return { updatedTrip: { id: tripId, ...input } };
},
createTrip: (userId: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 };
},
listTrips: () => [{ id: 1 }],
NotFoundError, ValidationError,
};
});
vi.mock('../../../src/services/exchangeRateService', () => ({
getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08, GBP: 0.85 })),
}));
vi.mock('../../../src/services/placeService', () => ({
createPlace: vi.fn((tid: string, body: Record<string, unknown>) => ({ id: 10, trip_id: Number(tid), ...body })),
updatePlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })),
deletePlace: vi.fn((_tid: string, pid: string) => pid !== '99'),
}));
vi.mock('../../../src/services/dayService', () => ({
createDay: vi.fn((tid: number) => ({ id: 20, trip_id: tid, assignments: [] })),
getDay: vi.fn((id: number) => (id === 99 ? undefined : { id, title: null })),
updateDay: vi.fn((id: number) => ({ id, assignments: [] })),
deleteDay: vi.fn(),
listDays: vi.fn((tid: number) => ({ days: [{ id: 3, trip_id: Number(tid), day_number: 1, assignments: [], notes_items: [] }] })),
listAccommodations: vi.fn((tid: number) => [{ id: 11, trip_id: Number(tid), place_name: 'Ryokan' }]),
// place 999 / day 88 don't belong to the trip
validateAccommodationRefs: vi.fn((_tid: number, placeId?: number, startDayId?: number, endDayId?: number) => {
const errors: { field: string; message: string }[] = [];
if (placeId === 999) errors.push({ field: 'place_id', message: 'Place not found' });
if (startDayId === 88 || endDayId === 88) errors.push({ field: 'start_day_id', message: 'Start day not found' });
return errors;
}),
createAccommodation: vi.fn((tid: number, data: Record<string, unknown>) => ({ id: 60, trip_id: Number(tid), ...data })),
getAccommodation: vi.fn((id: number) => (Number(id) === 404 ? undefined : { id: Number(id), place_id: 7, start_day_id: 3, end_day_id: 4 })),
updateAccommodation: vi.fn((id: number, _existing: unknown, fields: Record<string, unknown>) => ({ id: Number(id), ...fields })),
deleteAccommodation: vi.fn((id: number) => (Number(id) === 61 ? { linkedReservationId: 40, deletedBudgetItemId: 9 } : { linkedReservationId: null, deletedBudgetItemId: null })),
}));
vi.mock('../../../src/services/assignmentService', () => ({
createAssignment: vi.fn((dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })),
deleteAssignment: vi.fn(),
dayExists: vi.fn((dayId: number) => dayId === 3),
placeExists: vi.fn((placeId: number) => placeId === 7),
getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id })),
}));
vi.mock('../../../src/services/budgetService', () => ({ listBudgetItems: vi.fn(() => []) }));
vi.mock('../../../src/services/packingService', () => ({
listItems: vi.fn((tid: number, userId: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]),
// Return the item with the #858 privacy fields the create-rpc-host deps scope on.
createItem: vi.fn((tid: number, input: { name: string; visibility?: string; recipient_ids?: number[] }, ownerId?: number) => {
if (input.visibility === 'personal') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: [] };
if (input.visibility === 'shared') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: (input.recipient_ids || []).map((id) => ({ user_id: id })) };
return { id: 70, trip_id: Number(tid), name: input.name, is_private: 0, owner_id: ownerId };
}),
// itemId 99 => a stale-write conflict result; otherwise the after-state (is_private per input).
updateItem: vi.fn((tid: number, id: string, input: { is_private?: boolean }) =>
Number(id) === 99 ? { conflict: true, server: { id: 99 } } : { id: Number(id), trip_id: Number(tid), is_private: input.is_private ? 1 : 0, owner_id: 5 },
),
// The raw deleted row (owner-only for a private item, no recipients — #858).
deleteItem: vi.fn((_tid: number, id: string) => (Number(id) === 404 ? null : Number(id) === 71 ? { id: 71, is_private: 1, owner_id: 5 } : { id: Number(id), is_private: 0 })),
listBags: vi.fn((tid: number) => [{ id: 80, trip_id: Number(tid), name: 'Backpack' }]),
createBag: vi.fn((tid: number, data: { name: string }) => ({ id: 80, trip_id: Number(tid), name: data.name })),
updateBag: vi.fn((_tid: number, bagId: string) => (Number(bagId) === 404 ? null : { id: Number(bagId), name: 'Renamed' })),
deleteBag: vi.fn((_tid: number, bagId: string) => Number(bagId) !== 404),
setBagMembers: vi.fn((_tid: number, bagId: string, userIds: number[]) => (Number(bagId) === 404 ? null : userIds.map((u) => ({ user_id: u })))),
}));
vi.mock('../../../src/services/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict }));
vi.mock('../../../src/services/weatherService', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) }));
vi.mock('../../../src/services/categoryService', () => ({ listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]) }));
vi.mock('../../../src/services/tagService', () => ({
listTags: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]),
createTag: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })),
getTagByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })),
updateTag: vi.fn((tagId: number, name?: string) => ({ id: Number(tagId), name })),
deleteTag: vi.fn(),
}));
vi.mock('../../../src/services/todoService', () => ({
listItems: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), name: 'Pack' }]),
createItem: vi.fn((tid: number, data: { name: string }) => ({ id: 90, trip_id: Number(tid), name: data.name })),
updateItem: vi.fn((_tid: number, id: string) => (Number(id) === 404 ? null : { id: Number(id), name: 'Done' })),
deleteItem: vi.fn((_tid: number, id: string) => Number(id) !== 404),
}));
const { testFilesDir } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const osm = require('node:os') as typeof import('node:os');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pm = require('node:path') as typeof import('node:path');
return { testFilesDir: pm.join(osm.tmpdir(), 'trek-crh-files-test') };
});
vi.mock('../../../src/services/fileService', () => ({
listFiles: vi.fn((tid: number, trash: boolean) => [{ id: 2, trip_id: tid, trash }]),
filesDir: testFilesDir,
BLOCKED_EXTENSIONS: ['.exe', '.bat', '.sh'],
createFile: vi.fn((tid: number, file: { filename: string; originalname: string; size: number }, uploadedBy: number) => ({ id: 130, trip_id: Number(tid), ...file, uploaded_by: uploadedBy })),
createFileLink: vi.fn(() => [{ file_id: 130 }]),
getFileById: vi.fn((id: number) => (Number(id) === 404 ? undefined : Number(id) === 500 ? { id: 500, filename: 'huge.mp4', original_name: 'huge.mp4', mime_type: 'video/mp4', file_size: 400 * 1024 * 1024, deleted_at: null } : { id: Number(id), filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: 'application/pdf', file_size: 3, deleted_at: null, description: 'old', place_id: null, reservation_id: null })),
resolveFilePath: vi.fn((filename: string) => ({ resolved: `${testFilesDir}/${filename}`, safe: filename !== 'evil' })),
updateFile: vi.fn((id: number, _cur: unknown, updates: unknown) => ({ id: Number(id), ...(updates as object) })),
softDeleteFile: vi.fn(),
// reservation_id/place_id 999 = belongs to another trip
findForeignLinkTarget: vi.fn((_tid: number, opts: { reservation_id?: number | null; place_id?: number | null }) =>
(Number(opts.reservation_id) === 999 ? 'reservation_id' : Number(opts.place_id) === 999 ? 'place_id' : null)),
}));
vi.mock('../../../src/services/collabService', () => ({
listNotes: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), title: 'Note' }]),
listPolls: vi.fn((tid: number) => [{ id: 2, trip_id: Number(tid), question: 'Q?' }]),
listMessages: vi.fn((tid: number, before?: number) => [{ id: 3, trip_id: Number(tid), text: 'hi', _before: before ?? null }]),
createNote: vi.fn((tid: number, uid: number, data: { title: string }) => ({ id: 140, trip_id: Number(tid), created_by: uid, title: data.title })),
createPoll: vi.fn((tid: number, _uid: number, data: { question: string }) => ({ id: 141, trip_id: Number(tid), question: data.question })),
votePoll: vi.fn((_tid: number, pollId: number, _uid: number, optionIndex: number) =>
(optionIndex > 5 ? { error: 'Invalid option' } : { poll: { id: Number(pollId), votes: 1 } })),
createMessage: vi.fn((tid: number, uid: number, text: string) =>
(text === 'toolong' ? { error: 'Message too long' } : { message: { id: 142, trip_id: Number(tid), user_id: uid, text } })),
}));
vi.mock('../../../src/services/tripMembership', () => ({
joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op
}));
const { notifySend } = vi.hoisted(() => ({ notifySend: vi.fn(async () => undefined) }));
vi.mock('../../../src/services/notificationService', () => ({ send: notifySend }));
// userId 7 = no provider configured; everyone else resolves to a stub config.
vi.mock('../../../src/nest/llm-parse/llm-config.resolver', () => ({
resolveLlmConfig: vi.fn((uid: number) => (uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' })),
}));
const { llmExtract } = vi.hoisted(() => ({ llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]) }));
vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) }));
// The per-user settings read + OAuth broker the runtime deps delegate to.
vi.mock('../../../src/nest/plugins/plugins.service', () => ({ readUserSettingDecrypted: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)) }));
vi.mock('../../../src/nest/plugins/plugin-oauth.service', () => ({ PluginOAuthService: class { async getAccessToken(_pid: string, uid: number) { return uid === 5 ? 'tok-5' : null; } } }));
// Reservations: the Nest service is delegated to; mock it so the create-rpc-host
// reservation deps' side-effect branches (accommodation / budget-sync / notify) run.
vi.mock('../../../src/nest/reservations/reservations.service', () => ({
ReservationsService: class {
create(tid: string, input: Record<string, unknown>) {
return { reservation: { id: 40, trip_id: Number(tid), ...input }, accommodationCreated: input.title === 'Stay' };
}
getReservation(id: string) { return id === '404' ? undefined : { id: Number(id), title: 'Old', type: 'flight' }; }
update(id: string, tid: string, input: Record<string, unknown>) {
return { reservation: { id: Number(id), trip_id: Number(tid), ...input }, accommodationChanged: input.title === 'New' };
}
remove(id: string) {
if (id === '404') return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
return { deleted: { id: Number(id), title: 'Gone', type: 'hotel', accommodation_id: 7 }, accommodationDeleted: true, deletedBudgetItemId: 9 };
}
list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; }
syncBudgetOnCreate() {}
syncBudgetOnUpdate() {}
notifyBookingChange() {}
},
}));
vi.mock('../../../src/services/journeyService', () => ({
listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]),
// journeyId 88 = no access (listEntries self-gates to null); else returns entries
listEntries: vi.fn((journeyId: number, uid: number) => (journeyId === 88 ? null : [{ id: 10, journey_id: journeyId, author_id: uid }])),
// journeyId 99 = not editable by the acting user (canEdit inside returns null/false)
createEntry: vi.fn((journeyId: number, uid: number, data: unknown) => (journeyId === 99 ? null : { id: 120, journey_id: journeyId, created_by: uid, ...(data as object) })),
updateEntry: vi.fn((entryId: number, _uid: number, data: unknown) => (entryId === 99 ? null : { id: entryId, ...(data as object) })),
deleteEntry: vi.fn((entryId: number) => entryId !== 99),
}));
vi.mock('../../../src/services/atlasService', () => ({
listVisitedCountries: vi.fn(() => [{ country_code: 'JP' }]),
listManuallyVisitedRegions: vi.fn(() => [{ region_code: 'JP-13' }]),
listBucketList: vi.fn((uid: number) => [{ id: 5, user_id: uid, name: 'Kyoto' }]),
markCountryVisited: vi.fn(),
unmarkCountryVisited: vi.fn(),
markRegionVisited: vi.fn(),
unmarkRegionVisited: vi.fn(),
createBucketItem: vi.fn((uid: number, data: { name: string }) => ({ id: 110, user_id: uid, name: data.name })),
deleteBucketItem: vi.fn((_uid: number, itemId: number) => Number(itemId) !== 404),
}));
vi.mock('../../../src/services/vacayService', () => ({
getPlanData: vi.fn((uid: number) => ({ plan: { id: 1, owner: uid } })),
getActivePlanId: vi.fn(() => 77),
toggleEntry: vi.fn((uid: number, planId: number) => ({ action: 'added', uid, planId })),
toggleCompanyHoliday: vi.fn((planId: number) => ({ action: 'added', planId })),
}));
vi.mock('../../../src/services/collectionsService', () => {
const httpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; };
return {
listCollections: vi.fn((uid: number) => ({ collections: [{ id: 1, owner: uid }] })),
getCollection: vi.fn((uid: number, id: number) => ({ id, owner: uid, places: [] })),
createCollection: vi.fn((uid: number, body: unknown) => ({ id: 100, owner_id: uid, ...(body as object) })),
// id 99 = viewer-only (403); id 404 = invisible (404) — the service throws status-tagged errors
updateCollection: vi.fn((_uid: number, id: number, body: unknown) => { if (id === 99) httpError(403, 'read-only'); if (id === 404) httpError(404, 'Collection not found'); return { id, ...(body as object) }; }),
savePlace: vi.fn((uid: number, body: unknown) => ({ id: 101, saved_by: uid, ...(body as object) })),
copyToTrip: vi.fn(() => ({ copied: 2, skipped: [] })),
deletePlace: vi.fn((_uid: number, placeId: number) => { if (placeId === 404) httpError(404, 'Collection not found'); }),
};
});
vi.mock('../../../src/services/dayNoteService', () => ({
listNotes: vi.fn((dayId: number, tripId: number) => [{ id: 1, day_id: dayId, trip_id: tripId }]),
createNote: vi.fn((dayId: number, _tripId: number, text: string) => ({ id: 50, day_id: dayId, text })),
getNote: vi.fn((id: number) => (id === 99 ? undefined : { id, text: 'Old' })),
updateNote: vi.fn((id: number, _current: unknown, fields: Record<string, unknown>) => ({ id, ...fields })),
deleteNote: vi.fn(),
dayExists: vi.fn((dayId: number) => dayId === 3),
}));
import { createRealRpcHost, getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/create-rpc-host';
import { db as mockDb } from '../../../src/db/database';
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-'));
process.env.TREK_PLUGINS_DATA_DIR = tmp;
});
afterAll(() => {
closePluginDataDb('wired');
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('create-rpc-host wiring', () => {
it('caches one data db per plugin id', () => {
const a = getPluginDataDb('wired');
const b = getPluginDataDb('wired');
expect(a).toBe(b);
});
it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => {
const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip']));
const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } });
expect(migrated.ok).toBe(true);
// acting user 5 is a member of trip 1 (mocked canAccessTrip) → broadcast allowed + namespaced
await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } }, 5);
expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 });
const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user']));
// a per-user broadcast may only target the acting user themselves
await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 5, event: 'hi', data: {} } }, 5);
expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:wired', event: 'hi' });
});
it('db.tx dispatches an atomic batch through to the plugin db under db:own', async () => {
const host = createRealRpcHost('wtx', new Set(['db:own']));
await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' } });
const res = await host.dispatch({ k: 'req', id: '2', method: 'db.tx', params: { ops: [
{ sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] },
{ sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] },
] } });
expect(res.ok).toBe(true);
expect((res as { result: { results: unknown[] } }).result.results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]);
// malformed ops → BAD_PARAMS, and no db:own grant → not reachable
const bad = await host.dispatch({ k: 'req', id: '3', method: 'db.tx', params: { ops: [{ sql: 42 }] } });
expect(bad.ok).toBe(false);
const noGrant = createRealRpcHost('wtx2', new Set());
expect((await noGrant.dispatch({ k: 'req', id: '4', method: 'db.tx', params: { ops: [] } })).ok).toBe(false);
closePluginDataDb('wtx');
closePluginDataDb('wtx2');
});
it('closePluginDataDb closes and drops the cached handle', () => {
getPluginDataDb('transient');
closePluginDataDb('transient');
// a fresh get after close returns a NEW instance (cache was cleared)
const a = getPluginDataDb('transient');
closePluginDataDb('transient');
const b = getPluginDataDb('transient');
expect(a).not.toBe(b);
closePluginDataDb('transient');
});
it('getPluginDataDb recreates a handle that was closed WITHOUT eviction (terminal-failure dispose)', () => {
const a = getPluginDataDb('closedcache');
expect(a.isOpen()).toBe(true);
// simulate the supervisor's dispose() path: close the handle but leave it cached
a.close();
expect(a.isOpen()).toBe(false);
const b = getPluginDataDb('closedcache'); // must NOT return the dead handle
expect(b).not.toBe(a);
expect(b.isOpen()).toBe(true);
expect(b.exec('CREATE TABLE t (v)').changes).toBe(0); // usable again
closePluginDataDb('closedcache');
});
});
describe('create-rpc-host — planner write + metadata deps', () => {
const host = (...perms: string[]) => createRealRpcHost('writer', new Set(perms));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid);
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); });
afterAll(() => closePluginDataDb('writer'));
it('places.create/update/delete delegate + broadcast; a missing place is RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:places');
expect((await call(h, 'places.create', { tripId: 1, input: { name: 'P' } })).ok).toBe(true);
// Write deps re-emit the SAME core event the controllers do (not the plugin: namespace).
expect(broadcast).toHaveBeenCalledWith(1, 'place:created', expect.anything());
expect((await call(h, 'places.update', { tripId: 1, placeId: 5, input: { name: 'Q' } })).ok).toBe(true);
expect((await call(h, 'places.update', { tripId: 1, placeId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'places.delete', { tripId: 1, placeId: 5 })).ok).toBe(true);
expect((await call(h, 'places.delete', { tripId: 1, placeId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('days + itinerary delegate; a day/place/assignment outside the trip is refused', async () => {
const h = host('db:write:days', 'db:write:itinerary');
expect((await call(h, 'days.create', { tripId: 1, input: { notes: 'n' } })).ok).toBe(true);
expect((await call(h, 'days.update', { tripId: 1, dayId: 3, input: { notes: 'x' } })).ok).toBe(true);
expect((await call(h, 'days.delete', { tripId: 1, dayId: 3 })).ok).toBe(true);
expect((await call(h, 'days.update', { tripId: 1, dayId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 })).ok).toBe(true);
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 99, placeId: 7 })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 3, placeId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 30 })).ok).toBe(true);
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('trips.update: archive/cover need their own permission; service errors map to RPC codes', async () => {
const h = host('db:write:trips');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'T' } })).ok).toBe(true);
checkPermission.mockImplementation((action: string) => action !== 'trip_archive');
expect((await call(h, 'trips.update', { tripId: 1, input: { is_archived: 1 } })).error.code).toBe('RESOURCE_FORBIDDEN');
checkPermission.mockImplementation((action: string) => action !== 'trip_cover_upload');
expect((await call(h, 'trips.update', { tripId: 1, input: { cover_image: '/x.jpg' } })).error.code).toBe('RESOURCE_FORBIDDEN');
checkPermission.mockReturnValue(true);
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'gone' } })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'crash' } })).error.code).toBe('HOST_ERROR'); // rethrow of an unknown error
});
it('metadata: round-trips and enforces the key/value/access limits', async () => {
const h = host('db:meta');
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true);
expect((await call(h, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 });
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert path
expect((await call(h, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place → trip 1
expect((await call(h, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true });
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error.code).toBe('BAD_PARAMS');
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error.code).toBe('BAD_PARAMS');
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('costs deps: create + reads wired through the budget service and addon gate', async () => {
const h = host('db:read:costs', 'db:write:costs');
expect((await call(h, 'costs.create', { tripId: 1, input: { name: 'Hotel' } })).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'budget:created', expect.anything());
expect((await call(h, 'costs.getByTrip', { tripId: 1 })).ok).toBe(true);
expect((await call(h, 'costs.listMine', {})).ok).toBe(true);
});
it('costs deps: update wired through BudgetService.update + broadcasts budget:updated', async () => {
const h = host('db:write:costs');
expect((await call(h, 'costs.update', { tripId: 1, itemId: 9, input: { name: 'Hostel' } })).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'budget:updated', expect.anything());
});
it('costs deps: update of a missing item is RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:costs');
expect((await call(h, 'costs.update', { tripId: 1, itemId: 404, input: { name: 'X' } })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('costs deps: delete wired through BudgetService.remove + broadcasts budget:deleted', async () => {
const h = host('db:write:costs');
const res = await call(h, 'costs.delete', { tripId: 1, itemId: 9 });
expect(res.ok).toBe(true);
expect(res.result).toMatchObject({ deleted: true });
expect(broadcast).toHaveBeenCalledWith(1, 'budget:deleted', { itemId: 9 });
});
it('costs deps: delete of a missing item is RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:costs');
expect((await call(h, 'costs.delete', { tripId: 1, itemId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('packing/files read deps delegate to their services (trash excluded for files)', async () => {
const h = host('db:read:packing', 'db:read:files');
// acting user 5 is threaded to the packing service (#858 private-item filter); _uid proves it
expect((await call(h, 'packing.list', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, name: 'Socks', _uid: 5 }]);
expect((await call(h, 'files.list', { tripId: 1 })).result).toEqual([{ id: 2, trip_id: 1, trash: false }]);
});
it('users.getById is scoped to people the acting user shares a trip with', async () => {
const h = host('db:read:users');
expect((await call(h, 'users.getById', { id: 6 }, 5)).ok).toBe(true); // 5 (owner) + 6 (member) share trip 1
expect((await call(h, 'users.getById', { id: 999 }, 5)).error.code).toBe('RESOURCE_FORBIDDEN');
});
});
describe('create-rpc-host — reservations, day notes, cross-trip + addon reads (Waves 1-5)', () => {
const host = (...perms: string[]) => createRealRpcHost('w15', new Set(perms));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid);
beforeEach(() => {
checkPermission.mockReset(); checkPermission.mockReturnValue(true);
isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true);
});
afterAll(() => closePluginDataDb('w15'));
it('reservations create/update/delete run the real side-effect wiring; a missing one is refused', async () => {
const h = host('db:write:reservations');
expect((await call(h, 'reservations.create', { tripId: 1, input: { title: 'Flight' } })).ok).toBe(true);
// title 'Stay' drives the accommodationCreated branch of the wiring
expect((await call(h, 'reservations.create', { tripId: 1, input: { title: 'Stay' } })).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:created', expect.anything(), undefined);
expect((await call(h, 'reservations.update', { tripId: 1, reservationId: 40, input: { title: 'New' } })).ok).toBe(true);
expect((await call(h, 'reservations.update', { tripId: 1, reservationId: 404, input: { title: 'X' } })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'reservations.delete', { tripId: 1, reservationId: 40 })).ok).toBe(true);
expect((await call(h, 'reservations.delete', { tripId: 1, reservationId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('day notes create/update/delete run the wiring; a day/note outside the trip is refused', async () => {
const h = host('db:write:daynotes');
expect((await call(h, 'daynotes.create', { tripId: 1, dayId: 3, input: { text: 'Pack' } })).ok).toBe(true);
expect((await call(h, 'daynotes.create', { tripId: 1, dayId: 88, input: { text: 'x' } })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'daynotes.update', { tripId: 1, dayId: 3, noteId: 5, input: { text: 'y' } })).ok).toBe(true);
expect((await call(h, 'daynotes.update', { tripId: 1, dayId: 3, noteId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'daynotes.delete', { tripId: 1, dayId: 3, noteId: 5 })).ok).toBe(true);
expect((await call(h, 'daynotes.delete', { tripId: 1, dayId: 3, noteId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('cross-trip reads enumerate accessible trips and reservations', async () => {
const h = host('db:read:trips');
expect((await call(h, 'trips.listMine', {})).ok).toBe(true);
const r = await call(h, 'reservations.listMine', {});
expect(r.ok).toBe(true);
expect(r.result).toHaveLength(1);
});
it('wave-13 wiring: collab reads, journal entries, atlas bucket, file content, trip create, rates delegate correctly', async () => {
// collab reads delegate to collabService and require the collab addon
const collab = host('db:read:collab');
expect((await call(collab, 'collab.listNotes', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, title: 'Note' }]);
expect((await call(collab, 'collab.listMessages', { tripId: 1, before: 9 })).result).toEqual([{ id: 3, trip_id: 1, text: 'hi', _before: 9 }]);
isAddonEnabled.mockReturnValueOnce(false);
expect((await call(collab, 'collab.listPolls', { tripId: 1 })).error.code).toBeDefined(); // addon off -> refused
// journal.getEntries: delegates + self-gate (journey 88 = no access)
const journal = host('db:read:journal');
expect((await call(journal, 'journal.getEntries', { journeyId: 7 })).result).toEqual([{ id: 10, journey_id: 7, author_id: 5 }]);
expect((await call(journal, 'journal.getEntries', { journeyId: 88 })).error.code).toBe('RESOURCE_FORBIDDEN');
// atlas.bucketList
const atlas = host('db:read:atlas');
expect((await call(atlas, 'atlas.bucketList', {})).result).toEqual([{ id: 5, user_id: 5, name: 'Kyoto' }]);
// files.getContent: size-capped (file 500 = 400MB -> BAD_PARAMS), 404 refused
fs.mkdirSync(testFilesDir, { recursive: true });
fs.writeFileSync(`${testFilesDir}/visa.pdf`, 'hi');
const files = host('db:read:files:content');
const content = await call(files, 'files.getContent', { tripId: 1, fileId: 2 });
expect(content.result).toMatchObject({ name: 'visa.pdf', mimetype: 'application/pdf', content_base64: Buffer.from('hi').toString('base64') });
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 500 })).error.code).toBe('BAD_PARAMS');
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
// trips.create: owner = acting user, delegates to createTrip; validation error mapped
const create = host('db:create:trips');
expect((await call(create, 'trips.create', { input: { title: 'Japan' } })).result).toMatchObject({ id: 99, user_id: 5, title: 'Japan' });
expect((await call(create, 'trips.create', { input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
// rates.get: tenant-free, delegates to the cached service
const rates = host('rates:read');
expect((await call(rates, 'rates.get', { base: 'EUR' })).result).toMatchObject({ EUR: 1, USD: 1.08 });
});
it('trip-scoped reads are wired to the hydrated services (days incl. assignments, reservations incl. endpoints)', async () => {
const h = host('db:read:trips');
const days = await call(h, 'trips.getDays', { tripId: 1 });
expect(days.ok).toBe(true);
// listDays returns { days: [...] }; the wiring unwraps to the bare array like getPlaces
expect(days.result).toEqual([{ id: 3, trip_id: 1, day_number: 1, assignments: [], notes_items: [] }]);
const res = await call(h, 'trips.getReservations', { tripId: 1 });
expect(res.ok).toBe(true);
expect(res.result).toEqual([{ id: 1, trip_id: 1, title: 'Flight' }]);
const acc = await call(h, 'trips.getAccommodations', { tripId: 1 });
expect(acc.ok).toBe(true);
expect(acc.result).toEqual([{ id: 11, trip_id: 1, place_name: 'Ryokan' }]);
});
it('accommodations create validates refs, creates via dayService and emits the cascade broadcasts', async () => {
const h = host('db:write:accommodations');
const good = await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00' } });
expect(good.ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:created', expect.anything());
// the auto-created partner hotel reservation announces itself, like the REST path
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:created', {});
// a place/day of another trip is refused before anything is written
expect((await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 999, start_day_id: 3, end_day_id: 4 } })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 88, end_day_id: 4 } })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('accommodations update/delete scope the row to the trip; delete cascades reservation + budget broadcasts', async () => {
const h = host('db:write:accommodations');
expect((await call(h, 'accommodations.update', { tripId: 1, accommodationId: 60, input: { notes: 'late checkout' } })).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:updated', expect.anything());
expect((await call(h, 'accommodations.update', { tripId: 1, accommodationId: 404, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
const del = await call(h, 'accommodations.delete', { tripId: 1, accommodationId: 61 });
expect(del.ok).toBe(true);
expect(del.result).toMatchObject({ deleted: true });
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:deleted', { reservationId: 40 });
expect(broadcast).toHaveBeenCalledWith(1, 'budget:deleted', { itemId: 9 });
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:deleted', { accommodationId: 61 });
expect((await call(h, 'accommodations.delete', { tripId: 1, accommodationId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('addon reads delegate, and a disabled addon is refused', async () => {
const h = host('db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:collections');
expect((await call(h, 'journal.listMine', {})).ok).toBe(true);
expect((await call(h, 'atlas.visited', {})).ok).toBe(true);
expect((await call(h, 'vacay.mine', {})).ok).toBe(true);
expect((await call(h, 'collections.listMine', {})).ok).toBe(true);
expect((await call(h, 'collections.get', { id: 1 })).ok).toBe(true);
isAddonEnabled.mockReturnValue(false);
expect((await call(h, 'journal.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'collections.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN');
});
});
describe('create-rpc-host — packing write with #858 privacy-scoped broadcasts', () => {
const host = () => createRealRpcHost('pk', new Set(['db:write:packing']))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
host().dispatch({ k: 'req', id: 'x', method, params }, uid)
// the onlyUserId (5th arg) of every broadcast for `event` since the last clear:
// undefined = whole trip room, a number = that user's sockets only.
const fanout = (event: string) => broadcast.mock.calls.filter((c) => c[1] === event).map((c) => c[4])
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() })
afterAll(() => closePluginDataDb('pk'))
it('create: Common -> whole room; Personal -> owner-only; Shared -> owner + recipients', async () => {
expect((await call('packing.create', { tripId: 1, input: { name: 'Common', visibility: 'common' } })).ok).toBe(true)
expect(fanout('packing:created')).toEqual([undefined]) // whole room
broadcast.mockClear()
expect((await call('packing.create', { tripId: 1, input: { name: 'Mine', visibility: 'personal' } })).ok).toBe(true)
expect(fanout('packing:created')).toEqual([5]) // owner-only
broadcast.mockClear()
expect((await call('packing.create', { tripId: 1, input: { name: 'Ours', visibility: 'shared', recipient_ids: [6] } })).ok).toBe(true)
expect([...fanout('packing:created')].sort()).toEqual([5, 6]) // owner + recipient, never the room
})
it('update: the four public<->private transitions route correctly (never leaks a privatized item)', async () => {
await call('packing.update', { tripId: 1, itemId: 71, input: { is_private: true } }) // stays private (71 seeded private)
expect(fanout('packing:updated')).toEqual([5])
expect(fanout('packing:deleted')).toEqual([])
expect(fanout('packing:created')).toEqual([])
broadcast.mockClear()
await call('packing.update', { tripId: 1, itemId: 70, input: { is_private: true } }) // public -> private (70 seeded public)
expect(fanout('packing:deleted')).toEqual([undefined]) // drop from the room FIRST (the anti-leak)
expect(fanout('packing:created')).toEqual([5]) // then re-add owner-only
broadcast.mockClear()
await call('packing.update', { tripId: 1, itemId: 71, input: { is_private: false } }) // private -> public
expect(fanout('packing:created')).toEqual([undefined])
expect(fanout('packing:updated')).toEqual([undefined])
broadcast.mockClear()
await call('packing.update', { tripId: 1, itemId: 70, input: { is_private: false } }) // stays public
expect(fanout('packing:updated')).toEqual([undefined])
expect(fanout('packing:deleted')).toEqual([])
})
it('update: a stale-write conflict is BAD_PARAMS and never broadcasts', async () => {
const res = await call('packing.update', { tripId: 1, itemId: 99, input: { name: 'x' } })
expect((res as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect(broadcast).not.toHaveBeenCalled()
})
it('delete: a private item is owner-scoped; a missing one is RESOURCE_FORBIDDEN', async () => {
await call('packing.delete', { tripId: 1, itemId: 71 })
expect(fanout('packing:deleted')).toEqual([5]) // owner-only (recipients get no packing:deleted)
broadcast.mockClear()
await call('packing.delete', { tripId: 1, itemId: 70 })
expect(fanout('packing:deleted')).toEqual([undefined]) // common -> room
const missing = await call('packing.delete', { tripId: 1, itemId: 404 })
expect((missing as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
})
describe('create-rpc-host — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => {
const host = (...perms: string[]) => createRealRpcHost('w1', new Set(perms))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true) })
afterAll(() => closePluginDataDb('w1'))
it('weather + categories are tenant-free reads (no user needed)', async () => {
expect((await call(host('weather:read'), 'weather.get', { lat: 48, lng: 11 }, undefined)).ok).toBe(true)
expect((await call(host('db:read:categories'), 'categories.list', {}, undefined)).ok).toBe(true)
})
it('tags: list/create/update/delete are the acting user\'s; a missing tag is RESOURCE_FORBIDDEN', async () => {
const h = host('db:read:tags', 'db:write:tags')
expect((await call(h, 'tags.list', {}, 5)).ok).toBe(true)
expect((await call(h, 'tags.create', { input: { name: 'x' } }, 5)).ok).toBe(true)
expect((await call(h, 'tags.update', { tagId: 1, input: { name: 'y' } }, 5)).ok).toBe(true)
expect((await call(h, 'tags.delete', { tagId: 1 }, 5)).ok).toBe(true)
expect(((await call(h, 'tags.update', { tagId: 404, input: {} }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect(((await call(h, 'tags.delete', { tagId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('trips.members returns the roster', async () => {
const r = await call(host('db:read:trips'), 'trips.members', { tripId: 1 }, 5)
expect(r.ok).toBe(true)
expect(Array.isArray(r.result)).toBe(true)
})
it('todos list/create/update/delete run the wiring; a missing one is RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:todos', 'db:read:todos')
expect((await call(h, 'todos.list', { tripId: 1 }, 5)).ok).toBe(true)
expect((await call(h, 'todos.create', { tripId: 1, input: { name: 'Pack' } }, 5)).ok).toBe(true)
expect((await call(h, 'todos.update', { tripId: 1, todoId: 90, input: { checked: 1 } }, 5)).ok).toBe(true)
expect((await call(h, 'todos.delete', { tripId: 1, todoId: 90 }, 5)).ok).toBe(true)
expect(((await call(h, 'todos.update', { tripId: 1, todoId: 404, input: {} }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect(((await call(h, 'todos.delete', { tripId: 1, todoId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('packing bags list/create/update/delete/setMembers run the wiring', async () => {
const h = host('db:write:packing')
expect((await call(h, 'packing.listBags', { tripId: 1 }, 5)).ok).toBe(true)
expect((await call(h, 'packing.createBag', { tripId: 1, input: { name: 'Bag' } }, 5)).ok).toBe(true)
expect((await call(h, 'packing.updateBag', { tripId: 1, bagId: 80, input: { name: 'X' } }, 5)).ok).toBe(true)
expect((await call(h, 'packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [5] }, 5)).ok).toBe(true)
expect((await call(h, 'packing.deleteBag', { tripId: 1, bagId: 80 }, 5)).ok).toBe(true)
expect(((await call(h, 'packing.deleteBag', { tripId: 1, bagId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
})
describe('create-rpc-host — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => {
const host = (...perms: string[]) => createRealRpcHost('w2', new Set(perms))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true) })
afterAll(() => closePluginDataDb('w2'))
it('atlas writes delegate uid-scoped; disabled addon refused; missing bucket item forbidden', async () => {
const h = host('db:write:atlas')
expect((await call(h, 'atlas.markCountry', { code: 'JP' })).ok).toBe(true)
expect((await call(h, 'atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' })).ok).toBe(true)
expect((await call(h, 'atlas.unmarkRegion', { regionCode: 'JP-13' })).ok).toBe(true)
expect((await call(h, 'atlas.createBucketItem', { input: { name: 'Kyoto' } })).ok).toBe(true)
expect((await call(h, 'atlas.deleteBucketItem', { itemId: 110 })).ok).toBe(true)
expect(((await call(h, 'atlas.deleteBucketItem', { itemId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
isAddonEnabled.mockReturnValue(false)
expect(((await call(h, 'atlas.markCountry', { code: 'JP' })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('vacay writes resolve the plan host-side from the acting user', async () => {
const h = host('db:write:vacay')
const r = await call(h, 'vacay.toggleEntry', { date: '2026-08-01' })
expect(r.ok).toBe(true)
expect(r.result).toMatchObject({ uid: 5, planId: 77 }) // uid + the user's own active plan, never a plugin-named one
expect((await call(h, 'vacay.toggleCompanyHoliday', { date: '2026-12-24' })).ok).toBe(true)
})
it('journal writes map an uneditable journey/entry to RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:journal')
expect((await call(h, 'journal.createEntry', { journeyId: 1, input: { entry_date: '2026-08-01' } })).ok).toBe(true)
expect(((await call(h, 'journal.createEntry', { journeyId: 99, input: { entry_date: '2026-08-01' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect((await call(h, 'journal.updateEntry', { entryId: 120, input: { story: 'x' } })).ok).toBe(true)
expect(((await call(h, 'journal.updateEntry', { entryId: 99, input: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect((await call(h, 'journal.deleteEntry', { entryId: 120 })).ok).toBe(true)
expect(((await call(h, 'journal.deleteEntry', { entryId: 99 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('collections writes map the service 403/404 to RESOURCE_FORBIDDEN', async () => {
const h = host('db:write:collections')
expect((await call(h, 'collections.create', { input: { name: 'Tokyo eats' } })).ok).toBe(true)
expect((await call(h, 'collections.update', { id: 1, input: { name: 'Renamed' } })).ok).toBe(true)
expect(((await call(h, 'collections.update', { id: 99, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // viewer-only 403
expect(((await call(h, 'collections.update', { id: 404, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // invisible 404
expect((await call(h, 'collections.savePlace', { input: { collection_id: 1, name: 'Ramen' } })).ok).toBe(true)
expect((await call(h, 'collections.copyToTrip', { input: { trip_id: 1, place_ids: [101] } })).ok).toBe(true)
expect((await call(h, 'collections.deletePlace', { placeId: 101 })).ok).toBe(true)
expect(((await call(h, 'collections.deletePlace', { placeId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
})
describe('create-rpc-host — Wave 3 wiring (files write / collab / member-add)', () => {
const host = (...perms: string[]) => createRealRpcHost('w3', new Set(perms))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() })
afterAll(() => closePluginDataDb('w3'))
it('files.create writes bytes, blocks bad extensions + foreign targets, broadcasts file:created', async () => {
const h = host('db:write:files')
const good = await call(h, 'files.create', { tripId: 1, input: { name: 'plan.pdf', content_base64: Buffer.from('hello').toString('base64') } })
expect(good.ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:created')).toBe(true)
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'evil.exe', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'noext', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: 'aGk=', reservation_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('files.create blocks a demo user while DEMO_MODE is on, but not other members', async () => {
const prev = process.env.DEMO_MODE
process.env.DEMO_MODE = 'true'
try {
const h = host('db:write:files')
// user 77 is the demo account (demo@trek.app) → the plugin upload is refused
const denied = await call(h, 'files.create', { tripId: 1, input: { name: 'demo.pdf', content_base64: Buffer.from('x').toString('base64') } }, 77)
expect((denied as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
// a normal member (user 5, no demo email) is unaffected
const ok = await call(h, 'files.create', { tripId: 1, input: { name: 'ok.pdf', content_base64: Buffer.from('x').toString('base64') } }, 5)
expect(ok.ok).toBe(true)
} finally {
if (prev === undefined) delete process.env.DEMO_MODE; else process.env.DEMO_MODE = prev
}
})
it('files link/update/softDelete verify the file is on the trip + same-trip targets', async () => {
const h = host('db:write:files')
expect((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 7 } })).ok).toBe(true)
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 404, opts: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect((await call(h, 'files.update', { tripId: 1, fileId: 130, input: { description: 'new' } })).ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:updated')).toBe(true)
expect((await call(h, 'files.softDelete', { tripId: 1, fileId: 130 })).ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:deleted')).toBe(true)
expect(((await call(h, 'files.softDelete', { tripId: 1, fileId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('collab writes delegate + broadcast; service errors map to BAD_PARAMS; addon gated', async () => {
const h = host('db:write:collab')
expect((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'Ideas' } })).ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'collab:note:created')).toBe(true)
expect((await call(h, 'collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A', 'B'] } })).ok).toBe(true)
expect((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 0 })).ok).toBe(true)
expect(((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 9 })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect((await call(h, 'collab.createMessage', { tripId: 1, text: 'hi' })).ok).toBe(true)
expect(((await call(h, 'collab.createMessage', { tripId: 1, text: 'toolong' })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
isAddonEnabled.mockReturnValue(false)
expect(((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('trips.addMember verifies the target user exists and reports owner-add as joined:false', async () => {
const h = host('db:write:members')
const r = await call(h, 'trips.addMember', { tripId: 1, userId: 6 })
expect(r.ok).toBe(true)
expect(r.result).toMatchObject({ joined: true })
const ownerAdd = await call(h, 'trips.addMember', { tripId: 1, userId: 5 }) // owner -> no-op
expect(ownerAdd.result).toMatchObject({ joined: false })
expect(((await call(h, 'trips.addMember', { tripId: 1, userId: 12345 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
})
describe('create-rpc-host — Wave 4 wiring (notify / ai)', () => {
const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
beforeEach(() => { notifySend.mockClear(); llmExtract.mockClear() })
afterAll(() => closePluginDataDb('w4'))
it('notify.send delegates to notificationService with the plugin_notification event + forced target', async () => {
const h = host('notify:send')
const r = await call(h, 'notify.send', { input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } }, 5)
expect(r.ok).toBe(true)
expect(notifySend).toHaveBeenCalledWith(expect.objectContaining({
event: 'plugin_notification', actorId: null, scope: 'user', targetId: 5,
params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }),
}))
// trip scope for a trip the acting user (owner 5) can access
expect((await call(h, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok).toBe(true)
})
it('ai.complete/extract run under the resolved provider; unconfigured user → BAD_PARAMS', async () => {
const h = host('ai:invoke')
const c = await call(h, 'ai.complete', { prompt: 'Summarize' }, 5)
expect(c.ok).toBe(true)
expect(c.result).toMatchObject({ text: expect.stringContaining('answer:') })
expect(llmExtract).toHaveBeenCalled()
const e = await call(h, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5)
expect(e.ok).toBe(true)
expect(Array.isArray(e.result.results)).toBe(true)
// user 7 has no provider → the router's aiConfigured() check trips first
expect(((await call(h, 'ai.complete', { prompt: 'hi' }, 7)) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
})
it('scheduler.set upserts by name with caps; cancel removes; recurring floor enforced', async () => {
const h = createRealRpcHost('wsched', new Set(['jobs:run']), { callPlugin: async () => undefined, emitPluginEvent: () => {} } as never);
const c = async (method: string, params: Record<string, unknown>): Promise<{ ok: boolean; result?: unknown; error?: { code: string; message: string } }> =>
h.dispatch({ k: 'req', id: 'x', method, params }, undefined) as never;
const due = Date.now() + 120_000;
expect((await c('scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } })).ok).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dbAny = mockDb as any;
expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1);
// re-set same name = upsert (still 1 row, new due)
expect((await c('scheduler.set', { name: 'poll', dueAt: due + 1000 })).ok).toBe(true);
expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1);
// recurring below the 60s floor is refused
expect((await c('scheduler.set', { name: 'fast', dueAt: due, everyMs: 5000 })).error?.code).toBe('BAD_PARAMS');
// cancel removes it
expect((await c('scheduler.cancel', { name: 'poll' })).result).toMatchObject({ cancelled: true });
expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(0);
closePluginDataDb('wsched');
})
it('enforces the daily notify + AI budgets, seeded from today\'s audit rows', async () => {
// Seed the audit with a plugin that has already spent its whole day (default caps
// are 100 notify / 200 ai) so the budget is exhausted on first use.
const today = new Date().toISOString().slice(0, 10) + 'T08:00:00.000Z';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dbAny = mockDb as any;
const ins = dbAny.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)");
for (let i = 0; i < 100; i++) ins.run('wbudget', 'notify.send', today);
for (let i = 0; i < 200; i++) ins.run('wbudget', 'ai.complete', today);
const h = createRealRpcHost('wbudget', new Set(['notify:send', 'ai:invoke']), { callPlugin: async () => undefined, emitPluginEvent: () => {} } as never);
const c = async (method: string, params: Record<string, unknown>): Promise<{ ok: boolean; error?: { code: string; message: string } }> =>
h.dispatch({ k: 'req', id: 'x', method, params }, 5) as never;
const n = await c('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } });
expect(n.ok).toBe(false);
expect(n.error?.code).toBe('BAD_PARAMS');
expect(n.error?.message).toMatch(/budget/i);
const a = await c('ai.complete', { prompt: 'hi' });
expect(a.ok).toBe(false);
expect(a.error?.message).toMatch(/budget/i);
closePluginDataDb('wbudget');
})
})
describe('create-rpc-host — Wave 8 wiring (settings.get / oauth.getToken)', () => {
const host = (...perms: string[]) => createRealRpcHost('w8', new Set(perms))
// uid is explicit here (no default) — an undefined must stay undefined, not fall back.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
afterAll(() => closePluginDataDb('w8'))
it('settings.get returns the acting user\'s decrypted value; userless → undefined', async () => {
const h = host() // no permission needed — the plugin's own settings
expect((await call(h, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' })
expect((await call(h, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined })
})
it('oauth.getToken returns only the acting user\'s access token, gated by oauth:client', async () => {
const h = host('oauth:client')
expect((await call(h, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' })
expect((await call(h, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }) // not connected
expect((await call(h, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null }) // userless → null (SDK contract)
expect((await call(host(), 'oauth.getToken', {}, 5)).ok).toBe(false) // no grant
})
})
@@ -0,0 +1,102 @@
/**
* DayScheduleController (#plugins): bounded time contributions plugins attach to
* the day plan via the dayScheduleProvider hook. Mirrors the map-markers
* hardening — gate + fail-safe + server-side normalization — plus the schedule
* specifics: dayIds are validated against the trip's own days and minutes are
* clamped to a day, because this output feeds displayed timing totals.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { canAccessTrip, pluginsEnabled, tripDays } = vi.hoisted(() => ({
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
pluginsEnabled: vi.fn(() => true),
tripDays: { value: [{ id: 10 }, { id: 11 }] as Array<{ id: number }> },
}));
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ all: () => tripDays.value }) },
canAccessTrip,
}));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.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),
daySchedule: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayScheduleController(runtime, new DatabaseService(dbConn)), runtime };
}
const item = (over: Record<string, unknown> = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over });
describe('DayScheduleController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockReturnValue({ id: 1 } as never); });
it('gates: disabled / no user / non-member all return [] (no plugin calls on the first)', async () => {
pluginsEnabled.mockReturnValue(false);
const x = controller(() => [item()]);
expect(await x.c.get('1', req(5))).toEqual({ items: [] });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
expect((await controller(() => [item()]).c.get('1', req(undefined))).items).toEqual([]);
canAccessTrip.mockReturnValue(undefined as never);
expect((await controller(() => [item()]).c.get('1', req(5))).items).toEqual([]);
});
it('keeps a valid item, stamps pluginId, rounds + clamps minutes, defaults the tone', async () => {
const { c } = controller(() => [
item({ assignmentId: 42, minutes: 35.6, tone: 'success' }),
item({ id: 's2', minutes: 999999, position: 'start' }),
]);
const out = (await c.get('1', req(5))).items;
expect(out[0]).toMatchObject({ pluginId: 'p1', dayId: 10, assignmentId: 42, minutes: 36, tone: 'success' });
expect(out[1]).toMatchObject({ minutes: 1440, position: 'start', tone: 'default' });
});
it("drops items anchored to another trip's day, without id/label, or non-objects", async () => {
const { c } = controller(() => [
item({ dayId: 999 }), // not a day of this trip
item({ id: '' }), // no id
item({ label: '' }), // no label
item({ dayId: 'x' }), // non-numeric day
null, // non-object
item({ id: 'good', dayId: 11 }),
]);
const out = (await c.get('1', req(5))).items;
expect(out.map(i => i.id)).toEqual(['good']);
});
it('ignores bogus anchors and positions instead of failing the item', async () => {
const { c } = controller(() => [item({ assignmentId: 'x', reservationId: 1.5, position: 'middle', minutes: -10 })]);
const out = (await c.get('1', req(5))).items;
expect(out[0].assignmentId).toBeUndefined();
expect(out[0].reservationId).toBeUndefined();
expect(out[0].position).toBeUndefined();
expect(out[0].minutes).toBeUndefined();
});
it('caps items at 60 per provider and skips a failing provider', async () => {
const many = Array.from({ length: 80 }, (_, i) => item({ id: `s${i}` }));
const { c } = controller((id) => (id === 'bad' ? (() => { throw new Error('boom'); })() : many), ['good', 'bad']);
const out = (await c.get('1', req(5))).items;
expect(out).toHaveLength(60);
});
it('bounds work on an all-invalid oversized payload', async () => {
const huge = Array.from({ length: 100_000 }, () => ({ id: 's', dayId: 999, label: 'x' })); // foreign day -> all dropped
const { c } = controller(() => huge);
expect((await c.get('1', req(5))).items).toEqual([]);
});
it('skips the day lookup entirely when no provider is active', async () => {
const { c, runtime } = controller(() => [item()], []);
expect((await c.get('1', req(5))).items).toEqual([]);
expect(runtime.daySchedule).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,227 @@
/**
* DayTintsController (#plugins): the per-day colour plugins put behind the day cards
* via the dayTintProvider hook. Same gate + fail-safe + server-side normalization as
* the day-schedule hook, plus the rule this hook exists to make deterministic: a day
* takes exactly one tint, so a contested day must resolve the same way every request
* or the card flickers between two plugins' colours.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { canAccessTrip, pluginsEnabled, tripDays } = vi.hoisted(() => ({
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
pluginsEnabled: vi.fn(() => true),
tripDays: { value: [{ id: 10 }, { id: 11 }] as Array<{ id: number }> },
}));
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ all: () => tripDays.value }) },
canAccessTrip,
}));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.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),
dayTints: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayTintsController(runtime, new DatabaseService(dbConn)), runtime };
}
const tint = (over: Record<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
describe('DayTintsController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockReturnValue({ id: 1 } as never); });
it('gates: disabled / no user / non-member all return [] (no plugin calls on the first)', async () => {
pluginsEnabled.mockReturnValue(false);
const x = controller(() => [tint()]);
expect(await x.c.get('1', req(5))).toEqual({ tints: [] });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
expect((await controller(() => [tint()]).c.get('1', req(undefined))).tints).toEqual([]);
canAccessTrip.mockReturnValue(undefined as never);
expect((await controller(() => [tint()]).c.get('1', req(5))).tints).toEqual([]);
});
it('keeps a valid tint, stamps pluginId, and caps the label', async () => {
const { c } = controller(() => [tint({ label: 'x'.repeat(200) })]);
const out = (await c.get('1', req(5))).tints;
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ pluginId: 'p1', dayId: 10 });
expect(out[0].label).toHaveLength(60);
});
it('spreads the `tone` shorthand across all three regions', async () => {
const { c } = controller(() => [tint({ tone: 'warn' })]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' });
});
it('lets a named region override the shorthand', async () => {
const { c } = controller(() => [tint({ tone: 'warn', badgeTone: 'danger' })]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeTone: 'danger', headerTone: 'warn', activityTone: 'warn' });
});
it('tints ONLY the named regions when there is no shorthand', async () => {
// The point of the split: mark the leg on the badge, leave the dense activity
// list untouched. An absent region must stay absent, not fall back to a colour.
const { c } = controller(() => [{ dayId: 10, badgeTone: 'success' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeTone: 'success' });
expect(out[0].headerTone).toBeUndefined();
expect(out[0].activityTone).toBeUndefined();
});
it('degrades a bogus tone to default rather than dropping the region', async () => {
const { c } = controller(() => [
tint({ tone: 'chartreuse' }),
{ dayId: 11, headerTone: 42 },
]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
// A named-but-bogus region is still a request to tint it; unnamed ones stay off.
expect(out[1]).toEqual({ pluginId: 'p1', dayId: 11, headerTone: 'default' });
});
it('spreads a `color` shorthand across all three regions, lower-cased', async () => {
const { c } = controller(() => [{ dayId: 10, color: '#A1B2C3' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeColor: '#a1b2c3', headerColor: '#a1b2c3', activityColor: '#a1b2c3' });
});
it('gives a region exactly one paint — a colour beats a tone at the same level', async () => {
// Both set on the badge: the colour is the more specific request, and the tone
// must not ride along, or the client has a tie to break.
const { c } = controller(() => [{ dayId: 10, badgeTone: 'danger', badgeColor: '#00ff00' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeColor: '#00ff00' });
expect(out[0].badgeTone).toBeUndefined();
});
it('lets a named region override the shorthand across channels, both ways', async () => {
const { c } = controller(() => [
{ dayId: 10, color: '#112233', badgeTone: 'danger' }, // tone overrides a colour shorthand
{ dayId: 11, tone: 'warn', badgeColor: '#112233' }, // colour overrides a tone shorthand
]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeTone: 'danger', headerColor: '#112233', activityColor: '#112233' });
expect(out[0].badgeColor).toBeUndefined();
expect(out[1]).toMatchObject({ badgeColor: '#112233', headerTone: 'warn', activityTone: 'warn' });
});
it('accepts ONLY #rrggbb — a CSS colour cannot be smuggled in as a string', async () => {
// The value ends up inside a `color-mix()` in an inline `background`, which takes a
// layer list: a string that closes the paren early could append a url() layer and
// beacon every viewer of the trip. None of these may reach the client as a colour.
const evil = 'red 50%, transparent), url(https://evil.example/x.png';
const { c } = controller(() => [
{ dayId: 10, badgeColor: evil, headerColor: 'rebeccapurple', activityColor: '#abc' },
{ dayId: 11, color: 'rgb(1,2,3)', headerColor: '#12345', activityColor: '#1234567' },
]);
const out = (await c.get('1', req(5))).tints;
expect(JSON.stringify(out)).not.toContain('evil.example');
for (const t of out) {
expect(t.badgeColor).toBeUndefined();
expect(t.headerColor).toBeUndefined();
expect(t.activityColor).toBeUndefined();
}
// Rejected, but the regions still asked to be tinted — they degrade like a bogus
// tone does rather than silently rendering plain.
expect(out[0]).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
// A junk shorthand paints nothing, so the badge — which named neither channel —
// stays untinted, while the two regions that did name one degrade to `default`.
expect(out[1]).toMatchObject({ headerTone: 'default', activityTone: 'default' });
expect(out[1].badgeTone).toBeUndefined();
});
it('keeps a valid sibling when only one channel of a region is junk', async () => {
const { c } = controller(() => [{ dayId: 10, badgeColor: 'chartreuse', badgeTone: 'success' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeTone: 'success' });
});
it('treats an entry with no tone at all as "tint this day", default everywhere', async () => {
const { c } = controller(() => [{ dayId: 10 }, { dayId: 11, tone: undefined }]);
const out = (await c.get('1', req(5))).tints;
for (const t of out) expect(t).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
expect(out[0].label).toBeUndefined();
});
it("drops tints on another trip's day, on a non-numeric day, and non-objects", async () => {
const { c } = controller(() => [
tint({ dayId: 999 }), // not a day of this trip
tint({ dayId: 'x' }), // non-numeric day
tint({ dayId: null }),
null, // non-object
tint({ dayId: 11 }),
]);
const out = (await c.get('1', req(5))).tints;
expect(out.map(t => t.dayId)).toEqual([11]);
});
it('takes a provider\'s FIRST answer for a day it tints twice', async () => {
const { c } = controller(() => [
tint({ dayId: 10, tone: 'success', label: 'first' }),
tint({ dayId: 10, tone: 'danger', label: 'second' }),
]);
const out = (await c.get('1', req(5))).tints;
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ badgeTone: 'success', label: 'first' });
});
it('resolves a contested day WHOLE — the loser cannot fill in the winner\'s empty regions', async () => {
const { c } = controller(
(id) => (id === 'first'
? [{ dayId: 10, badgeTone: 'success' }] // badge only
: [{ dayId: 10, tone: 'danger' }, { dayId: 11, tone: 'warn' }]), // would fill all three
['first', 'second'],
);
const out = (await c.get('1', req(5))).tints;
// Day 10 is `first`'s badge-only contribution — `second` contributes nothing to
// it, so two plugins can never each own part of one card. Day 11 is uncontested.
expect(out).toEqual([
{ pluginId: 'first', dayId: 10, badgeTone: 'success' },
{ pluginId: 'second', dayId: 11, badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' },
]);
});
it('skips a failing provider without losing the healthy one', async () => {
const { c } = controller(
(id) => (id === 'bad' ? (() => { throw new Error('boom'); })() : [tint()]),
['bad', 'good'],
);
const out = (await c.get('1', req(5))).tints;
expect(out.map(t => t.pluginId)).toEqual(['good']);
});
it('bounds work on an all-invalid oversized payload', async () => {
const huge = Array.from({ length: 100_000 }, () => ({ dayId: 999, tone: 'success' })); // foreign day -> all dropped
const { c } = controller(() => huge);
expect((await c.get('1', req(5))).tints).toEqual([]);
});
it('slices the raw array at 2000 before validating it', async () => {
// 2000 junk entries, then a legitimate one — past the slice, so it never lands.
const raw = [...Array.from({ length: 2000 }, () => ({ dayId: 999 })), tint({ dayId: 11 })];
const { c } = controller(() => raw);
expect((await c.get('1', req(5))).tints).toEqual([]);
});
it('ignores a non-array payload', async () => {
const { c } = controller(() => ({ tints: [tint()] }));
expect((await c.get('1', req(5))).tints).toEqual([]);
});
it('skips the day lookup entirely when no provider is active', async () => {
const { c, runtime } = controller(() => [tint()], []);
expect((await c.get('1', req(5))).tints).toEqual([]);
expect(runtime.dayTints).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { it, expect } from 'vitest';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../../../src/plugin-event-sink';
import { KNOWN_PERMISSIONS } from '../../../src/nest/plugins/protocol/envelope';
it('every snapshot grant is a known db:read permission', () => {
for (const p of Object.values(SNAPSHOT_GRANT)) {
expect(KNOWN_PERMISSIONS).toContain(p);
expect(p).toMatch(/^db:read:/);
}
});
it('every snapshot family has an id-key path', () => {
for (const family of Object.keys(SNAPSHOT_GRANT)) {
expect(Object.keys(ENTITY_ID_KEYS)).toContain(family);
}
});
+566
View File
@@ -0,0 +1,566 @@
/**
* The plugin surface that belongs to no domain: the plugin's own sqlite (DbRpc), its
* namespaced entity metadata (MetaRpc) and the host-mediated calls (HostSurfaceRpc —
* user lookup, broadcasts, notifications, LLM, OAuth, scheduler).
*
* These cases used to live in plugin-host-deps.factory.test.ts, which tested the same
* behaviour through a 26-argument wiring sheet. Same requests, same expected codes;
* what changed is that they now run against three decorated classes and a real
* in-memory core DB, and that the host is built by the production factory.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// A real in-memory core db, so the metadata SQL and the entity->trip resolution run
// for real. Trip 1 is owned by user 5; user 6 is a member; user 9 shares nothing.
vi.mock('../../../src/db/database', () => {
const Database = require('better-sqlite3');
const d = new Database(':memory:');
d.exec(`
CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER);
CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE reservations (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT);
CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key));
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
`);
d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run();
d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run();
d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run();
d.prepare('INSERT INTO reservations (id, trip_id) VALUES (40, 1)').run();
d.prepare('INSERT INTO day_accommodations (id, trip_id) VALUES (11, 1)').run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (5, 'trip_owner', 'owner', 'Owner')").run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (6, 'user', 'ada', 'Ada')").run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (9, 'user', 'eve', 'Eve')").run();
d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run();
return {
db: d,
canAccessTrip: (tripId: number, userId: number) =>
tripId === 1 && (userId === 5 || userId === 6) ? { id: 1, user_id: 5 } : undefined,
};
});
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
const notifySend = vi.fn(async () => undefined);
const { llmExtract } = vi.hoisted(() => ({
llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]),
}));
vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) }));
import { PluginRpcHostFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { MetaRpc } from '../../../src/nest/plugins/host/rpc/meta.rpc';
import { HostSurfaceRpc } from '../../../src/nest/plugins/host/rpc/host-surface.rpc';
import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state';
import { db as mockDb } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
// Typed from the real method rather than from the always-true body below, so a case that
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
const checkPermission = vi.fn<PermissionsService['checkPermission']>(() => true);
const permissions = { checkPermission } as unknown as PermissionsService;
const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService;
const notifications = { send: notifySend } as unknown as NotificationsService;
// user 7 has no provider configured; everyone else resolves to a stub config.
const llmConfig = {
resolve: vi.fn((uid: number) => (uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' })),
} as unknown as LlmConfigResolver;
const oauth = {
async getAccessToken(_pid: string, uid: number) {
return uid === 5 ? 'tok-5' : null;
},
} as unknown as PluginOAuthService;
// The per-user settings reads are stubbed rather than seeded: this suite is about the
// RPC surface, and plugin-user-settings.test.ts already covers the encryption round-trip.
const userSettings = {
readOne: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)),
} as unknown as PluginUserSettingsService;
const dbs = new DatabaseService(mockDb);
const guards = new PluginGuards(dbs, permissions, addons);
const registry = createTestPluginRegistry([
new DbRpc(userSettings),
new MetaRpc(dbs, guards),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards),
]);
const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
/**
* A response read loosely, on purpose.
*
* `RpcResponse & Partial<RpcError>` collapsed to `never`: both carry `ok`, one
* as the literal `true` and the other as `false`, so the intersection has no
* inhabitant and every `.ok` / `.result` / `.error` below was an error the
* build never saw, because tests/ sits outside tsconfig's include. The union
* would be the honest runtime type, but `dispatch` here is asserted on from
* both sides in the same expression, so this widens instead of narrowing.
*/
type Res = Pick<RpcResponse, 'k' | 'id'> & {
ok: boolean;
result?: unknown;
error?: RpcError['error'];
};
// The acting user is a REST arg, not a defaulted one: an explicit `undefined` has to
// stay undefined (that is the userless-context case), and a default parameter would
// quietly turn it back into user 5.
const call = (
host: ReturnType<typeof makeHost>,
method: string,
params: Record<string, unknown> = {},
...uid: (number | undefined)[]
): Promise<Res> => host.dispatch({ k: 'req', id: 'x', method, params }, uid.length ? uid[0] : 5) as Promise<Res>;
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-hostrpc-'));
process.env.TREK_PLUGINS_DATA_DIR = tmp;
});
afterAll(() => {
closePluginDataDb('wired');
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('DbRpc — the plugin\'s own sqlite', () => {
it('HOSTRPC-001 caches one data db per plugin id', () => {
expect(getPluginDataDb('wired')).toBe(getPluginDataDb('wired'));
});
it('HOSTRPC-002 a granted db:own call runs against the plugin db', async () => {
const host = makeHost('wired', 'db:own');
expect((await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE t (v TEXT)' })).ok).toBe(true);
expect((await call(host, 'db.exec', { sql: "INSERT INTO t (v) VALUES ('a')" })).ok).toBe(true);
const rows = await call(host, 'db.query', { sql: 'SELECT v FROM t' });
expect(rows.result).toEqual([{ v: 'a' }]);
});
it('HOSTRPC-003 db.tx dispatches an atomic batch; malformed ops are BAD_PARAMS', async () => {
const host = makeHost('wtx', 'db:own');
await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' });
const res = await call(host, 'db.tx', {
ops: [
{ sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] },
{ sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] },
],
});
expect((res.result as { results: unknown[] }).results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]);
expect((await call(host, 'db.tx', { ops: [{ sql: 42 }] })).error?.code).toBe('BAD_PARAMS');
closePluginDataDb('wtx');
});
it('HOSTRPC-004 without db:own not one of the four is reachable', async () => {
const host = makeHost('wnogrant');
for (const method of ['db.query', 'db.exec', 'db.migrate', 'db.tx']) {
expect((await call(host, method, { sql: 'SELECT 1', id: '1', ops: [] })).error?.code).toBe('PERMISSION_DENIED');
}
});
it('HOSTRPC-005 two plugins never see each other\'s tables', async () => {
// The regression this guards: DbRpc is a SINGLETON that serves every plugin, so a
// handle captured in a field or a closure would hand plugin B the rows of whichever
// plugin happened to construct it first.
const a = makeHost('tenant-a', 'db:own');
const b = makeHost('tenant-b', 'db:own');
await call(a, 'db.migrate', { id: '001', sql: 'CREATE TABLE secrets (v TEXT)' });
await call(a, 'db.exec', { sql: "INSERT INTO secrets (v) VALUES ('a-only')" });
const leaked = await call(b, 'db.query', { sql: 'SELECT v FROM secrets' });
expect(leaked.ok).toBe(false);
expect(leaked.error?.code).toBe('HOST_ERROR');
expect(leaked.error?.message).toMatch(/no such table/i);
// …and plugin A still reads its own row afterwards.
expect((await call(a, 'db.query', { sql: 'SELECT v FROM secrets' })).result).toEqual([{ v: 'a-only' }]);
closePluginDataDb('tenant-a');
closePluginDataDb('tenant-b');
});
it('HOSTRPC-006 a handle closed WITHOUT eviction is recreated, not reused dead', () => {
const a = getPluginDataDb('closedcache');
expect(a.isOpen()).toBe(true);
// The supervisor's dispose() path: close the handle but leave it cached.
a.close();
const b = getPluginDataDb('closedcache');
expect(b).not.toBe(a);
expect(b.isOpen()).toBe(true);
closePluginDataDb('closedcache');
});
it('HOSTRPC-007 closePluginDataDb drops the cached handle', () => {
const a = getPluginDataDb('transient');
closePluginDataDb('transient');
const b = getPluginDataDb('transient');
expect(a).not.toBe(b);
closePluginDataDb('transient');
});
});
describe('DbRpc — the unconditional three', () => {
afterAll(() => closePluginDataDb('open3'));
it('HOSTRPC-008 plugins.call and events.emit route through the host\'s own router', async () => {
const calls: unknown[] = [];
const emits: unknown[] = [];
const router: PluginCallRouter = {
callPlugin: async (callerId, targetId, fn, args, uid) => {
calls.push({ callerId, targetId, fn, args, uid });
return { echoed: true };
},
emitPluginEvent: (sourceId, event, payload) => {
emits.push({ sourceId, event, payload });
},
};
// No permission granted at all — these two are registered unconditionally.
const host = factory.create('open3', new Set(), router);
const res = await call(host, 'plugins.call', { targetId: 'other', fn: 'sum', args: [1, 2] }, 5);
expect(res.result).toEqual({ echoed: true });
// The caller id comes from the HOST, never from params, which is what lets the
// router authorise the dependency edge.
expect(calls).toEqual([{ callerId: 'open3', targetId: 'other', fn: 'sum', args: [1, 2], uid: 5 }]);
expect((await call(host, 'events.emit', { event: 'ping', payload: { a: 1 } }, 5)).result).toEqual({ ok: true });
expect(emits).toEqual([{ sourceId: 'open3', event: 'ping', payload: { a: 1 } }]);
});
it('HOSTRPC-009 settings.get returns the acting user\'s decrypted value; userless yields undefined', async () => {
const host = makeHost('open3');
expect((await call(host, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' });
expect((await call(host, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined });
// Another user's value is simply not there — the read is per acting user.
expect((await call(host, 'settings.get', { key: 'apiKey' }, 6)).result).toEqual({ value: undefined });
});
it('HOSTRPC-010 a missing key is BAD_PARAMS, not a silent undefined', async () => {
expect((await call(makeHost('open3'), 'settings.get', {}, 5)).error?.code).toBe('BAD_PARAMS');
});
});
describe('MetaRpc — namespaced entity metadata', () => {
beforeEach(() => {
checkPermission.mockReset();
checkPermission.mockReturnValue(true);
});
afterAll(() => closePluginDataDb('meta'));
it('HOSTRPC-011 round-trips and enforces the key/value/access limits', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true);
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 });
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert
expect((await call(host, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place -> trip 1
expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true });
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-012 an unreadable value comes back as null rather than throwing', async () => {
const host = makeHost('meta', 'db:meta');
await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'corrupt', value: 1 });
(mockDb as unknown as { prepare(sql: string): { run(...a: unknown[]): unknown } })
.prepare("UPDATE plugin_entity_metadata SET value='{not json' WHERE plugin_id='meta' AND key='corrupt'")
.run();
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'corrupt' })).result).toBeNull();
const listed = (await call(host, 'meta.list', { entityType: 'trip', entityId: 1 })).result as Record<string, unknown>;
expect(listed.corrupt).toBeNull();
await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'corrupt' });
});
it('HOSTRPC-013a an omitted value is stored as null, not as the string "undefined"', async () => {
const host = makeHost('meta', 'db:meta');
const set = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'blank' });
expect(set.result).toEqual({ key: 'blank', value: null });
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'blank' })).result).toBeNull();
await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'blank' });
});
it('HOSTRPC-013 a key that was never set reads as null, and deleting it reports false', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toBeNull();
expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toEqual({ deleted: false });
});
it('HOSTRPC-014 the per-entity key quota is enforced on new keys only', async () => {
const host = makeHost('metaquota', 'db:meta');
for (let i = 0; i < 100; i++) {
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: `k${i}`, value: i })).ok).toBe(true);
}
const overflow = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k100', value: 1 });
expect(overflow.error?.code).toBe('BAD_PARAMS');
expect(overflow.error?.message).toMatch(/too many metadata keys/);
// An existing key still updates — the quota counts keys, not writes.
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k0', value: 'again' })).ok).toBe(true);
closePluginDataDb('metaquota');
});
it('HOSTRPC-015 every entity type resolves its own edit action, and a read-only member is refused a write', async () => {
const host = makeHost('meta', 'db:meta');
const seen: string[] = [];
checkPermission.mockImplementation((action) => {
seen.push(action);
return true;
});
for (const [entityType, entityId] of [['trip', 1], ['place', 7], ['day', 3], ['reservation', 40], ['accommodation', 11]] as const) {
expect((await call(host, 'meta.set', { entityType, entityId, key: 'k', value: 1 })).ok).toBe(true);
}
// Accommodations deliberately ride on day_edit, like the accommodation write path.
expect(seen).toEqual(['trip_edit', 'place_edit', 'day_edit', 'reservation_edit', 'day_edit']);
checkPermission.mockReturnValue(false);
const write = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 });
expect(write.error?.code).toBe('RESOURCE_FORBIDDEN');
// …but a READ is only access-gated, so it still works for the same user.
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toBe(1);
});
it('HOSTRPC-016 an unknown entityType is BAD_PARAMS and a userless context is refused', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.set', { entityType: 'user', entityId: 1, key: 'k', value: 1 })).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
// An entity id that resolves to nothing is refused rather than reported as empty.
expect((await call(host, 'meta.get', { entityType: 'reservation', entityId: 999, key: 'k' })).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-017 one plugin never reads another\'s keys on the same entity', async () => {
const a = makeHost('meta-a', 'db:meta');
const b = makeHost('meta-b', 'db:meta');
await call(a, 'meta.set', { entityType: 'trip', entityId: 1, key: 'shared', value: 'a-value' });
expect((await call(b, 'meta.get', { entityType: 'trip', entityId: 1, key: 'shared' })).result).toBeNull();
expect((await call(b, 'meta.list', { entityType: 'trip', entityId: 1 })).result).toEqual({});
closePluginDataDb('meta-a');
closePluginDataDb('meta-b');
});
it('HOSTRPC-018 without db:meta not one of the four is reachable', async () => {
const host = makeHost('meta', 'db:read:trips');
for (const method of ['meta.get', 'meta.set', 'meta.list', 'meta.delete']) {
expect((await call(host, method, { entityType: 'trip', entityId: 1, key: 'k' })).error?.code).toBe('PERMISSION_DENIED');
}
});
});
describe('HostSurfaceRpc — users, broadcasts, notify, ai, oauth, scheduler', () => {
beforeEach(() => {
checkPermission.mockReset();
checkPermission.mockReturnValue(true);
notifySend.mockClear();
llmExtract.mockClear();
broadcast.mockClear();
broadcastToUser.mockClear();
});
afterAll(() => closePluginDataDb('surface'));
it('HOSTRPC-019 users.getById is scoped to people the acting user shares a trip with', async () => {
const host = makeHost('surface', 'db:read:users');
const shared = await call(host, 'users.getById', { id: 6 }, 5);
expect(shared.result).toEqual({ id: 6, username: 'ada', display_name: 'Ada', avatar: null });
// Reading yourself needs no shared trip.
expect((await call(host, 'users.getById', { id: 5 }, 5)).ok).toBe(true);
// A stranger is refused, which is what stops id enumeration.
expect((await call(host, 'users.getById', { id: 9 }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'users.getById', { id: 6 }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-020 trip broadcasts are force-namespaced and membership-gated', async () => {
const host = makeHost('surface', 'ws:broadcast:trip');
expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }, 5)).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'plugin:surface:ping', { a: 1 });
// A primitive payload is wrapped rather than dropped.
await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }, 5);
expect(broadcast).toHaveBeenLastCalledWith(1, 'plugin:surface:ping', { value: 'primitive' });
broadcast.mockClear();
expect((await call(host, 'ws.broadcastToTrip', { tripId: 2, event: 'x', data: {} }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect(broadcast).not.toHaveBeenCalled();
});
it('HOSTRPC-021 a per-user broadcast may only target the acting user', async () => {
const host = makeHost('surface', 'ws:broadcast:user');
expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: { x: 2 } }, 5)).ok).toBe(true);
expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:surface', event: 'hi', x: 2 });
expect((await call(host, 'ws.broadcastToUser', { userId: 6, event: 'hi', data: {} }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: {} }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-022 notify.send forces the recipient and carries the plugin_notification event', async () => {
const host = makeHost('surface', 'notify:send');
expect((await call(host, 'notify.send', { input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } }, 5)).ok).toBe(true);
expect(notifySend).toHaveBeenCalledWith(expect.objectContaining({
event: 'plugin_notification',
actorId: null,
scope: 'user',
targetId: 5,
params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }),
}));
expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok).toBe(true);
// Another user, a trip the acting user isn't in, and the admin scope are all refused.
expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 9 } }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 2 } }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'admin', targetId: 0 } }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-023 notify.send rejects an off-site link and an empty or emoji-only title', async () => {
const host = makeHost('surface', 'notify:send');
const base = { body: 'b', scope: 'user', targetId: 5 };
for (const link of ['//evil.com', 'https://evil.com/x', '/\\evil.com', 'javascript:alert(1)']) {
expect((await call(host, 'notify.send', { input: { ...base, title: 't', link } }, 5)).error?.code).toBe('BAD_PARAMS');
}
expect((await call(host, 'notify.send', { input: { ...base, title: '' } }, 5)).error?.code).toBe('BAD_PARAMS');
// stripEmoji collapses an all-emoji title to '', so it lands on the same refusal.
expect((await call(host, 'notify.send', { input: { ...base, title: '🎉🎉' } }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'notify.send', { input: { ...base, title: 'x'.repeat(201) } }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'notify.send', { input: { title: 't', body: 'y'.repeat(1001), scope: 'user', targetId: 5 } }, 5)).error?.code).toBe('BAD_PARAMS');
// A non-string title or body lands on the same refusal as an empty one, rather
// than reaching stripEmoji and throwing there.
expect((await call(host, 'notify.send', { input: { ...base, title: 42 } }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'notify.send', { input: { title: 't', body: 42, scope: 'user', targetId: 5 } }, 5)).error?.code).toBe('BAD_PARAMS');
expect(notifySend).not.toHaveBeenCalled();
});
it('HOSTRPC-024 ai.complete/extract run under the resolved provider, with caps', async () => {
const host = makeHost('surface', 'ai:invoke');
const completed = await call(host, 'ai.complete', { prompt: 'Summarize' }, 5);
expect(completed.result).toMatchObject({ text: expect.stringContaining('answer:') });
const extracted = await call(host, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5);
expect(Array.isArray((extracted.result as { results: unknown[] }).results)).toBe(true);
// user 7 has no provider configured
expect((await call(host, 'ai.complete', { prompt: 'hi' }, 7)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.complete', { prompt: '' }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.complete', { prompt: 'x'.repeat(20001) }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: 'not-an-object' }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: '', jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 'x'.repeat(20001), jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.complete', { prompt: 'hi' }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
// A non-string prompt/text is the same refusal as an empty one, not a crash.
expect((await call(host, 'ai.complete', { prompt: 42 }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 42, jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS');
// …and a null jsonSchema is refused despite typeof null === 'object'.
expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: null }, 5)).error?.code).toBe('BAD_PARAMS');
});
it('HOSTRPC-025 the plugin\'s system prompt and extraction hint are passed through, with a default', async () => {
const host = makeHost('surface', 'ai:invoke');
await call(host, 'ai.complete', { prompt: 'p', system: 'be terse' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: 'be terse', text: 'p' }));
await call(host, 'ai.complete', { prompt: 'p' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: expect.stringContaining('helpful assistant') }));
await call(host, 'ai.extract', { text: 't', jsonSchema: { type: 'object' }, prompt: '' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: expect.stringContaining('Extract structured data') }));
});
it('HOSTRPC-026 a model that answers with no text yields an empty string, not undefined', async () => {
llmExtract.mockResolvedValueOnce([{ text: 42 } as never]);
expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' });
llmExtract.mockResolvedValueOnce([]);
expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' });
});
it('HOSTRPC-027 oauth.getToken returns only the acting user\'s access token', async () => {
const host = makeHost('surface', 'oauth:client');
expect((await call(host, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' });
expect((await call(host, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }); // not connected
// A userless context gets null, matching the SDK contract, not RESOURCE_FORBIDDEN.
expect((await call(host, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null });
expect((await call(makeHost('surface'), 'oauth.getToken', {}, 5)).error?.code).toBe('PERMISSION_DENIED');
});
it('HOSTRPC-028 scheduler.set upserts by name with caps; cancel removes', async () => {
const host = makeHost('wsched', 'jobs:run');
const count = () =>
(mockDb as unknown as { prepare(s: string): { get(): { n: number } } })
.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'")
.get().n;
const due = Date.now() + 120_000;
// Userless is fine — a scheduled task is like a job.
expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } }, undefined)).ok).toBe(true);
expect(count()).toBe(1);
expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due + 1000 }, undefined)).ok).toBe(true);
expect(count()).toBe(1); // upsert by (plugin, name)
expect((await call(host, 'scheduler.cancel', { name: 'poll' }, undefined)).result).toEqual({ cancelled: true });
expect(count()).toBe(0);
expect((await call(host, 'scheduler.cancel', { name: 'ghost' }, undefined)).result).toEqual({ cancelled: false });
});
it('HOSTRPC-029 the scheduler caps bound name, horizon, interval and payload', async () => {
const host = makeHost('wsched2', 'jobs:run');
const due = Date.now() + 120_000;
const bad = async (params: Record<string, unknown>) => (await call(host, 'scheduler.set', params, undefined)).error?.code;
expect(await bad({ name: '', dueAt: due })).toBe('BAD_PARAMS');
expect(await bad({ name: 'x'.repeat(129), dueAt: due })).toBe('BAD_PARAMS');
expect(await bad({ name: 'far', dueAt: Date.now() + 400 * 24 * 60 * 60 * 1000 })).toBe('BAD_PARAMS');
expect(await bad({ name: 'nan', dueAt: Number.POSITIVE_INFINITY })).toBe('BAD_PARAMS');
expect(await bad({ name: 'fast', dueAt: due, everyMs: 5000 })).toBe('BAD_PARAMS');
expect(await bad({ name: 'fat', dueAt: due, payload: { blob: 'z'.repeat(9000) } })).toBe('BAD_PARAMS');
// A dueAt in the past is clamped to now rather than refused, so a plugin that wakes
// up late still gets its task fired on the next sweep.
expect((await call(host, 'scheduler.set', { name: 'late', dueAt: 1 }, undefined)).ok).toBe(true);
const row = (mockDb as unknown as { prepare(s: string): { get(): { due_at: number } } })
.prepare("SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id='wsched2' AND name='late'")
.get();
expect(row.due_at).toBeGreaterThan(1);
});
it('HOSTRPC-030 a plugin cannot hoard more than 100 scheduled tasks', async () => {
const host = makeHost('wschedmax', 'jobs:run');
const due = Date.now() + 120_000;
for (let i = 0; i < 100; i++) {
expect((await call(host, 'scheduler.set', { name: `t${i}`, dueAt: due }, undefined)).ok).toBe(true);
}
const over = await call(host, 'scheduler.set', { name: 't100', dueAt: due }, undefined);
expect(over.error?.message).toMatch(/too many scheduled tasks/);
// An existing name still upserts — the cap counts entries, not calls.
expect((await call(host, 'scheduler.set', { name: 't0', dueAt: due + 5 }, undefined)).ok).toBe(true);
});
it('HOSTRPC-031 the daily notify + AI budgets are enforced, seeded from today\'s audit rows', async () => {
// Seed the audit with a plugin that has already spent its whole day (default caps
// are 100 notify / 200 ai) so the budget is exhausted on first use.
const today = `${new Date().toISOString().slice(0, 10)}T08:00:00.000Z`;
const insert = (mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } })
.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)");
for (let i = 0; i < 100; i++) insert.run('wbudget', 'notify.send', today);
for (let i = 0; i < 200; i++) insert.run('wbudget', 'ai.complete', today);
const host = makeHost('wbudget', 'notify:send', 'ai:invoke');
const notified = await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, 5);
expect(notified.error?.code).toBe('BAD_PARAMS');
expect(notified.error?.message).toMatch(/budget/i);
expect(notifySend).not.toHaveBeenCalled();
expect((await call(host, 'ai.complete', { prompt: 'hi' }, 5)).error?.message).toMatch(/budget/i);
closePluginDataDb('wbudget');
});
it('HOSTRPC-032 every host-surface method is unreachable without its own grant', async () => {
const host = makeHost('surface');
const denied: Record<string, Record<string, unknown>> = {
'users.getById': { id: 6 },
'ws.broadcastToTrip': { tripId: 1, event: 'x' },
'ws.broadcastToUser': { userId: 5, event: 'x' },
'notify.send': { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } },
'ai.complete': { prompt: 'x' },
'ai.extract': { text: 'x', jsonSchema: {} },
'oauth.getToken': {},
'scheduler.set': { name: 'x', dueAt: Date.now() + 1000 },
'scheduler.cancel': { name: 'x' },
};
for (const [method, params] of Object.entries(denied)) {
expect((await call(host, method, params, 5)).error?.code).toBe('PERMISSION_DENIED');
}
});
});
@@ -17,21 +17,25 @@ vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: (entryId: number) => entryJourney(entryId) }) },
canAccessTrip: vi.fn(),
}));
vi.mock('../../../src/services/journeyService', () => ({ canAccessJourney }));
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
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 { JournalEntryRowsController } from '../../../src/nest/plugins/contributions/journal-entry-rows.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.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), runtime };
journalRows: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
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 });
@@ -73,7 +77,7 @@ describe('JournalEntryRowsController', () => {
{ label: 'Steps', value: '9000', url: 'mailto:x@y.z' },
],
}]);
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'journalEntryProvider', 'getRows', [7], 5, 5000);
expect(runtime.journalRows).toHaveBeenCalledWith('p1', 7, 5);
});
it('drops javascript:/empty/unparseable urls, caps lengths, drops label-less rows and non-objects', async () => {
@@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
const base = { id: 'flight-tracker', name: 'Flight', version: '1.2.0', type: 'widget', apiVersion: 1 };
const withApi = (apiVersion: unknown) => ({ ...base, apiVersion, trek: '>=3.2.0 <4.0.0' });
describe('parseManifest', () => {
it('parses a valid manifest with defaults', () => {
@@ -99,6 +100,23 @@ describe('parseManifest', () => {
});
});
describe('apiVersion', () => {
it.each([[0], [-3], [1.5], ['1']])('rejects non-positive-integer %p', (v) => {
expect(() => parseManifest(withApi(v))).toThrow('apiVersion must be a positive integer');
});
it('defaults to 1 when absent', () => {
const { apiVersion: _omitted, ...noApi } = base;
expect(parseManifest(noApi).apiVersion).toBe(1);
});
it('tolerates a future apiVersion under discovery (no requireTrek)', () => {
expect(parseManifest(withApi(2)).apiVersion).toBe(2);
});
it('refuses a future apiVersion on install paths (requireTrek)', () => {
expect(() => parseManifest(withApi(2), { requireTrek: true }))
.toThrow('plugin requires plugin-API v2; this TREK supports v1');
});
});
describe('parseManifest capabilities', () => {
it('parses a hero widget slot and defaults to sidebar', () => {
const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
@@ -113,6 +131,18 @@ describe('parseManifest capabilities', () => {
expect(pd.capabilities.widget?.slot).toBe('place-detail');
});
it('parses routeProfiles (id shape, label cap, icon trim) and rejects malformed ones', () => {
const m = parseManifest({ ...base, capabilities: { routeProfiles: [{ id: 'ev', label: ' EV ', icon: 'zap' }] } });
expect(m.capabilities.routeProfiles).toEqual([{ id: 'ev', label: 'EV', icon: 'zap' }]);
// id must be lowercase kebab, ≤24 chars; label required ≤40; max 3; no duplicates
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: [{ id: 'EV', label: 'x' }] } })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: [{ id: 'ev' }] } })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: [{ id: 'ev', label: 'L'.repeat(41) }] } })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: [{ id: 'ev', label: 'a' }, { id: 'ev', label: 'b' }] } })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: [1, 2, 3, 4].map(i => ({ id: `p${i}`, label: 'x' })) } })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, capabilities: { routeProfiles: 'ev' } })).toThrow(ManifestError);
});
it('accepts the day-detail widget slot (mounts in the day panel)', () => {
const dd = parseManifest({ ...base, capabilities: { widget: { slot: 'day-detail' } } });
expect(dd.capabilities.widget?.slot).toBe('day-detail');
@@ -0,0 +1,142 @@
/**
* MapLayersController (#plugins): bounded vector overlays plugins draw on the trip
* map via the mapLayerProvider hook. Mirrors the map-markers hardening — gate +
* fail-safe + server-side normalization: coordinate range checks, clamped styling
* numerics, enum whitelists on the raw values, and hard per-provider budgets
* (layers / features / vertices) with drop-not-truncate semantics for geometry.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
canAccessTrip: vi.fn((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 { MapLayersController } from '../../../src/nest/plugins/contributions/map-layers.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),
mapLayers: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new MapLayersController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime };
}
const line = (n = 3, over: Record<string, unknown> = {}) => ({
type: 'polyline',
points: Array.from({ length: n }, (_, i) => [48 + i * 0.01, 2 + i * 0.01]),
...over,
});
const layer = (features: unknown[], over: Record<string, unknown> = {}) => ({ id: 'l1', features, ...over });
describe('MapLayersController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockReturnValue({ id: 1 } as never); });
it('gates: disabled / no user / non-member all return [] (no plugin calls on the first)', async () => {
pluginsEnabled.mockReturnValue(false);
const x = controller(() => [layer([line()])]);
expect(await x.c.get('1', req(5))).toEqual({ layers: [] });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
expect((await controller(() => [layer([line()])]).c.get('1', req(undefined))).layers).toEqual([]);
canAccessTrip.mockReturnValue(undefined as never);
expect((await controller(() => [layer([line()])]).c.get('1', req(5))).layers).toEqual([]);
});
it('keeps a valid layer, stamps pluginId, clamps styling and caps the label', async () => {
const { c } = controller(() => [layer(
[line(3, { tone: 'success', width: 99, opacity: 7, dash: 'dash', label: 'L'.repeat(200) })],
{ name: 'N'.repeat(200) },
)]);
const out = (await c.get('1', req(5))).layers;
expect(out).toHaveLength(1);
expect(out[0].pluginId).toBe('p1');
expect(out[0].name!.length).toBe(60);
const f = out[0].features[0];
expect(f).toMatchObject({ type: 'polyline', tone: 'success', width: 8, opacity: 1, dash: 'dash', fill: false });
expect(f.label!.length).toBe(80);
expect(f.points).toHaveLength(3);
});
it('defaults tone/dash on unknown RAW values and applies numeric defaults', async () => {
const evil = { toString: () => 'success' };
const { c } = controller(() => [layer([line(2, { tone: evil, dash: evil, width: 'x', opacity: 'x' })])]);
const f = (await c.get('1', req(5))).layers[0].features[0];
expect(f.tone).toBe('default');
expect(f.dash).toBe('solid');
expect(f.width).toBe(3);
expect(f.opacity).toBe(0.8);
});
it('drops invalid shapes whole: bad vertex, short polygon, radius-less circle, unknown type', async () => {
const { c } = controller(() => [layer([
line(3, { points: [[48, 2], [200, 2], [48.2, 2.2]] }), // out-of-range vertex
{ type: 'polygon', points: [[48, 2], [48.1, 2.1]] }, // polygon needs >= 3
{ type: 'circle', center: [48, 2] }, // no radius
{ type: 'blob', points: [[48, 2], [48.1, 2.1]] }, // unknown type
null, // non-object
line(2, { label: 'good' }), // the only survivor
])]);
const out = (await c.get('1', req(5))).layers;
expect(out[0].features).toHaveLength(1);
expect(out[0].features[0].label).toBe('good');
});
it('renders circles with a clamped metric radius and polygon fill by default', async () => {
const { c } = controller(() => [layer([
{ type: 'circle', center: [48, 2], radiusM: 99_999_999 },
{ type: 'polygon', points: [[48, 2], [48.1, 2.1], [48, 2.2]] },
{ type: 'polygon', points: [[48, 2], [48.1, 2.1], [48, 2.2]], fill: false },
])]);
const [circle, poly, noFill] = (await c.get('1', req(5))).layers[0].features;
expect(circle).toMatchObject({ type: 'circle', radiusM: 2_000_000, fill: true });
expect(poly.fill).toBe(true);
expect(noFill.fill).toBe(false);
});
it('drops an oversized shape whole instead of truncating it', async () => {
const { c } = controller(() => [layer([line(2001), line(2, { label: 'kept' })])]);
const out = (await c.get('1', req(5))).layers;
expect(out[0].features).toHaveLength(1);
expect(out[0].features[0].label).toBe('kept');
});
it('enforces the per-provider budgets: 4 layers, 150 features, 8000 vertices', async () => {
// 5 layers -> 4; the vertex budget kills the tail of a dense provider.
const dense = Array.from({ length: 5 }, (_, i) => layer(
Array.from({ length: 60 }, () => line(50)), { id: `l${i}` },
));
const { c } = controller(() => dense);
const out = (await c.get('1', req(5))).layers;
expect(out.length).toBeLessThanOrEqual(4);
const features = out.flatMap(l => l.features);
expect(features.length).toBeLessThanOrEqual(150);
const vertices = features.reduce((n, f) => n + (f.points?.length ?? 1), 0);
expect(vertices).toBeLessThanOrEqual(8000);
});
it('bounds work on an all-invalid oversized payload (no unbounded iteration)', async () => {
// 100k valid-id / zero-feature layers would iterate fully without the raw cap.
const huge = Array.from({ length: 100_000 }, () => ({ id: 'x', features: [] }));
const { c } = controller(() => huge);
const out = (await c.get('1', req(5))).layers;
expect(out).toEqual([]); // all dropped (no features), and the scan stayed bounded
});
it('drops id-less and empty layers, keeps other providers when one fails', async () => {
const { c } = controller(
(id) => (id === 'bad'
? (() => { throw new Error('boom'); })()
: [layer([], { id: 'empty' }), layer([line()], { id: '' }), layer([line()], { id: 'ok' })]),
['good', 'bad'],
);
const out = (await c.get('1', req(5))).layers;
expect(out.map(l => `${l.pluginId}:${l.id}`)).toEqual(['good:ok']);
});
});
@@ -13,17 +13,18 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { MapMarkersController } from '../../../src/nest/plugins/map-markers.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { MapMarkersController } from '../../../src/nest/plugins/contributions/map-markers.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),
invokeHook: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginRuntimeService;
return { c: new MapMarkersController(runtime), runtime };
mapMarkers: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new MapMarkersController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime };
}
const mk = (over: Record<string, unknown> = {}) => ({ id: 'm1', lat: 48.85, lng: 2.35, ...over });
@@ -20,13 +20,17 @@ const { testDb, dbMock } = vi.hoisted(() => {
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
import { AddonsService } from '../../../src/nest/addons/addons.service';
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
testDb.prepare(
@@ -42,7 +46,7 @@ beforeEach(() => {
testDb.prepare('DELETE FROM plugins').run();
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
testDb.prepare('DELETE FROM plugin_actions').run();
rt = new PluginRuntimeService();
rt = createPluginRuntime(new DatabaseService(dbConn));
});
describe('operator-supplied egress hosts', () => {
@@ -156,13 +160,20 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
install('gotify', true);
install('plain', false);
const before = new PluginsService().list().plugins;
// list() resolves required-addon dependencies through AddonsService, so it gets a real
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
const listPlugins = () => {
const dbs = new DatabaseService(dbConn);
return new PluginsService(dbs, new AddonsService(dbs)).list().plugins;
};
const before = listPlugins();
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
// A plugin that never asked for it must never invite the admin to add hosts.
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
const after = new PluginsService().list().plugins;
const after = listPlugins();
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
delete process.env.TREK_PLUGINS_ENABLED;
});
@@ -14,17 +14,18 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PdfSectionsController } from '../../../src/nest/plugins/pdf-sections.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PdfSectionsController } from '../../../src/nest/plugins/contributions/pdf-sections.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),
invokeHook: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginRuntimeService;
return { c: new PdfSectionsController(runtime), runtime };
pdfSections: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new PdfSectionsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime };
}
const sec = (over: Record<string, unknown> = {}) => ({ title: 'Weather', ...over });
@@ -55,7 +56,7 @@ describe('PdfSectionsController', () => {
paragraphs: ['Sunny all week'],
table: { headers: ['Day', 'Temp'], rows: [['Mon', '24°C']] },
}]);
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'pdfSectionProvider', 'getSections', [1], 5, 5000);
expect(runtime.pdfSections).toHaveBeenCalledWith('p1', 1, 5);
});
it('drops non-objects, untitled sections and a non-array result; coerces + caps the title', async () => {
@@ -9,20 +9,22 @@ vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: (placeId: number) => placeTrip(placeId) }) },
canAccessTrip,
}));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PlaceDetailsController } from '../../../src/nest/plugins/place-details.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
function controller(over: Partial<PluginRuntimeService> = {}) {
function controller(over: Partial<PluginHooks> = {}) {
const runtime = {
providersOf: vi.fn(() => ['p1', 'p2']),
invokeHook: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])),
placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])),
...over,
} as unknown as PluginRuntimeService;
return { c: new PlaceDetailsController(runtime), runtime };
} as unknown as PluginHooks;
return { c: new PlaceDetailsController(runtime, new DatabaseService(dbConn)), runtime };
}
describe('PlaceDetailsController', () => {
@@ -49,26 +51,26 @@ describe('PlaceDetailsController', () => {
it('merges every provider that returns items and skips one that throws (graceful)', async () => {
const { c, runtime } = controller({
invokeHook: vi.fn(async (id: string) => {
placeDetails: vi.fn(async (id: string) => {
if (id === 'p2') throw new Error('slow provider');
return [{ label: 'Reviews', value: '12' }];
}) as unknown as PluginRuntimeService['invokeHook'],
}) as unknown as PluginHooks['placeDetails'],
});
const res = await c.get('7', req(5));
expect(res.providers).toEqual([{ pluginId: 'p1', items: [{ label: 'Reviews', value: '12' }] }]);
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'placeDetailProvider', 'getDetails', [7], 5, 5000);
expect(runtime.placeDetails).toHaveBeenCalledWith('p1', 7, 5);
});
it('normalizes rows: drops a javascript: url, caps lengths, drops non-objects + label-less rows', async () => {
const { c } = controller({
providersOf: vi.fn(() => ['p1']),
invokeHook: vi.fn(async () => [
placeDetails: vi.fn(async () => [
{ label: 'x'.repeat(200), value: 'y'.repeat(500), url: 'javascript:alert(1)' },
{ label: 'Site', url: 'https://ok.example' },
{ value: 'no label' }, // dropped
'not an object', // dropped
{ label: 'Mail', url: 'mailto:a@b.c' },
]) as unknown as PluginRuntimeService['invokeHook'],
]) as unknown as PluginHooks['placeDetails'],
});
const items = (await c.get('7', req(5))).providers[0].items;
expect(items).toEqual([
@@ -81,11 +83,11 @@ describe('PlaceDetailsController', () => {
it('caps the row count per provider at 12 and drops a provider that yields nothing usable', async () => {
const { c } = controller({
providersOf: vi.fn(() => ['flood', 'empty']),
invokeHook: vi.fn(async (id: string) =>
placeDetails: vi.fn(async (id: string) =>
id === 'flood'
? Array.from({ length: 50 }, (_v, i) => ({ label: `r${i}` }))
: [{ value: 'no label' }, 'junk'],
) as unknown as PluginRuntimeService['invokeHook'],
) as unknown as PluginHooks['placeDetails'],
});
const res = await c.get('7', req(5));
expect(res.providers).toHaveLength(1); // 'empty' contributes nothing -> dropped
@@ -15,16 +15,36 @@ import { PluginFrameController } from '../../../src/nest/plugins/plugin-frame.co
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
let codeRoot: string;
let outsideRoot: string;
// Windows only lets an unprivileged process create a directory junction.
const dirLink = process.platform === 'win32' ? 'junction' : 'dir';
let symlinksAvailable = true;
beforeAll(() => {
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-frame-'));
outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-outside-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
const dir = path.join(codeRoot, 'widget', 'client');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.html'), '<!doctype html><body>hi</body>');
// Something worth stealing, plus a link out of client/ that points at it.
fs.mkdirSync(path.join(outsideRoot, 'secrets'), { recursive: true });
fs.writeFileSync(path.join(outsideRoot, 'secrets', 'leak.js'), 'TOP SECRET');
// A dev-linked plugin: the whole tree is a link, which must still be served.
fs.mkdirSync(path.join(outsideRoot, 'linked', 'client'), { recursive: true });
fs.writeFileSync(path.join(outsideRoot, 'linked', 'client', 'index.html'), '<!doctype html><body>dev</body>');
try {
fs.symlinkSync(path.join(outsideRoot, 'secrets'), path.join(dir, 'esc'), dirLink);
fs.symlinkSync(path.join(outsideRoot, 'linked'), path.join(codeRoot, 'linked'), dirLink);
} catch {
symlinksAvailable = false;
}
});
afterAll(() => {
delete process.env.TREK_PLUGINS_DIR;
fs.rmSync(codeRoot, { recursive: true, force: true });
fs.rmSync(outsideRoot, { recursive: true, force: true });
});
function fakeRes() {
@@ -157,4 +177,23 @@ describe('PluginFrameController', () => {
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
expect(res.statusCode).toBe(404);
});
// The lexical guard above cannot see through a link, and statSync follows one.
it('403 on a symlink inside client/ that points out of the plugin', () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
expect(res.statusCode).toBe(403);
expect(res.filePath).toBeUndefined();
});
// Dev-link mode: the plugin directory itself is a link, so the containment
// check has to resolve BOTH sides or local development stops working.
it('still serves a dev-linked plugin whose whole tree is a symlink', () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
expect(res.statusCode).toBe(200);
expect(res.filePath).toContain('index.html');
});
});
@@ -0,0 +1,175 @@
/**
* PluginGuards holds the resource gates the decorated *.rpc.ts handlers will use from
* the first rollout PR onward. Its messages are copied character-for-character from
* the legacy router, because rpc-host.test.ts asserts them and shipped plugins read
* them, so these tests pin the exact strings rather than just the refusal.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../../../src/nest/plugins/host/rpc-errors';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
/**
* The guards read `actingUserId` and nothing else off the context, so the two per-host
* collaborators are inert here. `plugins` gets real no-op functions rather than a cast,
* so a guard that ever reached for a peer would hit a callable, not undefined.
*/
const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
pluginId: 'p',
actingUserId,
data: {} as PluginRpcContext['data'],
plugins: {
call: vi.fn(async () => undefined),
emit: vi.fn(),
},
});
/** Trip 1 belongs to user 42 and only user 42 may reach it, mirroring rpc-host.test.ts. */
function build(overrides: { role?: string | undefined; allow?: boolean; addonOn?: boolean } = {}) {
const db = {
canAccessTrip: vi.fn((tripId: number, userId: number) =>
tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined,
),
prepare: vi.fn(() => ({
get: () => ('role' in overrides ? (overrides.role === undefined ? undefined : { role: overrides.role }) : { role: 'user' }),
})),
} as unknown as DatabaseService;
const permissions = {
checkPermission: vi.fn(() => overrides.allow ?? true),
} as unknown as PermissionsService;
const addons = {
isAddonEnabled: vi.fn(() => overrides.addonOn ?? true),
} as unknown as AddonsService;
return { guards: new PluginGuards(db, permissions, addons), db, permissions, addons };
}
describe('PluginGuards — tripRead', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', () => {
const { guards } = build();
const read = vi.fn((userId: number) => ({ seenBy: userId }));
expect(guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(read).toHaveBeenCalledWith(42);
});
it('PGUARD-002 a userless context is refused before the read runs', () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 1 }, ctx(undefined), read)).toThrow(
new ForbiddenResource('trip reads require an authenticated user context'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-003 a non-member is refused, naming the trip', () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 2 }, ctx(42), read)).toThrow(
new ForbiddenResource('no access to trip 2'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', () => {
const { guards } = build();
expect(() => guards.tripRead({}, ctx(42), vi.fn())).toThrow(new BadParams('tripId must be a number'));
});
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', () => {
const { guards } = build();
expect(guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
});
});
describe('PluginGuards — requireActor', () => {
it('PGUARD-006 returns the acting user', () => {
expect(build().guards.requireActor(ctx(42), 'tag')).toBe(42);
});
it('PGUARD-007 appends the word "writes" to the noun, verbatim as the router did', () => {
expect(() => build().guards.requireActor(ctx(undefined), 'tag')).toThrow(
new ForbiddenResource('tag writes require an authenticated user context'),
);
});
});
describe('PluginGuards — requireTripEdit and canEditAs', () => {
it('PGUARD-008 passes when the user may access and edit', () => {
const { guards, permissions } = build({ allow: true });
expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).not.toThrow();
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false);
});
it('PGUARD-009 no access wins over no permission, and names the trip', () => {
const { guards } = build({ allow: false });
expect(() => guards.requireTripEdit(2, 42, 'trip_edit')).toThrow(
new ForbiddenResource('no access to trip 2'),
);
});
it('PGUARD-010 access without the edit permission is a different message', () => {
const { guards } = build({ allow: false });
expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).toThrow(
new ForbiddenResource('no permission to edit trip 1'),
);
});
it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', () => {
expect(build().guards.canEditAs('trip_edit', 2, 42)).toBe(false);
});
it('PGUARD-012 canEditAs returns false when the user row is gone', () => {
expect(build({ role: undefined }).guards.canEditAs('trip_edit', 1, 42)).toBe(false);
});
it('PGUARD-013 a missing role column falls back to "user"', () => {
const { guards, permissions } = build({ role: null as unknown as string });
guards.canEditAs('trip_edit', 1, 42);
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false);
});
it('PGUARD-014 a non-owner member is flagged as shared', () => {
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 7 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }) })),
} as unknown as DatabaseService;
const permissions = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
const guards = new PluginGuards(db, permissions, {} as AddonsService);
guards.canEditAs('trip_edit', 1, 42);
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 7, 42, true);
});
});
describe('PluginGuards — requireAddon', () => {
it('PGUARD-015 an enabled addon passes', () => {
expect(() => build({ addonOn: true }).guards.requireAddon('budget', 'costs')).not.toThrow();
});
it('PGUARD-016 a disabled addon is refused with the noun in the message', () => {
expect(() => build({ addonOn: false }).guards.requireAddon('budget', 'costs')).toThrow(
new ForbiddenResource('the costs addon is disabled'),
);
});
});
describe('PluginGuards — capStrings', () => {
it('PGUARD-017 a field within the cap passes', () => {
expect(() => build().guards.capStrings({ name: 'ok' }, { name: 200 })).not.toThrow();
});
it('PGUARD-018 an oversized field is BAD_PARAMS, naming field and cap', () => {
expect(() => build().guards.capStrings({ name: 'x'.repeat(201) }, { name: 200 })).toThrow(
new BadParams('name must be 200 characters or fewer'),
);
});
it('PGUARD-019 a non-string value is left alone', () => {
expect(() => build().guards.capStrings({ name: 12345 }, { name: 2 })).not.toThrow();
});
it('PGUARD-020 an absent field is left alone', () => {
expect(() => build().guards.capStrings({}, { name: 1 })).not.toThrow();
});
});
@@ -0,0 +1,146 @@
/**
* The host-to-plugin hook contracts.
*
* The 15 hooks used to be invoked straight from the controllers, with the fn name and
* the timeout spelled out at each call site. Nothing checked that a `hook:*` grant on
* the consent screen had a consumer behind it, that the fn name matched what the SDK
* documents, or that two call sites for the same hook agreed on the budget.
*
* These tests pin all three, plus the argument shape each hook is handed.
*/
import { describe, it, expect, vi } from 'vitest';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import { PluginsRuntimeModule } from '../../../src/nest/plugins/plugins-runtime.module';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
type Invocation = [string, string, string, unknown[], number | undefined, number];
function hooks() {
const invokeHook = vi.fn(async () => ({ ok: true }));
const providersOf = vi.fn(() => ['p1', 'p2']);
const runtime = { invokeHook, providersOf } as unknown as PluginRuntimeService;
return { hooks: new PluginHooks(runtime), invokeHook, providersOf };
}
/** The (hook, fn, timeout) triple every call site used to carry on its own. */
const CONTRACTS: Array<[hook: string, fn: string, timeoutMs: number]> = [
['photoProvider', 'search', 5000],
['photoProvider', 'getById', 5000],
['calendarSource', 'getName', 3000],
['calendarSource', 'getEvents', 5000],
['placeDetailProvider', 'getDetails', 5000],
['warningProvider', 'getWarnings', 5000],
['tableContributor', 'getContributions', 5000],
['mapMarkerProvider', 'getMarkers', 5000],
['mapLayerProvider', 'getLayers', 5000],
['routeProvider', 'getRoute', 20_000],
['dayScheduleProvider', 'getSchedule', 5000],
['dayTintProvider', 'getDayTints', 5000],
['pdfSectionProvider', 'getSections', 5000],
['atlasLayerProvider', 'getLayers', 5000],
['journalEntryProvider', 'getRows', 5000],
['tripCardProvider', 'getCards', 5000],
['notificationChannel', 'send', 8000],
['notificationChannel', 'test', 8000],
];
describe('PluginHooks contracts', () => {
const registry = createTestPluginRegistry([new PluginHooks({} as never)]);
it('PLUGHOOK-001 every hook the consent screen offers has a host-side consumer', () => {
// A plain string set on purpose: the keys probed against it come from
// HOOK_PERMISSION, and whether each one is a hook the host consumes is the
// very thing under test, not something to assert into the element type.
const covered = new Set<string>([...registry.hookContracts().values()].map((c) => c.hook));
expect([...Object.keys(HOOK_PERMISSION)].filter((h) => !covered.has(h))).toEqual([]);
});
it('PLUGHOOK-002 the declared (hook, fn) pairs are exactly the ones the host calls', () => {
const declared = [...registry.hookContracts().keys()].sort();
expect(declared).toEqual(CONTRACTS.map(([hook, fn]) => `${hook}#${fn}`).sort());
});
it('PLUGHOOK-003 each contract carries the permission HOOK_PERMISSION assigns and its own budget', () => {
for (const [hook, fn, timeoutMs] of CONTRACTS) {
const contract = registry.hookContract(hook, fn);
expect(contract.permission).toBe((HOOK_PERMISSION as Record<string, string>)[hook]);
expect(contract.timeoutMs).toBe(timeoutMs);
expect(contract.consumer).toMatch(/^PluginHooks\./);
}
});
it('PLUGHOOK-004 a call site asking for a fn nobody declared is a throw, not a silent miss', () => {
expect(() => registry.hookContract('photoProvider', 'searchAll')).toThrow(/no @PluginHook declaration/);
});
it('PLUGHOOK-005 providersOf is passed straight through to the runtime', () => {
const { hooks: h, providersOf } = hooks();
expect(h.providersOf('photoProvider')).toEqual(['p1', 'p2']);
expect(providersOf).toHaveBeenCalledWith('photoProvider');
});
it('PLUGHOOK-006 the read hooks forward their arguments and the acting user', async () => {
const { hooks: h, invokeHook } = hooks();
const last = (): Invocation => invokeHook.mock.calls.at(-1) as unknown as Invocation;
await h.searchPhotos('p', 'beach', 3, 60, 7);
expect(last()).toEqual(['p', 'photoProvider', 'search', ['beach', { page: 3, limit: 60 }], 7, 5000]);
await h.getPhoto('p', 'ph-1', 7);
expect(last()).toEqual(['p', 'photoProvider', 'getById', ['ph-1'], 7, 5000]);
await h.calendarName('p', 9);
expect(last()).toEqual(['p', 'calendarSource', 'getName', [], 9, 3000]);
await h.calendarEvents('p', 9, 'a', 'b');
expect(last()).toEqual(['p', 'calendarSource', 'getEvents', [9, 'a', 'b'], 9, 5000]);
await h.placeDetails('p', 7, 5);
expect(last()).toEqual(['p', 'placeDetailProvider', 'getDetails', [7], 5, 5000]);
await h.tripWarnings('p', 1, 5);
expect(last()).toEqual(['p', 'warningProvider', 'getWarnings', [1], 5, 5000]);
await h.tableContributions('p', 'places', 1, 5);
expect(last()).toEqual(['p', 'tableContributor', 'getContributions', ['places', 1], 5, 5000]);
await h.mapMarkers('p', 1, 5);
expect(last()).toEqual(['p', 'mapMarkerProvider', 'getMarkers', [1], 5, 5000]);
await h.mapLayers('p', 1, 5);
expect(last()).toEqual(['p', 'mapLayerProvider', 'getLayers', [1], 5, 5000]);
await h.daySchedule('p', 1, 5);
expect(last()).toEqual(['p', 'dayScheduleProvider', 'getSchedule', [1], 5, 5000]);
await h.dayTints('p', 1, 5);
expect(last()).toEqual(['p', 'dayTintProvider', 'getDayTints', [1], 5, 5000]);
await h.pdfSections('p', 1, 5);
expect(last()).toEqual(['p', 'pdfSectionProvider', 'getSections', [1], 5, 5000]);
await h.atlasLayers('p', 5);
expect(last()).toEqual(['p', 'atlasLayerProvider', 'getLayers', [], 5, 5000]);
await h.journalRows('p', 7, 5);
expect(last()).toEqual(['p', 'journalEntryProvider', 'getRows', [7], 5, 5000]);
await h.tripCards('p', [1, 2], 5);
expect(last()).toEqual(['p', 'tripCardProvider', 'getCards', [[1, 2]], 5, 5000]);
});
it('PLUGHOOK-007 a route solve gets four times the budget of a plain read', async () => {
const { hooks: h, invokeHook } = hooks();
const request = { tripId: 1, dayId: 7, profile: 'ev', waypoints: [] };
await h.route('p', request, 5);
expect(invokeHook).toHaveBeenCalledWith('p', 'routeProvider', 'getRoute', [request], 5, 20_000);
});
it('PLUGHOOK-008 both notification-channel calls run with NO acting user', async () => {
// A notification is host-initiated for an arbitrary recipient, so the hook is
// handed that recipient's own decrypted config rather than the right to read
// anything as them. A bound acting user here would be exactly that right.
const { hooks: h, invokeHook } = hooks();
const message = { event: 'trip_reminder', title: 't', body: 'b' };
await h.sendNotification('p', message, { token: 'x' });
expect(invokeHook).toHaveBeenCalledWith('p', 'notificationChannel', 'send', [message, { token: 'x' }], undefined, 8000);
await h.testNotification('p', { token: 'x' });
expect(invokeHook).toHaveBeenCalledWith('p', 'notificationChannel', 'test', [{ token: 'x' }], undefined, 8000);
});
it('PLUGHOOK-009 the class is listed in its module providers', () => {
// A decorated class no module registers is invisible to discovery, which would
// now fail boot on "no host-side consumer" rather than fail quietly. Cheapest
// possible guard against getting there.
expectRegisteredProvider(PluginsRuntimeModule, PluginHooks);
});
});
@@ -0,0 +1,43 @@
/**
* Grammar-compat pin for the plugin job scheduler's node-cron → cron migration.
*
* A validation *tightening* would silently disable a published plugin's job
* (an invalid cron is skipped, never scheduled — deliberately, so a bad
* schedule can't take the host down). This runs the REAL `cron` validator
* (unmocked) over the manifest shapes plugins realistically declare and pins
* that everything node-cron accepted still schedules, and garbage still
* doesn't.
*/
import { describe, it, expect } from 'vitest';
import { validateCronExpression } from 'cron';
const ACCEPTED = [
'0 0 * * *', // nightly
'*/5 * * * *', // step minutes
'0 9 * * MON', // day-of-week name
'0 9 * * sun', // lowercase name
'0 9 * * 7', // 7-as-Sunday
'0 0 1 JAN *', // month name
'30 */2 * * 0-5', // range + step combined
'1-5 * * * *', // minute range
'*/30 * * * * *', // 6-field with seconds first
'0 0 29 2 *', // leap-day
];
const REJECTED = [
'not-a-cron',
'',
'* * *', // too few fields
'61 * * * *', // minute out of range
'0 24 * * *', // hour out of range
];
describe('plugin job cron grammar (real validator)', () => {
it.each(ACCEPTED)('PJOB-COMPAT accepts %j', (expr) => {
expect(validateCronExpression(expr).valid).toBe(true);
});
it.each(REJECTED)('PJOB-COMPAT rejects %j', (expr) => {
expect(validateCronExpression(expr).valid).toBe(false);
});
});
@@ -6,11 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const h = vi.hoisted(() => ({ tasks: [] as Array<{ expr: string; fn: () => void; stopped: boolean; stop(): void }> }));
vi.mock('node-cron', () => ({
default: {
validate: (expr: string) => typeof expr === 'string' && expr.trim() !== '' && expr !== 'not-a-cron',
schedule: (expr: string, fn: () => void) => {
const t = { expr, fn, stopped: false, stop() { this.stopped = true; } };
vi.mock('cron', () => ({
validateCronExpression: (expr: string) => ({ valid: typeof expr === 'string' && expr.trim() !== '' && expr !== 'not-a-cron' }),
CronJob: {
from: ({ cronTime, onTick }: { cronTime: string; onTick: () => void }) => {
const t = { expr: cronTime, fn: onTick, stopped: false, stop() { this.stopped = true; } };
h.tasks.push(t);
return t;
},
@@ -12,9 +12,11 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginOAuthController } from '../../../src/nest/plugins/plugin-oauth.controller';
import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -30,7 +32,7 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
disconnect: vi.fn(),
...over,
} as unknown as PluginOAuthService;
return { c: new PluginOAuthController(svc), svc };
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
}
describe('PluginOAuthController', () => {
@@ -6,14 +6,19 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../../src/services/apiKeyCrypto', () => ({
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
encrypt_api_key: (v: unknown) => (typeof v === 'string' ? `enc:${v}` : v),
decrypt_api_key: (v: unknown) => (typeof v === 'string' && v.startsWith('enc:') ? v.slice(4) : v),
}));
vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https://trek.example' }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getAppUrl: () => 'https://trek.example' };
});
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
// resolves the host before fetching. Stub DNS so the fake provider.example host
@@ -25,7 +30,7 @@ vi.mock('node:dns/promises', () => {
});
import Database from 'better-sqlite3';
import { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
import { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
const CFG = {
oauth_authorize_url: 'https://provider.example/authorize',
@@ -50,12 +55,12 @@ const NOW = 1_700_000_000_000;
describe('PluginOAuthService', () => {
let svc: PluginOAuthService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
expect(new PluginOAuthService().providerConfig('p')).toBeNull();
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
});
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
@@ -76,18 +81,18 @@ describe('PluginOAuthService', () => {
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/https/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|private/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
// IPv6-literal loopback must not slip past the fast-fail
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
// cloud-metadata by literal is refused too
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
// an internal name suffix is refused
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/local/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
});
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
@@ -9,17 +9,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginPhotosController } from '../../../src/nest/plugins/plugin-photos.controller';
import { PluginCalendarController } from '../../../src/nest/plugins/plugin-calendar.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PluginPhotosController } from '../../../src/nest/plugins/contributions/plugin-photos.controller';
import { PluginCalendarController } from '../../../src/nest/plugins/contributions/plugin-calendar.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
function runtime(providers: string[], invoke: (id: string, fn: string, args: unknown[]) => unknown) {
return {
providersOf: vi.fn(() => providers),
invokeHook: vi.fn(async (id: string, _hook: string, fn: string, args: unknown[]) => invoke(id, fn, args)),
} as unknown as PluginRuntimeService;
searchPhotos: vi.fn(async (id: string, query: string, page: number, limit: number) => invoke(id, 'search', [query, { page, limit }])),
getPhoto: vi.fn(async (id: string, photoId: string) => invoke(id, 'getById', [photoId])),
calendarName: vi.fn(async (id: string) => invoke(id, 'getName', [])),
calendarEvents: vi.fn(async (id: string, userId: number, start: string, end: string) => invoke(id, 'getEvents', [userId, start, end])),
} as unknown as PluginHooks;
}
describe('PluginPhotosController (photoProvider hook)', () => {
@@ -54,7 +57,7 @@ describe('PluginPhotosController (photoProvider hook)', () => {
const c = new PluginPhotosController(rt);
const out = (await c.search('beach', '3', req(7))).providers;
expect(out.map((p) => p.pluginId)).toEqual(['ok']);
expect(rt.invokeHook).toHaveBeenCalledWith('ok', 'photoProvider', 'search', ['beach', { page: 3, limit: 60 }], 7, 5000);
expect(rt.searchPhotos).toHaveBeenCalledWith('ok', 'beach', 3, 60, 7);
});
it('item: returns one normalized photo from a known provider, null for an unknown one', async () => {
@@ -81,7 +84,7 @@ describe('PluginCalendarController (calendarSource hook)', () => {
expect(out[0].events).toHaveLength(1);
expect(out[0].events[0]).toMatchObject({ id: 'e1', title: 'Flight', allDay: false });
// getEvents got the user + the window
expect(rt.invokeHook).toHaveBeenCalledWith('cal', 'calendarSource', 'getEvents', [9, '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z'], 9, 5000);
expect(rt.calendarEvents).toHaveBeenCalledWith('cal', 9, '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z');
});
it('gates on user + plugins-enabled, and skips a failing source', async () => {
@@ -20,6 +20,7 @@ import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.s
import { PluginRegistryService, RegistryError } from '../../../src/nest/plugins/registry/registry.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const ADMIN = { id: 1 } as { id: number };
const REQ = { headers: {}, socket: {} } as never;
@@ -48,11 +49,14 @@ let runtime: PluginRuntimeService;
let registry: PluginRegistryService;
let controller: PluginsController;
let plugins: PluginsService;
beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn() } as unknown as PluginRegistryService;
controller = new PluginsController({} as PluginsService, runtime, registry);
registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService;
plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService;
controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
});
describe('signature refusal codes survive to the client', () => {
@@ -97,6 +101,87 @@ describe('signature refusal codes survive to the client', () => {
});
});
// The rollback path: an admin picks a specific (possibly older) version, and the runtime
// must install exactly that one — the compat gate stays server-side in selectVersion.
describe('POST :id/update — explicit version', () => {
it('forwards the requested version to the runtime', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
const res = await controller.update('flight-tracker', { version: '1.2.0' });
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]);
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
});
it('an omitted body keeps the default newest-compatible resolution', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: undefined }]);
});
});
// The update hold: only a DELIBERATE version pick may set it, every successful
// install/update recomputes it (so landing back on the newest clears a stale one),
// and dependency resolution — which pins versions too, but never deliberately —
// must not touch it.
describe('update hold wiring', () => {
it('an explicit-version update recomputes the hold as deliberate', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker', { version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a default update recomputes as non-deliberate (clears a stale hold)', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '2.0.0', false);
});
it('an explicit-version install recomputes the hold as deliberate', async () => {
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' });
await controller.install({ id: 'flight-tracker', version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a dependency-resolution install does NOT touch the hold', async () => {
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] });
await controller.install({ id: 'flight-tracker', withDependencies: true });
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('a failed update leaves the hold untouched', async () => {
vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable'));
await expect(controller.update('flight-tracker', { version: '1.2.0' })).rejects.toThrow(HttpException);
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('POST :id/resume-updates clears the hold', async () => {
const res = await controller.resumeUpdates('flight-tracker');
expect(plugins.resumeUpdates).toHaveBeenCalledWith('flight-tracker');
expect(res).toEqual({ updateHold: false });
});
it('POST :id/resume-updates is a 404 for an unknown plugin', async () => {
vi.mocked(plugins.resumeUpdates).mockReturnValue(false);
const { status } = await wireFailure(async () => controller.resumeUpdates('ghost'));
expect(status).toBe(404);
});
});
describe('POST :id/retrust', () => {
it('re-pins AND installs in one call, returning the update result', async () => {
vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
@@ -0,0 +1,136 @@
/**
* PluginRoutesController (#plugins): targeted routing through ONE routeProvider
* plugin profile. Mirrors the provider-hook hardening (gate + fail-safe) plus the
* bits unique to routing: strict request validation (waypoint count + ranges),
* the declared-profile re-check against the DB row, and whole-result rejection
* of malformed routes (a wrong leg count must never mis-key sidebar connectors).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { canAccessTrip, pluginsEnabled, capabilitiesRow } = vi.hoisted(() => ({
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
pluginsEnabled: vi.fn(() => true),
capabilitiesRow: { value: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }) as string | undefined },
}));
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: () => (capabilitiesRow.value === undefined ? undefined : { capabilities: capabilitiesRow.value }) }) },
canAccessTrip,
}));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginRoutesController } from '../../../src/nest/plugins/contributions/plugin-routes.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
function controller(invoke: () => unknown, providers = ['ev-plug']) {
const runtime = {
providersOf: vi.fn(() => providers),
route: vi.fn(async () => invoke()),
} as unknown as PluginHooks;
return { c: new PluginRoutesController(runtime, new DatabaseService(dbConn)), runtime };
}
const wp = (n = 3) => Array.from({ length: n }, (_, i) => ({ lat: 48 + i * 0.1, lng: 2 + i * 0.1 }));
const goodRoute = (n = 3, over: Record<string, unknown> = {}) => ({
coordinates: [[48, 2], [48.1, 2.1], [48.2, 2.2]],
distance: 12000,
duration: 900,
legs: Array.from({ length: n - 1 }, () => ({ distance: 6000, duration: 450 })),
...over,
});
const body = (over: Record<string, unknown> = {}) => ({ tripId: 1, dayId: 7, waypoints: wp(), ...over });
describe('PluginRoutesController', () => {
beforeEach(() => {
pluginsEnabled.mockReturnValue(true);
canAccessTrip.mockReturnValue({ id: 1 } as never);
capabilitiesRow.value = JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] });
});
it('gates: disabled / no user / non-member / bad profile id all return null', async () => {
pluginsEnabled.mockReturnValue(false);
const x = controller(() => goodRoute());
expect(await x.c.route('ev-plug', 'ev', body(), req(5))).toEqual({ route: null });
expect(x.runtime.providersOf).not.toHaveBeenCalled();
pluginsEnabled.mockReturnValue(true);
expect((await controller(() => goodRoute()).c.route('ev-plug', 'ev', body(), req(undefined))).route).toBeNull();
canAccessTrip.mockReturnValue(undefined as never);
expect((await controller(() => goodRoute()).c.route('ev-plug', 'ev', body(), req(5))).route).toBeNull();
canAccessTrip.mockReturnValue({ id: 1 } as never);
expect((await controller(() => goodRoute()).c.route('ev-plug', '../etc', body(), req(5))).route).toBeNull();
});
it('rejects invalid waypoint lists without invoking the plugin', async () => {
const cases = [
body({ waypoints: wp(1) }), // too few
body({ waypoints: wp(31) }), // too many
body({ waypoints: [{ lat: 200, lng: 2 }, { lat: 48, lng: 2 }] }), // out of range
body({ waypoints: 'nope' }),
body({ tripId: 'NaN' }),
];
for (const b of cases) {
const { c, runtime } = controller(() => goodRoute());
expect((await c.route('ev-plug', 'ev', b as never, req(5))).route).toBeNull();
expect(runtime.route).not.toHaveBeenCalled();
}
});
it('refuses a plugin that is not a granted provider or a profile it never declared', async () => {
expect((await controller(() => goodRoute(), []).c.route('ev-plug', 'ev', body(), req(5))).route).toBeNull();
// provider ok, but the DB row declares no such profile (hand-edited or stale)
expect((await controller(() => goodRoute()).c.route('ev-plug', 'scenic', body(), req(5))).route).toBeNull();
capabilitiesRow.value = undefined; // plugin row vanished
expect((await controller(() => goodRoute()).c.route('ev-plug', 'ev', body(), req(5))).route).toBeNull();
});
it('returns a normalized route and passes the request through to the hook', async () => {
const { c, runtime } = controller(() => goodRoute(3, {
legs: [{ distance: 6000, duration: 450, note: 'charge to 80%' }, { distance: 6000, duration: 450 }],
viaPoints: [{ lat: 48.05, lng: 2.05, label: 'Fastned', tone: 'success', dwellSeconds: 1500.7 }],
}));
const { route } = await c.route('ev-plug', 'ev', body(), req(5));
expect(route).not.toBeNull();
expect(route!.pluginId).toBe('ev-plug');
expect(route!.profile).toBe('ev');
expect(route!.legs[0].note).toBe('charge to 80%');
expect(route!.viaPoints[0]).toMatchObject({ label: 'Fastned', tone: 'success', dwellSeconds: 1501 });
expect(runtime.route).toHaveBeenCalledWith('ev-plug', { tripId: 1, dayId: 7, profile: 'ev', waypoints: wp() }, 5);
});
it('rejects a malformed route whole: wrong leg count, bad vertex, negative numbers', async () => {
const bads = [
goodRoute(3, { legs: [{ distance: 1, duration: 1 }] }), // 1 leg for 3 waypoints
goodRoute(3, { coordinates: [[48, 2], [999, 2]] }), // out-of-range vertex
goodRoute(3, { distance: -5 }),
goodRoute(3, { duration: 'soon' }),
goodRoute(3, { legs: [{ distance: 1, duration: -1 }, { distance: 1, duration: 1 }] }),
'nope',
];
for (const bad of bads) {
expect((await controller(() => bad).c.route('ev-plug', 'ev', body(), req(5))).route).toBeNull();
}
});
it('drops bad via points individually and caps counts, without failing the route', async () => {
const vias = [
{ lat: 48, lng: 2, tone: { toString: () => 'success' }, label: 'L'.repeat(200) }, // raw tone check + cap
{ lat: 999, lng: 2, label: 'dropped' },
...Array.from({ length: 50 }, (_, i) => ({ lat: 48, lng: 2, label: `v${i}` })),
];
const { c } = controller(() => goodRoute(3, { viaPoints: vias }));
const { route } = await c.route('ev-plug', 'ev', body(), req(5));
expect(route).not.toBeNull();
expect(route!.viaPoints.length).toBeLessThanOrEqual(40);
expect(route!.viaPoints[0].tone).toBe('default');
expect(route!.viaPoints[0].label!.length).toBe(80);
expect(route!.viaPoints.some(v => v.label === 'dropped')).toBe(false);
});
it('answers null when the provider throws or times out', async () => {
const { c } = controller(() => { throw new Error('solver down'); });
expect((await c.route('ev-plug', 'ev', body(), req(5))).route).toBeNull();
});
});
@@ -11,6 +11,8 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
@@ -26,7 +28,7 @@ function ctrl() {
} as unknown as PluginsService;
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime), svc, runtime };
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
}
describe('PluginUserSettingsController', () => {
@@ -8,16 +8,22 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Reversible crypto stub so we can assert encrypt-at-rest without a real key env.
vi.mock('../../../src/services/apiKeyCrypto', () => ({
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
maybe_encrypt_api_key: (v: unknown) => (typeof v === 'string' ? `enc:${v}` : v),
decrypt_api_key: (v: unknown) => (typeof v === 'string' && v.startsWith('enc:') ? v.slice(4) : v),
}));
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import Database from 'better-sqlite3';
import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
/** The host-side settings reads, over the same connection the test seeded. */
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
function freshDb() {
const d = new Database(':memory:');
@@ -35,7 +41,13 @@ function freshDb() {
describe('per-user plugin settings', () => {
let svc: PluginsService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(); });
// AddonsService only feeds PluginsService.list(), which no case here calls, but it is a
// real collaborator on the same connection rather than a stand-in.
beforeEach(() => {
getDb.current = freshDb();
const dbs = new DatabaseService(dbConn);
svc = new PluginsService(dbs, new AddonsService(dbs));
});
it('lists only the user-scope fields, in order', () => {
const fields = svc.userSettingsFields('p');
@@ -48,14 +60,14 @@ describe('per-user plugin settings', () => {
expect(masked.apiKey).toBe('••••••••'); // never echoed
expect(masked.units).toBe('metric');
// decrypted runtime read returns the real value; the stored form is ciphertext
expect(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123');
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123');
expect(svc.getUserConfig('p', 42).apiKey).toBe('••••••••');
});
it('an unchanged secret (the mask) keeps the stored ciphertext', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
expect(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect(svc.getUserConfig('p', 42).units).toBe('imperial');
});
@@ -70,6 +82,6 @@ describe('per-user plugin settings', () => {
it('is per-user — one user cannot see another\'s value', () => {
svc.updateUserConfig('p', 42, { units: 'metric' });
expect(svc.getUserConfig('p', 99).units).toBeUndefined();
expect(readUserSettingDecrypted('p', 99, 'apiKey')).toBeUndefined();
expect(userSettings().readOne('p', 99, 'apiKey')).toBeUndefined();
});
});
@@ -8,9 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import { PluginConsentRequired } from '../../../src/nest/plugins/plugin-runtime.service';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ctrl = (runtime: any) => new PluginsController({} as any, runtime, {} as any);
const ctrl = (runtime: any) => new PluginsController({} as any, runtime, {} as any, { isManaged: () => false } as unknown as RuntimeEnvService);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const status = async (p: Promise<any>): Promise<number> =>
p.then(() => -1, (e) => (e instanceof HttpException ? e.getStatus() : 0));
@@ -0,0 +1,83 @@
/**
* PluginsFeedController (#plugins): the authenticated active-plugin feed. Focused
* on the parts that re-validate hand-editable DB JSON: capability-derived fields
* must never surface values the manifest parser would have rejected, and
* routeProfiles must only appear for plugins whose hook:route-provider grant is
* actually recorded — a declared-but-ungranted provider can never serve a route,
* so offering its profiles in the picker would produce dead buttons.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { rows, pluginsEnabled } = vi.hoisted(() => ({
rows: { value: [] as Array<Record<string, unknown>> },
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
const row = (over: Record<string, unknown> = {}) => ({
id: 'p1', name: 'P', type: 'integration', icon: null,
capabilities: '{}', granted_permissions: '[]',
...over,
});
describe('PluginsFeedController', () => {
const c = new PluginsFeedController(new DatabaseService(dbConn));
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
it('returns an empty feed when the runtime is disabled', () => {
pluginsEnabled.mockReturnValue(false);
rows.value = [row()];
expect(c.list()).toEqual({ plugins: [] });
});
it('serves routeProfiles only alongside the recorded hook:route-provider grant', () => {
rows.value = [
row({ id: 'granted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }), granted_permissions: JSON.stringify(['hook:route-provider']) }),
row({ id: 'ungranted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }) }),
];
const { plugins } = c.list();
expect(plugins.find(p => p.id === 'granted')?.routeProfiles).toEqual([{ id: 'ev', label: 'EV' }]);
expect(plugins.find(p => p.id === 'ungranted')?.routeProfiles).toBeUndefined();
});
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', () => {
rows.value = [row({
id: 'edited',
capabilities: JSON.stringify({
routeProfiles: [
{ id: '../up', label: 'bad id' },
{ id: 'ok', label: ' L '.padEnd(60, 'x') },
{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' },
],
}),
granted_permissions: JSON.stringify(['hook:route-provider']),
})];
const profiles = c.list().plugins[0].routeProfiles!;
expect(profiles.every(p => /^[a-z][a-z0-9-]{0,23}$/.test(p.id))).toBe(true);
expect(profiles.length).toBeLessThanOrEqual(3);
expect(profiles.every(p => p.label.length <= 40)).toBe(true);
});
it('flags geolocation only when the grant is recorded', () => {
rows.value = [
row({ id: 'granted', granted_permissions: JSON.stringify(['geolocation:read']) }),
row({ id: 'ungranted' }),
];
const { plugins } = c.list();
expect(plugins.find(p => p.id === 'granted')?.geolocation).toBe(true);
expect(plugins.find(p => p.id === 'ungranted')?.geolocation).toBeUndefined();
});
it('survives malformed JSON blobs without dropping the plugin', () => {
rows.value = [row({ capabilities: '{not json', granted_permissions: 'also not' })];
const { plugins } = c.list();
expect(plugins).toHaveLength(1);
expect(plugins[0].routeProfiles).toBeUndefined();
expect(plugins[0].slot).toBe('sidebar');
});
});
@@ -11,7 +11,7 @@ const { pluginsEnabledMock, extractTokenMock, verifyMock } = vi.hoisted(() => ({
verifyMock: vi.fn(() => ({ id: 5, username: 'ada', role: 'user' })),
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock }));
vi.mock('../../../src/middleware/auth', () => ({ extractToken: extractTokenMock, verifyJwtAndLoadUser: verifyMock }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: extractTokenMock, verifyJwtAndLoadUser: verifyMock }));
import { PluginsProxyController } from '../../../src/nest/plugins/plugins-proxy.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
@@ -9,18 +9,22 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range TEXT, sort_order INTEGER DEFAULT 0);
trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
@@ -38,25 +42,49 @@ describe('PluginsService.list', () => {
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
});
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
.run();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
expect(svc.resumeUpdates('ghost')).toBe(false);
});
it('reports enabled by default (no kill switch set)', () => {
testDb
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
});
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.enabled).toBe(false);
expect(out.plugins).toEqual([]);
});
@@ -76,7 +104,7 @@ describe('PluginsService.list', () => {
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
insert('signed-one', 'acme/signed-one', key);
const p = new PluginsService().list().plugins[0];
const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins[0];
expect(p.signed).toBe(true);
// Short head…tail, for eyeballing against what the author reads out over the
// phone. NOT a confidentiality measure — the key is public, and the re-trust
@@ -86,7 +114,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a registry plugin with no pinned key', () => {
insert('plain', 'acme/plain', null);
const p = new PluginsService().list().plugins[0];
const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins[0];
expect(p.signed).toBe(false);
expect(p.keyFingerprint).toBeNull();
});
@@ -94,7 +122,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => {
insert('uploaded', 'local:upload', null);
insert('linked', 'local:link', null);
const plugins = new PluginsService().list().plugins;
const plugins = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins;
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
['linked', false, 'local:link'],
['uploaded', false, 'local:upload'],
@@ -108,14 +136,18 @@ describe('PluginsService.list', () => {
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p]));
const byId = Object.fromEntries(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins.map((p) => [p.id, p]));
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
expect(byId.fine.updateBlock).toBeNull();
});
it('never leaks the raw pinned key into the list response (only the fingerprint)', () => {
insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
// PluginListItem is an interface, so it has no implicit index signature and cannot
// be narrowed to a record directly. Widening through unknown is what lets this case
// probe for a key the contract deliberately does not declare.
const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()
.plugins[0] as unknown as Record<string, unknown>;
expect(p.author_pubkey).toBeUndefined();
});
});
@@ -123,7 +155,7 @@ describe('PluginsService.list', () => {
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
const res = new PluginsController(svc, runtime, {} as never).list();
const res = new PluginsController(svc, runtime, {} as never, { isManaged: () => false } as unknown as RuntimeEnvService).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
@@ -133,7 +165,7 @@ describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
const feed = new PluginsFeedController();
const feed = new PluginsFeedController(new DatabaseService(dbConn));
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
@@ -147,7 +179,7 @@ describe('PluginsFeedController (client feed)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
@@ -155,14 +187,14 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
@@ -170,7 +202,7 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
});
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => {
@@ -180,7 +212,7 @@ describe('PluginsFeedController (client feed)', () => {
// the capability is meaningless off a trip-page and must not leak onto widgets
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
@@ -192,6 +224,9 @@ describe('PluginsController M2 endpoints', () => {
getInstanceConfig: vi.fn(() => ({ a: 1 })),
updateInstanceConfig: vi.fn(() => ({ a: 2 })),
} as unknown as PluginsService;
// None of the endpoints below carry the marker, so the ordinary install is the
// whole story here; the refusals have their own tests.
const envStub = { isManaged: () => false } as unknown as RuntimeEnvService;
beforeEach(() => {
(svc.getInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
@@ -201,32 +236,32 @@ describe('PluginsController M2 endpoints', () => {
it('get/update config delegate to the service', () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
const c = new PluginsController(svc, rt, {} as never);
const c = new PluginsController(svc, rt, {} as never, envStub);
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
});
it('activate spawns via the runtime when enabled', async () => {
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
const out = await new PluginsController(svc, rt, {} as never).activate('x');
const out = await new PluginsController(svc, rt, {} as never, envStub).activate('x', {});
expect(out).toEqual({ status: 'active' });
});
it('activate is 503 when the runtime is disabled', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
await expect(new PluginsController(svc, rt, {} as never, envStub).activate('x', {})).rejects.toMatchObject({ status: 503 });
});
it('activate surfaces an activation error as 400', async () => {
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
await expect(new PluginsController(svc, rt, {} as never, envStub).activate('x', {})).rejects.toMatchObject({ status: 400 });
});
it('deactivate stops the plugin (and cascades to dependents)', async () => {
const deactivateWithDependents = vi.fn(async () => ['x']);
const rt = { deactivateWithDependents } as never;
expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' });
expect(await new PluginsController(svc, rt, {} as never, envStub).deactivate('x')).toEqual({ status: 'inactive' });
expect(deactivateWithDependents).toHaveBeenCalledWith('x');
});
});
@@ -235,8 +270,9 @@ describe('PluginsService instance config', () => {
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
const svc = new PluginsService();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
// client gets the masked view
expect(masked.api_key).toBe('••••••••');
@@ -256,9 +292,22 @@ describe('PluginsService instance config', () => {
expect(svc.getInstanceConfig('x').api_key).toBe('••••••••');
});
it('drops a key the plugin never declared, like the user-scope sibling does', () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
expect(masked.server).toBe('https://h');
expect(masked.smuggled).toBeUndefined();
const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='y'").get() as { config: string }).config);
expect(stored).toEqual({ server: 'https://h' });
});
it('throws for an unknown plugin', () => {
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).toThrow(/not found/);
});
});
@@ -266,7 +315,7 @@ describe('PluginsService error log', () => {
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
it('lists and clears a plugin error log', () => {
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
const svc = new PluginsService();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
svc.clearErrors('p');
expect(svc.errors('p')).toEqual([]);
@@ -3,9 +3,9 @@
* the code/data path resolution (both the env-override and default branches).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS } from '../../../src/nest/plugins/protocol/envelope';
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS, HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope';
import path from 'node:path';
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs, pluginRealCodeDir, ensurePluginModuleType } from '../../../src/nest/plugins/paths';
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDataDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs, pluginRealCodeDir, ensurePluginModuleType } from '../../../src/nest/plugins/paths';
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
@@ -21,9 +21,19 @@ describe('envelope helpers', () => {
expect(isKnownPermission('')).toBe(false);
});
it('every known method maps to a permission', () => {
// The compile side of this is `as const satisfies Record<KnownMethod, KnownPermission>`
// on METHOD_PERMISSION. These are the runtime mirror, and they also cover what the type
// system cannot: isKnownPermission's http:outbound: prefix branch.
it('every known method maps to a permission the host actually knows', () => {
for (const m of KNOWN_METHODS) {
expect(METHOD_PERMISSION[m]).toBeTruthy();
const perm = METHOD_PERMISSION[m];
expect(isKnownPermission(perm), `${m} -> ${perm}`).toBe(true);
}
});
it('every hook permission is a known permission', () => {
for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) {
expect(isKnownPermission(perm), `${hook} -> ${perm}`).toBe(true);
}
});
});
@@ -91,3 +101,44 @@ describe('paths', () => {
}
});
});
/**
* The id these helpers get comes off a route parameter — POST
* /api/admin/plugins/:id/uninstall walks it into pluginCodeDir and hands the result
* to a recursive remove. Express decodes %2F only AFTER routing, so a traversal
* still matches the route and arrives here decoded.
*/
describe('plugin id containment', () => {
const traversals = [
'../../uploads',
'../..',
'..',
'.',
'a/b',
'a\b',
'C:\Windows',
'.hidden',
'',
];
it('refuses any id that could point outside its parent', () => {
for (const id of traversals) {
expect(() => pluginCodeDir(id), id).toThrow(/invalid plugin id/);
expect(() => pluginDataDir(id), id).toThrow(/invalid plugin id/);
}
});
it('still accepts the ids plugins actually use', () => {
for (const id of ['x', 'a1', '001', 'flight-tracker', 'Bad-Id', 'trip.todos', 'koffi_and_friends']) {
expect(path.basename(pluginCodeDir(id))).toBe(id);
expect(path.basename(pluginDataDir(id))).toBe(id);
}
});
it('keeps every accepted id inside the plugin roots', () => {
for (const id of ['x', 'flight-tracker', 'a.b', 'a-b_c']) {
expect(pluginCodeDir(id).startsWith(pluginsCodeRoot() + path.sep)).toBe(true);
expect(pluginDataDir(id).startsWith(pluginsDataRoot() + path.sep)).toBe(true);
}
});
});
@@ -10,7 +10,9 @@
*/
import { describe, it, expect } from 'vitest';
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { db } from '../../../src/db/database';
function makeSupervisor(): PluginSupervisor {
// createRpcHost is never called on the providersOf path (no spawn).
@@ -50,6 +52,42 @@ describe('providersOf enforces the hook:* grant', () => {
expect(s.providersOf('journalEntryProvider')).toEqual(['journal']);
});
it('maps mapLayerProvider to hook:map-layer-provider (not the marker grant)', () => {
const s = makeSupervisor();
put(s, 'layers', 'active', ['mapLayerProvider'], ['hook:map-layer-provider']);
// The marker grant must not bleed into the layer hook — they are separate consents.
put(s, 'markersOnly', 'active', ['mapLayerProvider'], ['hook:map-marker-provider']);
expect(s.providersOf('mapLayerProvider')).toEqual(['layers']);
});
it('maps routeProvider to hook:route-provider', () => {
const s = makeSupervisor();
put(s, 'ev', 'active', ['routeProvider'], ['hook:route-provider']);
put(s, 'ungranted', 'active', ['routeProvider'], ['hook:map-layer-provider']); // wrong grant
expect(s.providersOf('routeProvider')).toEqual(['ev']);
});
it('maps dayScheduleProvider to hook:day-schedule-provider', () => {
const s = makeSupervisor();
put(s, 'times', 'active', ['dayScheduleProvider'], ['hook:day-schedule-provider']);
put(s, 'ungranted', 'active', ['dayScheduleProvider'], ['hook:route-provider']); // wrong grant
expect(s.providersOf('dayScheduleProvider')).toEqual(['times']);
});
it('maps dayTintProvider to hook:day-tint-provider', () => {
const s = makeSupervisor();
put(s, 'segments', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
put(s, 'ungranted', 'active', ['dayTintProvider'], ['hook:day-schedule-provider']); // wrong grant
expect(s.providersOf('dayTintProvider')).toEqual(['segments']);
});
it('returns tint providers in insertion order — the controller lets the first win a contested day', () => {
const s = makeSupervisor();
put(s, 'first', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
put(s, 'second', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
expect(s.providersOf('dayTintProvider')).toEqual(['first', 'second']);
});
it('maps notificationChannel to hook:notification-channel', () => {
const s = makeSupervisor();
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
@@ -63,7 +101,7 @@ describe('providersOf enforces the hook:* grant', () => {
describe('runtime.invokeHook defense-in-depth', () => {
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
const rt = new PluginRuntimeService();
const rt = createPluginRuntime(new DatabaseService(db));
// one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok']
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });
@@ -0,0 +1,77 @@
/**
* The coverage ledger: the anchor for the whole decorator rollout.
*
* Every wire method is owned by exactly one decorated *.rpc.ts class in the registry.
* Both sets are derived by construction, so no list is maintained by hand and the test
* cannot drift.
*
* The rollout is finished, so the legacy set is empty and the ledger's job flips from
* "how far along is the move" to "nothing fell out of it": a method deleted without a
* replacement, or a new *.rpc.ts class that no one listed in allRpcControllers().
*/
import { describe, it, expect } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import {
KNOWN_METHODS,
KNOWN_PERMISSIONS,
UNCONDITIONAL_METHODS,
} from '../../../src/nest/plugins/protocol/envelope';
import { allRpcControllers, makeDeps } from '../../helpers/rpc-host-deps';
/** Reads the router's private dispatch map. That map IS the thing under test. */
const boundMethods = (host: PluginRpcHost): Set<string> =>
new Set((host as unknown as { methods: Map<string, unknown> }).methods.keys());
describe('plugin RPC coverage ledger', () => {
const registry = createTestPluginRegistry(allRpcControllers());
// Every permission granted, so the router registers everything it can.
const host = new PluginRpcHost('probe', new Set<string>(KNOWN_PERMISSIONS), makeDeps(), registry);
const all = boundMethods(host);
const fromRegistry = registry.methodNames();
const vocabulary = new Set<string>([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS]);
it('RPCLEDGER-001 every bound method comes from the registry, none from anywhere else', () => {
expect([...all].filter((m) => !fromRegistry.has(m))).toEqual([]);
});
it('RPCLEDGER-002 every registered method is one the wire protocol knows', () => {
expect([...all].filter((m) => !vocabulary.has(m))).toEqual([]);
});
it('RPCLEDGER-003 no method in the vocabulary is orphaned', () => {
expect([...vocabulary].filter((m) => !all.has(m))).toEqual([]);
});
it('RPCLEDGER-004 the registry covers the vocabulary exactly', () => {
expect(all).toEqual(vocabulary);
expect(fromRegistry.size).toBe(KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length);
});
it('RPCLEDGER-005 the surface is the 113 methods the protocol declares', () => {
// Pinned rather than derived: a method DELETED from KNOWN_METHODS together with
// its handler would keep every test above green, and this is the line that makes
// that show up in the diff.
expect(KNOWN_METHODS.length).toBe(110);
expect(UNCONDITIONAL_METHODS.length).toBe(3);
expect(fromRegistry.size).toBe(113);
});
it('RPCLEDGER-006 boot-time total coverage is armed in production', () => {
// The rollout's closing move: the registry service now FAILS APP BOOT when a
// KNOWN_METHOD has no decorated owner, instead of letting it surface as a runtime
// PERMISSION_DENIED nobody can explain.
const options = (new PluginRpcRegistryService({} as never, {} as never) as unknown as {
options: { requireTotalCoverage?: boolean };
}).options;
expect(options.requireTotalCoverage).toBe(true);
});
it('RPCLEDGER-007 a registry missing a method fails validate() rather than booting', () => {
// Drops TagsRpc, a method owner. Dropping the last entry would drop PluginHooks
// instead, and the failure would be about hooks rather than methods.
const short = allRpcControllers().slice(1);
expect(() => createTestPluginRegistry(short, { requireTotalCoverage: true })).toThrow(/has no handler/);
});
});
@@ -0,0 +1,290 @@
/**
* The defensive branches every migrated handler inherited from the router: the
* `?? ''` fallbacks, the `!= null` ternaries, the "service said no" paths and the
* error translations.
*
* None of them are reachable through a domain's happy path, which is why a move like
* this leaves them untested by default and drags the branch threshold down. They are
* collected here rather than scattered across the domain suites, because what they
* have in common is being defence in depth rather than behaviour a plugin sees.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { TagsRpc } from '../../../src/nest/tags/tags.rpc';
import { DayNotesRpc } from '../../../src/nest/day-notes/day-notes.rpc';
import { PlacesRpc } from '../../../src/nest/places/places.rpc';
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
import { FilesRpc } from '../../../src/nest/files/files.rpc';
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc';
import { ReservationsRpc } from '../../../src/nest/reservations/reservations.rpc';
import { DaysRpc } from '../../../src/nest/days/days.rpc';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { schemaMessage } from '../../../src/nest/plugins/host/rpc-params';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const err = (r: unknown) => (r as RpcError).error;
const ALL = new Set([
'db:read:tags', 'db:write:tags', 'db:write:daynotes', 'db:write:places', 'db:write:packing',
'db:write:files', 'db:write:trips', 'db:create:trips', 'db:write:accommodations', 'db:write:reservations',
'db:write:days', 'db:write:itinerary',
]);
function guardsFor() {
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as DatabaseService;
return {
db,
guards: new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
),
};
}
const realtime = () => ({ broadcast: vi.fn() }) as never;
describe('optional fields fall back rather than reaching the service as undefined', () => {
it('FALLBACK-001 a tag update without name or colour forwards undefined for both', async () => {
const { guards } = guardsFor();
const update = vi.fn(() => ({ id: 1 }));
const tags = { getByIdAndUser: vi.fn(() => ({ id: 1 })), update, list: vi.fn(() => []), create: vi.fn(), remove: vi.fn() } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new TagsRpc(tags)]));
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 42, color: 42 } }), 42);
expect(update).toHaveBeenCalledWith(1, undefined, undefined);
});
it('FALLBACK-002 a day note without a text falls back to an empty string', async () => {
const { guards } = guardsFor();
const create = vi.fn(() => ({ id: 1 }));
const notes = { dayExists: vi.fn(() => true), create, getNote: vi.fn(() => ({})), update: vi.fn(), remove: vi.fn(), list: vi.fn(() => []) } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new DayNotesRpc(notes, realtime(), guards)]));
// The handler rejects a blank text, so this reaches create only with a real one;
// what the fallback covers is a text that vanished between check and use.
await host.dispatch(req('daynotes.create', { tripId: 1, dayId: 3, input: { text: 'note', time: '09:00' } }), 42);
expect(create).toHaveBeenCalledWith(3, 1, 'note', '09:00', undefined, undefined);
});
it('FALLBACK-003 a file link with no targets stores three nulls', async () => {
const { guards, db } = guardsFor();
const createFileLink = vi.fn(() => []);
const files = { getFileById: vi.fn(() => ({})), findForeignLinkTarget: vi.fn(() => null), createFileLink } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new FilesRpc(files, realtime(), db, guards, {} as never)]));
await host.dispatch(req('files.createLink', { tripId: 1, fileId: 2, opts: {} }), 42);
expect(createFileLink).toHaveBeenCalledWith(2, { reservation_id: null, assignment_id: null, place_id: null });
});
it('FALLBACK-004 a file update tells an omitted link from one cleared with null', async () => {
const { guards, db } = guardsFor();
const updateFile = vi.fn((id: number) => ({ id }));
const files = { getFileById: vi.fn(() => ({})), findForeignLinkTarget: vi.fn(() => null), updateFile } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new FilesRpc(files, realtime(), db, guards, {} as never)]));
await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: { reservation_id: null, place_id: 7 } }), 42);
expect(updateFile).toHaveBeenLastCalledWith(2, expect.anything(), expect.objectContaining({ reservation_id: null, place_id: '7' }));
});
it('FALLBACK-005 a bag colour that is not a string is dropped', async () => {
const { guards } = guardsFor();
const createBag = vi.fn(() => ({ id: 80 }));
const packing = { createBag } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PackingRpc(packing, realtime(), guards)]));
await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag' } }), 42);
expect(createBag).toHaveBeenCalledWith('1', { name: 'Bag', color: undefined });
});
});
describe('a service that says no becomes a refusal, not a crash', () => {
it('FALLBACK-006 a place that will not delete is refused', async () => {
const { guards } = guardsFor();
// remove is async in production; a Promise-returning double pins the fix that
// awaits it — an un-awaited call compares a Promise (always truthy) against
// falsy and never refuses.
const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => Promise.resolve(false)), create: vi.fn(), update: vi.fn(), linkedExpenseIds: vi.fn(() => []) } as never;
const journey = { onPlaceDeleted: vi.fn(), onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn() } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PlacesRpc(places, journey, realtime(), guards)]));
expect(err(await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)).message).toBe('no place 7 on trip 1');
});
it('FALLBACK-007 a bag that will not update or delete is refused', async () => {
const { guards } = guardsFor();
const packing = { updateBag: vi.fn(() => null), deleteBag: vi.fn(() => false), setBagMembers: vi.fn(() => null) } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PackingRpc(packing, realtime(), guards)]));
expect(err(await host.dispatch(req('packing.updateBag', { tripId: 1, bagId: 80, input: {} }), 42)).message).toBe('no packing bag 80 on trip 1');
expect(err(await host.dispatch(req('packing.deleteBag', { tripId: 1, bagId: 80 }), 42)).message).toBe('no packing bag 80 on trip 1');
expect(err(await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [] }), 42)).message).toBe('no packing bag 80 on trip 1');
});
it('FALLBACK-008 an accommodation whose refs do not validate is refused with the validator message', async () => {
const { guards } = guardsFor();
const accommodations = {
validateAccommodationRefs: vi.fn(() => [{ message: 'day 9 is not on this trip' }]),
getAccommodation: vi.fn(() => ({ id: 11 })),
createAccommodation: vi.fn(),
updateAccommodation: vi.fn(),
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new AccommodationsRpc(accommodations, realtime(), guards)]));
expect(err(await host.dispatch(req('accommodations.update', { tripId: 1, accommodationId: 11, input: { start_day_id: 9 } }), 42)).message)
.toBe('day 9 is not on this trip');
});
});
describe('service errors are translated into the RPC taxonomy', () => {
function tripsHost(updateThrows?: Error, createThrows?: Error) {
const { guards, db } = guardsFor();
const trips = {
list: vi.fn(() => []),
updateTrip: vi.fn(() => { if (updateThrows) throw updateThrows; return { updatedTrip: { id: 1 } }; }),
create: vi.fn(() => { if (createThrows) throw createThrows; return { trip: { id: 99 } }; }),
removeMember: vi.fn(),
} as never;
const rpc = new TripsRpc(trips, {} as never, {} as never, {} as never, db, realtime(), guards, {} as never, {} as never);
return new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([rpc]));
}
it('FALLBACK-009 a trip update maps ValidationError and NotFoundError separately', async () => {
const invalid = tripsHost(new ValidationError('end before start'));
expect(err(await invalid.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'BAD_PARAMS', message: 'end before start' });
const missing = tripsHost(new NotFoundError('trip is gone'));
expect(err(await missing.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'trip is gone' });
});
it('FALLBACK-010 an unknown trip failure keeps its own taxonomy', async () => {
const boom = tripsHost(new Error('disk on fire'));
expect(err(await boom.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'HOST_ERROR', message: 'disk on fire' });
});
it('FALLBACK-011 trips.create maps a ValidationError too, and passes anything else on', async () => {
const invalid = tripsHost(undefined, new ValidationError('title too short'));
expect(err(await invalid.dispatch(req('trips.create', { input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'BAD_PARAMS', message: 'title too short' });
const boom = tripsHost(undefined, new Error('db locked'));
expect(err(await boom.dispatch(req('trips.create', { input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'HOST_ERROR' });
});
it('FALLBACK-012 a reservation delete without a type still notifies with an empty one', async () => {
const { guards } = guardsFor();
const notifyBookingChange = vi.fn();
const reservations = {
remove: vi.fn(() => ({ deleted: { title: 'Hotel', type: null, accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null })),
notifyBookingChange,
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new ReservationsRpc(reservations, realtime(), guards)]));
await host.dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42);
expect(notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Hotel', '');
});
it('FALLBACK-013 a reservation update falls back to the stored type when none is sent', async () => {
const { guards } = guardsFor();
const notifyBookingChange = vi.fn();
const reservations = {
getReservation: vi.fn(() => ({ id: 5, title: 'Hotel', type: 'lodging' })),
update: vi.fn(() => ({ reservation: { id: 5 }, accommodationChanged: false })),
syncBudgetOnUpdate: vi.fn(),
notifyBookingChange,
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new ReservationsRpc(reservations, realtime(), guards)]));
await host.dispatch(req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'Renamed', type: 'lodging' } }), 42);
expect(notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Renamed', 'lodging');
});
});
describe('schemaMessage is the one copy of a fallback that used to exist twenty times', () => {
it('FALLBACK-014 it reports the first issue when there is one', () => {
expect(schemaMessage({ issues: [{ message: 'name is required' }, { message: 'second' }] })).toBe('name is required');
});
it('FALLBACK-015 it falls back when a schema reports no issues at all', () => {
// Not reachable through Zod, which always attaches at least one issue on failure.
// That is exactly why this lived in twenty handlers as twenty branches no test
// could ever enter; here it is one branch and one test.
expect(schemaMessage({ issues: [] })).toBe('bad input');
expect(schemaMessage({})).toBe('bad input');
expect(schemaMessage({ issues: [{}] })).toBe('bad input');
});
});
/**
* Every schema-validated method whose schema actually rejects something, with such a
* payload. These are the `if (!parsed.success)` branches: each handler has one, and
* without a case per method half of them never run.
*
* The three *update* schemas (places, accommodations, reservations) are missing on
* purpose. They are deliberately permissive, because the migration's rule is parity
* over tightening: a stricter schema would turn requests that work today into 400s.
* Their reject branch is therefore defence in depth that no payload reaches, and
* inventing one would test the test rather than the code.
*/
const SCHEMA_REJECTS: Array<[string, Record<string, unknown>]> = [
['places.create', { tripId: 1, input: { name: 42 } }],
['days.create', { tripId: 1, input: { date: 42 } }],
['days.update', { tripId: 1, dayId: 3, input: { notes: 42 } }],
['packing.create', { tripId: 1, input: { name: 42 } }],
['packing.update', { tripId: 1, itemId: 70, input: { name: 42 } }],
['trips.update', { tripId: 1, input: { title: 42 } }],
['trips.create', { input: { title: 42 } }],
['accommodations.create', { tripId: 1, input: { place_id: 'nope' } }],
['reservations.create', { tripId: 1, input: { title: 42, type: 'lodging' } }],
];
describe('every schema-validated method rejects a payload its schema refuses', () => {
function everything() {
const { guards, db } = guardsFor();
const anything = new Proxy({}, { get: () => vi.fn(() => ({ id: 1, trip: { id: 1 }, updatedTrip: { id: 1 }, reservation: { id: 1 } })) }) as never;
const registry = createTestPluginRegistry([
new PlacesRpc(anything, anything, realtime(), guards),
new DaysRpc(anything, realtime(), guards),
new PackingRpc(anything, realtime(), guards),
new TripsRpc(anything, anything, anything, anything, db, realtime(), guards, anything, anything),
new AccommodationsRpc(anything, realtime(), guards),
new ReservationsRpc(anything, realtime(), guards),
]);
return new PluginRpcHost('p', ALL, makeDeps(), registry);
}
for (const [method, params] of SCHEMA_REJECTS) {
it(`FALLBACK-schema-${method}`, async () => {
const res = await everything().dispatch(req(method, params), 42);
expect(err(res).code).toBe('BAD_PARAMS');
});
}
it('FALLBACK-016 a reservation with malformed endpoints is rejected on update too', async () => {
const res = await everything().dispatch(
req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'lodging', endpoints: [{ junk: 1 }] } }), 42,
);
expect(err(res).code).toBe('BAD_PARAMS');
});
it('FALLBACK-017 an absent vacay note and an absent tag colour both pass undefined', async () => {
const { guards } = guardsFor();
const toggleCompanyHoliday = vi.fn(() => ({}));
const vacay = { getActivePlanId: vi.fn(() => 1), toggleCompanyHoliday, toggleEntry: vi.fn(), getPlanData: vi.fn() } as never;
const update = vi.fn(() => ({ id: 1 }));
const tags = { getByIdAndUser: vi.fn(() => ({ id: 1 })), update } as never;
const host = new PluginRpcHost(
'p',
new Set([...ALL, 'db:write:vacay']),
makeDeps(),
createTestPluginRegistry([new VacayRpc(vacay, guards), new TagsRpc(tags)]),
);
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01', note: 42 }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 'kept' } }), 42);
expect(update).toHaveBeenCalledWith(1, 'kept', undefined);
});
});
@@ -0,0 +1,138 @@
/**
* The supervisor tags every host-bound request with `_inv`, resolves the acting user
* from it BEFORE dispatch, and then passes the request on untouched. That made `_inv`
* visible to every handler and, in effect, a reserved param name.
*
* dispatch() now strips it. These tests prove the strip is invisible: the same result,
* the same audit row, and the acting user still resolved from the host side.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import type { RpcRequest, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
const req = (method: string, params: Record<string, unknown>): RpcRequest => ({ k: 'req', id: 'x', method, params });
/**
* db.* is the cheapest handler to watch. The only service it needs is the per-user
* settings store behind `settings.get`, which none of these cases dispatch, so the stub
* carries just that read, typed off the real signature.
*/
const dbRegistry = () => {
const settings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
return createTestPluginRegistry([new DbRpc(settings as PluginUserSettingsService)]);
};
/** trips.getById lives on the decorators now, so the audit cases bind it through them. */
const tripsRegistry = () => {
const db = {
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined),
prepare: () => ({ get: () => ({ id: 1, title: 'Japan' }), all: () => [] }),
} as unknown as DatabaseService;
const guards = new PluginGuards(db, {} as never, {} as never);
return createTestPluginRegistry([
new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards, {} as never, {} as never),
]);
};
describe('dispatch strips the supervisor _inv marker', () => {
it('RPCINV-001 the handler never sees _inv', async () => {
const deps = makeDeps();
const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry());
await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [], _inv: 'req-7' }));
expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []);
});
it('RPCINV-002 the result is identical with and without the marker', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps(), dbRegistry());
const withMarker = (await host.dispatch(req('db.query', { sql: 'SELECT 1', _inv: 'req-7' }))) as RpcResponse;
const without = (await host.dispatch(req('db.query', { sql: 'SELECT 1' }))) as RpcResponse;
expect(withMarker.ok).toBe(true);
expect(withMarker.result).toEqual(without.result);
});
it('RPCINV-003 the audit row is unchanged, because auditResource reads named fields only', async () => {
const deps = makeDeps();
const audit = vi.fn();
const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit }, tripsRegistry());
await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }), 42);
expect(audit).toHaveBeenCalledWith({
pluginId: 'p',
actingUserId: 42,
method: 'trips.getById',
resource: 'trip:1',
code: 'ok',
});
});
it('RPCINV-004 the acting user still comes from the host, not from the params', async () => {
const deps = makeDeps();
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps, tripsRegistry());
// No acting user bound: the trip read is refused even though _inv is present.
const res = await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }));
expect(res.ok).toBe(false);
expect((res as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('RPCINV-005 params without the marker are passed through untouched', async () => {
const deps = makeDeps();
const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry());
await host.dispatch(req('db.exec', { sql: 'INSERT INTO t VALUES (1)', args: [1] }));
expect(deps.data.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)', [1]);
});
it('RPCINV-006 a handler reading the whole payload object no longer finds _inv in it', async () => {
// packing.setBagMembers is the one handler that reads the params object as a
// whole (asPayload(p).userIds) rather than a named field off it, which makes it
// the case where a stray _inv could actually change behaviour.
const setBagMembers = vi.fn(() => ({ bagId: 80, members: [3] }));
const packing = { setBagMembers } as unknown as PackingService;
const guards = {
requireActor: () => 42,
requireTripEdit: () => undefined,
} as unknown as PluginGuards;
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService;
const host = new PluginRpcHost(
'p',
new Set(['db:write:packing']),
makeDeps(),
createTestPluginRegistry([new PackingRpc(packing, realtime, guards)]),
);
await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [3], _inv: 'req-7' }), 42);
expect(setBagMembers).toHaveBeenCalledWith('1', '80', [3]);
});
/**
* The `_inv` check runs before handle()'s try/catch, so a params value that is not
* an object used to throw a TypeError out of dispatch instead of coming back as an
* error envelope. The supervisor discards the promise dispatch lives on, which made
* that an unhandledRejection — process exit, from one malformed envelope.
*/
it('RPCINV-007 params that are not an object come back as an envelope, not a rejection', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps(), dbRegistry());
for (const params of [5, 'text', true] as unknown as Record<string, unknown>[]) {
const res = await host.dispatch({ k: 'req', id: 'x', method: 'db.query', params });
expect(res).toMatchObject({ k: 'res', id: 'x', ok: false });
}
});
it('RPCINV-008 null and undefined params still behave as an empty object', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps(), dbRegistry());
const nulled = await host.dispatch({ k: 'req', id: 'x', method: 'db.query', params: null as unknown as Record<string, unknown> });
// The type says params is required; the IPC channel does not, so a child can
// leave it off entirely.
const missing = await host.dispatch({ k: 'req', id: 'x', method: 'db.query', params: undefined as unknown as Record<string, unknown> });
expect(nulled).toEqual(missing);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
/**
* The four plugin RPC decorators. The compile-time half of their contract is
* enforced by tsc and cannot be asserted here; what these tests pin is what each
* decorator RECORDS, because that is what validate() and bindInto() later read.
*/
import { describe, it, expect } from 'vitest';
import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants';
import {
PluginController,
PluginHook,
PluginMethod,
PluginOpenMethod,
} from '../../../../src/nest/plugins/host/rpc-kit/decorators';
import { getEntry, isPluginController, type ClassRef } from '../../../../src/nest/plugins/host/rpc-kit/metadata';
const entryOf = (ctor: unknown, methodName: string) => getEntry(ctor as ClassRef, methodName);
describe('rpc-kit decorators', () => {
it('RPCKIT-DEC-001 @PluginController marks the class', () => {
@PluginController()
class Sample {}
expect(isPluginController(Sample)).toBe(true);
});
it('RPCKIT-DEC-002 @PluginController implies @Injectable', () => {
@PluginController()
class Sample {}
// What Injectable() writes. Nest resolves the class as a provider off this.
expect(Reflect.getMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBeUndefined();
expect(Reflect.hasMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBe(true);
});
it('RPCKIT-DEC-003 @PluginMethod records the wire method and its permission', () => {
@PluginController()
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(entryOf(Sample, 'list')).toEqual({
kind: 'method',
methodName: 'list',
method: 'tags.list',
permission: 'db:read:tags',
});
});
it('RPCKIT-DEC-004 the class method name and the wire method are independent', () => {
@PluginController()
class Sample {
@PluginMethod('tags.delete', { permission: 'db:write:tags' })
removeTheTag() {
return { deleted: true };
}
}
const entry = entryOf(Sample, 'removeTheTag');
expect(entry).toMatchObject({ methodName: 'removeTheTag', method: 'tags.delete' });
});
it('RPCKIT-DEC-005 @PluginOpenMethod records no permission at all', () => {
@PluginController()
class Sample {
@PluginOpenMethod('settings.get')
get() {
return { value: undefined };
}
}
expect(entryOf(Sample, 'get')).toEqual({ kind: 'open', methodName: 'get', method: 'settings.get' });
});
it('RPCKIT-DEC-006 @PluginHook records hook, fn and permission', () => {
@PluginController()
class Sample {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({
kind: 'hook',
hook: 'warningProvider',
fn: 'getWarnings',
permission: 'hook:trip-warning-provider',
});
});
it('RPCKIT-DEC-007 @PluginHook defaults the budget to 5000ms', () => {
@PluginController()
class Sample {
@PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers' })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 5000 });
});
it('RPCKIT-DEC-008 an explicit timeout wins over the default', () => {
@PluginController()
class Sample {
@PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 20_000 });
});
it('RPCKIT-DEC-009 several decorated methods on one class are all recorded', () => {
@PluginController()
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
@PluginMethod('tags.create', { permission: 'db:write:tags' })
create() {
return {};
}
undecorated() {
return null;
}
}
expect(entryOf(Sample, 'list')).toMatchObject({ method: 'tags.list' });
expect(entryOf(Sample, 'create')).toMatchObject({ method: 'tags.create' });
expect(entryOf(Sample, 'undecorated')).toBeUndefined();
});
it('RPCKIT-DEC-010 a method decorator alone does not mark the class', () => {
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
// The entry is recorded, but register() will still reject the class. That split
// is deliberate: listing the class in providers: [] must stay an explicit opt-in.
expect(entryOf(Sample, 'list')).toBeDefined();
expect(isPluginController(Sample)).toBe(false);
});
});
@@ -0,0 +1,94 @@
/**
* The metadata store under the plugin RPC decorators. It is a WeakMap/WeakSet keyed
* by constructor rather than reflect-metadata, so these tests run without a Nest app
* and without any polyfill import order to get right.
*/
import { describe, it, expect } from 'vitest';
import {
addEntry,
getEntry,
isPluginController,
markController,
type ClassRef,
} from '../../../../src/nest/plugins/host/rpc-kit/metadata';
import type { PluginRpcEntry } from '../../../../src/nest/plugins/host/rpc-kit/types';
const methodEntry = (methodName: string, method: 'tags.list' | 'tags.create' = 'tags.list'): PluginRpcEntry => ({
kind: 'method',
methodName,
method,
permission: method === 'tags.list' ? 'db:read:tags' : 'db:write:tags',
});
describe('rpc-kit metadata store', () => {
it('RPCKIT-META-001 an unmarked class is not a plugin controller', () => {
class Plain {}
expect(isPluginController(Plain)).toBe(false);
});
it('RPCKIT-META-002 markController makes the class recognisable', () => {
class Marked {}
markController(Marked as unknown as ClassRef);
expect(isPluginController(Marked)).toBe(true);
});
it('RPCKIT-META-003 a non-function is never a controller', () => {
expect(isPluginController({})).toBe(false);
expect(isPluginController(undefined)).toBe(false);
expect(isPluginController('Marked')).toBe(false);
});
it('RPCKIT-META-004 an entry is retrievable by its method name', () => {
class Holder {}
const entry = methodEntry('list');
addEntry(Holder as unknown as ClassRef, entry);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-005 an unknown method name resolves to undefined', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
expect(getEntry(Holder as unknown as ClassRef, 'missing')).toBeUndefined();
});
it('RPCKIT-META-006 a second entry for the same name replaces the first', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
const second = methodEntry('list', 'tags.create');
addEntry(Holder as unknown as ClassRef, second);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(second);
});
it('RPCKIT-META-007 entries are found on a subclass through the prototype chain', () => {
class Base {}
class Child extends Base {}
const entry = methodEntry('list');
addEntry(Base as unknown as ClassRef, entry);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-008 a subclass entry wins over the inherited one', () => {
class Base {}
class Child extends Base {}
addEntry(Base as unknown as ClassRef, methodEntry('list'));
const own = methodEntry('list', 'tags.create');
addEntry(Child as unknown as ClassRef, own);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(own);
});
it('RPCKIT-META-009 walking the chain stops cleanly at the root', () => {
class Lonely {}
expect(getEntry(Lonely as unknown as ClassRef, 'anything')).toBeUndefined();
});
it('RPCKIT-META-010 two classes keep separate entry maps', () => {
class A {}
class B {}
const a = methodEntry('list');
const b = methodEntry('list', 'tags.create');
addEntry(A as unknown as ClassRef, a);
addEntry(B as unknown as ClassRef, b);
expect(getEntry(A as unknown as ClassRef, 'list')).toBe(a);
expect(getEntry(B as unknown as ClassRef, 'list')).toBe(b);
});
});
@@ -0,0 +1,109 @@
/**
* The DI-discovered registry. It scans providers only, dedupes by instance, and
* validates at boot so a wrong decorator argument fails the app start rather than a
* single plugin activation.
*/
import { describe, it, expect, vi } from 'vitest';
import type { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { PluginRpcRegistryService } from '../../../../src/nest/plugins/host/rpc-kit/registry.service';
import { PluginController, PluginMethod } from '../../../../src/nest/plugins/host/rpc-kit/decorators';
@PluginController()
class TagsRpc {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
@PluginController()
class BadRpc {
@PluginMethod('tags.create', { permission: 'db:read:tags' as 'db:write:tags' })
create() {
return {};
}
}
class PlainProvider {
helper() {
return 1;
}
}
/** Mimics Nest's provider wrappers, including the same instance listed twice. */
function serviceFor(instances: object[]): PluginRpcRegistryService {
const discovery = {
getProviders: () => instances.map((instance) => ({ instance, metatype: instance.constructor })),
} as unknown as DiscoveryService;
const scanner = {
getAllMethodNames: (proto: object) =>
Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'),
} as unknown as MetadataScanner;
return new PluginRpcRegistryService(discovery, scanner);
}
describe('PluginRpcRegistryService', () => {
it('RPCKIT-SVC-001 registers the marked providers at boot', () => {
const service = serviceFor([new TagsRpc(), new PlainProvider()]);
service.scanProviders();
expect(service.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-SVC-002 the same instance wrapped twice is registered once', () => {
const instance = new TagsRpc();
const service = serviceFor([instance, instance]);
service.scanProviders();
// Without the dedupe this would be two entries and validate() would report
// tags.list as double-owned.
expect(service.list()).toHaveLength(1);
});
it('RPCKIT-SVC-003 unmarked providers are skipped rather than rejected', () => {
const service = serviceFor([new PlainProvider()]);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-004 a null instance is skipped', () => {
const discovery = {
getProviders: () => [{ instance: null, metatype: TagsRpc }, { instance: undefined, metatype: TagsRpc }],
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
const service = new PluginRpcRegistryService(discovery, scanner);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-005 a wrong decorator argument fails the boot, not the first plugin call', () => {
const service = serviceFor([new BadRpc()]);
expect(() => service.onModuleInit()).toThrow(
/method "tags.create" \(BadRpc.create\) declares "db:read:tags" but METHOD_PERMISSION says "db:write:tags"/,
);
});
it('RPCKIT-SVC-006 boot fails when the surface is incomplete', () => {
// The rollout is finished, so a decorated class that no module registers is no
// longer a silent PERMISSION_DENIED at runtime: the app refuses to start, and the
// message names the method that lost its owner.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no handler/);
});
it('RPCKIT-SVC-008 a hook nobody consumes fails boot too, so no grant is dead', () => {
// A hook:* permission on the consent screen with nothing behind it looks exactly
// like a live one to the user granting it.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no host-side consumer/);
});
it('RPCKIT-SVC-007 only providers are scanned, never controllers', () => {
const getControllers = vi.fn(() => []);
const discovery = {
getProviders: () => [],
getControllers,
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
new PluginRpcRegistryService(discovery, scanner).scanProviders();
expect(getControllers).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,403 @@
/**
* The registry is where a decorator argument is re-checked against
* protocol/envelope.ts at runtime, and where the granted subset is bound into the
* per-plugin dispatch map. Types evaporate under an `as` cast, so every compile-time
* guarantee has a runtime twin here, and each one gets a test.
*/
import { describe, it, expect, vi } from 'vitest';
import { Controller } from '@nestjs/common';
import {
PluginController,
PluginHook,
PluginMethod,
PluginOpenMethod,
} from '../../../../src/nest/plugins/host/rpc-kit/decorators';
import { PluginRpcRegistry } from '../../../../src/nest/plugins/host/rpc-kit/registry';
import { createTestPluginRegistry } from '../../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../../src/nest/plugins/host/rpc-kit/types';
const ctxFor = (actingUserId: number | undefined): PluginRpcContext => ({
pluginId: 'p',
actingUserId,
data: {} as PluginRpcContext['data'],
// No case below dispatches to a peer plugin, but the peers are a real collaborator
// on the context, so they get a stub that fails loudly rather than an empty object
// whose methods would be undefined the moment a handler reached for one.
plugins: {
call: () => Promise.reject(new Error('ctxFor: no peer plugin router in these cases')),
emit: () => {
throw new Error('ctxFor: no peer plugin router in these cases');
},
},
});
/** A well-formed controller: two tags methods with the permissions the table assigns. */
@PluginController()
class TagsRpc {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list(_params: Record<string, unknown>, ctx: PluginRpcContext) {
return { calledBy: ctx.actingUserId };
}
@PluginMethod('tags.create', { permission: 'db:write:tags' })
create(params: Record<string, unknown>, ctx: PluginRpcContext) {
return { name: params.name, by: ctx.actingUserId };
}
}
describe('PluginRpcRegistry — registration', () => {
it('RPCKIT-REG-001 an undecorated class is rejected by name', () => {
class NotAController {}
expect(() => new PluginRpcRegistry().register(new NotAController())).toThrow(
/NotAController is not decorated with @PluginController/,
);
});
it('RPCKIT-REG-002 a Nest @Controller is rejected, because @Injectable rewrites its scope metadata', () => {
@PluginController()
@Controller('things')
class HttpThing {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => new PluginRpcRegistry().register(new HttpThing())).toThrow(/is a Nest @Controller/);
});
it('RPCKIT-REG-003 only decorated methods are recorded', () => {
@PluginController()
class Mixed {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
helper() {
return 1;
}
}
const registry = createTestPluginRegistry([new Mixed()]);
expect(registry.list()).toHaveLength(1);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-004 an explicit method-name list narrows the scan', () => {
const registry = new PluginRpcRegistry();
registry.register(new TagsRpc(), ['list']);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-005 inherited decorated methods are picked up', () => {
@PluginController()
class Base {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
@PluginController()
class Child extends Base {}
const registry = createTestPluginRegistry([new Child()]);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-006 list() reports kind, permission and the owning class', () => {
const registry = createTestPluginRegistry([new TagsRpc()]);
expect(registry.list()).toEqual(
expect.arrayContaining([
{ kind: 'method', name: 'tags.list', permission: 'db:read:tags', className: 'TagsRpc', methodName: 'list' },
]),
);
});
});
describe('PluginRpcRegistry — validate', () => {
it('RPCKIT-REG-007 a well-formed controller validates', () => {
expect(() => createTestPluginRegistry([new TagsRpc()])).not.toThrow();
});
it('RPCKIT-REG-008 a permission the table does not assign is named with the expected literal', () => {
@PluginController()
class Wrong {
// The cast is the point: this is exactly what a hand-emitted consumer or an
// `as` cast would smuggle past tsc.
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/method "tags.list" \(Wrong.list\) declares "db:write:tags" but METHOD_PERMISSION says "db:read:tags"/,
);
});
it('RPCKIT-REG-008b a permission that does not exist at all is reported as unknown, too', () => {
@PluginController()
class Typo {
@PluginMethod('tags.list', { permission: 'db:read:tapps' as 'db:read:tags' })
list() {
return [];
}
}
let error: Error | undefined;
try {
createTestPluginRegistry([new Typo()]);
} catch (e) {
error = e as Error;
}
// Both checks fire: it disagrees with the table AND names a permission the host
// does not know. The second is what catches a permission invented out of thin air.
expect(error?.message).toMatch(/but METHOD_PERMISSION says "db:read:tags"/);
expect(error?.message).toMatch(
/method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/,
);
});
it('RPCKIT-REG-009 a method outside KNOWN_METHODS is named', () => {
@PluginController()
class Bogus {
@PluginMethod('tags.lst' as 'tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
/method "tags.lst" \(Bogus.list\) is not in KNOWN_METHODS/,
);
});
it('RPCKIT-REG-010 an open method outside UNCONDITIONAL_METHODS is named', () => {
@PluginController()
class Bogus {
@PluginOpenMethod('tags.list' as 'settings.get')
get() {
return {};
}
}
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
/open method "tags.list" \(Bogus.get\) is not in UNCONDITIONAL_METHODS/,
);
});
it('RPCKIT-REG-011 two owners of the same method name both classes', () => {
@PluginController()
class Second {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new TagsRpc(), new Second()])).toThrow(
/method "tags.list" is handled by TagsRpc.list and Second.list/,
);
});
it('RPCKIT-REG-012 a hook permission that disagrees with HOOK_PERMISSION is named', () => {
@PluginController()
class Wrong {
@PluginHook('warningProvider', {
permission: 'hook:route-provider' as 'hook:trip-warning-provider',
fn: 'getWarnings',
})
declare() {}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/hook "warningProvider" \(Wrong.declare\) declares "hook:route-provider" but HOOK_PERMISSION says "hook:trip-warning-provider"/,
);
});
it('RPCKIT-REG-013 a hook name that is not a HOOK_PERMISSION key is named', () => {
@PluginController()
class Wrong {
@PluginHook('warningProvidr' as 'warningProvider', {
permission: 'hook:trip-warning-provider',
fn: 'x',
})
declare() {}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/hook "warningProvidr" \(Wrong.declare\) is not a HOOK_PERMISSION key/,
);
});
it('RPCKIT-REG-014 the same (hook, fn) pair declared twice is rejected', () => {
@PluginController()
class A {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
@PluginController()
class B {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(() => createTestPluginRegistry([new A(), new B()])).toThrow(
/hook fn "warningProvider#getWarnings" is declared twice \(B.declare\)/,
);
});
it('RPCKIT-REG-015 two fns of the SAME hook are allowed — the fan-out is not uniform', () => {
@PluginController()
class Calendar {
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
name() {}
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 })
events() {}
}
expect(() => createTestPluginRegistry([new Calendar()])).not.toThrow();
});
it('RPCKIT-REG-016 every problem is reported at once, not just the first', () => {
@PluginController()
class Wrong {
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
list() {
return [];
}
@PluginOpenMethod('tags.create' as 'settings.get')
get() {
return {};
}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/METHOD_PERMISSION says "db:read:tags"; open method "tags.create"/,
);
});
it('RPCKIT-REG-017 requireTotalCoverage off tolerates an incomplete registry', () => {
expect(() => createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: false })).not.toThrow();
});
it('RPCKIT-REG-018 requireTotalCoverage on names an unhandled method and a consumerless hook', () => {
let error: Error | undefined;
try {
createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: true });
} catch (e) {
error = e as Error;
}
expect(error?.message).toMatch(/method "db.query" has no handler/);
expect(error?.message).toMatch(/method "plugins.call" has no handler/);
expect(error?.message).toMatch(/hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/);
// tags.list IS handled, so it must not be listed.
expect(error?.message).not.toMatch(/method "tags.list" has no handler/);
});
});
describe('PluginRpcRegistry — bindInto', () => {
const bind = (registry: PluginRpcRegistry, granted: string[]) => {
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
registry.bindInto(map, new Set(granted), ctxFor);
return map;
};
it('RPCKIT-REG-019 registration is authorization: an ungranted method is ABSENT, not guarded', () => {
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:read:tags']);
expect([...map.keys()]).toEqual(['tags.list']);
expect(map.has('tags.create')).toBe(false);
});
it('RPCKIT-REG-020 no grants means an empty map', () => {
expect(bind(createTestPluginRegistry([new TagsRpc()]), []).size).toBe(0);
});
it('RPCKIT-REG-021 the bound handler receives params and a context', () => {
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:write:tags']);
expect(map.get('tags.create')!({ name: 'work' }, 42)).toEqual({ name: 'work', by: 42 });
});
it('RPCKIT-REG-022 the handler keeps its `this`', () => {
@PluginController()
class Stateful {
private readonly answer = 7;
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return this.answer;
}
}
const map = bind(createTestPluginRegistry([new Stateful()]), ['db:read:tags']);
expect(map.get('tags.list')!({}, 42)).toBe(7);
});
it('RPCKIT-REG-022b a host-scoped http:outbound grant is honoured, not rejected as unknown', () => {
// The check uses isKnownPermission, not a set lookup on KNOWN_PERMISSIONS. A naive
// lookup would reject every host-scoped egress grant, because that family is
// open-ended by design.
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = bind(registry, ['db:read:tags', 'http:outbound:api.example.com']);
expect(map.has('tags.list')).toBe(true);
});
it('RPCKIT-REG-023 an open method binds with no grant at all', () => {
@PluginController()
class Open {
@PluginOpenMethod('settings.get')
get() {
return { value: undefined };
}
}
expect([...bind(createTestPluginRegistry([new Open()]), []).keys()]).toEqual(['settings.get']);
});
it('RPCKIT-REG-024 hook declarations are never bound as methods', () => {
@PluginController()
class Hooks {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(bind(createTestPluginRegistry([new Hooks()]), ['hook:trip-warning-provider']).size).toBe(0);
});
it('RPCKIT-REG-025 a collision with a name the legacy router already owns throws', () => {
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
map.set('tags.list', () => 'legacy');
expect(() => registry.bindInto(map, new Set(['db:read:tags']), ctxFor)).toThrow(
/plugin RPC method "tags.list" is registered twice \(TagsRpc.list and the legacy router\)/,
);
});
it('RPCKIT-REG-026 the context is built per dispatch, not once per bind', () => {
const makeCtx = vi.fn(ctxFor);
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
registry.bindInto(map, new Set(['db:read:tags']), makeCtx);
expect(makeCtx).not.toHaveBeenCalled();
expect(map.get('tags.list')!({}, 1)).toEqual({ calledBy: 1 });
expect(map.get('tags.list')!({}, 2)).toEqual({ calledBy: 2 });
expect(makeCtx).toHaveBeenCalledTimes(2);
});
});
describe('PluginRpcRegistry — hook contracts', () => {
@PluginController()
class Calendar {
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
name() {}
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents' })
events() {}
}
it('RPCKIT-REG-027 every declared pair is listed with its consumer', () => {
const contracts = createTestPluginRegistry([new Calendar()]).hookContracts();
expect([...contracts.keys()]).toEqual(['calendarSource#getName', 'calendarSource#getEvents']);
expect(contracts.get('calendarSource#getName')).toEqual({
hook: 'calendarSource',
fn: 'getName',
permission: 'hook:calendar-source',
timeoutMs: 3000,
consumer: 'Calendar.name',
});
});
it('RPCKIT-REG-028 hookContract returns the budget a call site should use', () => {
const registry = createTestPluginRegistry([new Calendar()]);
expect(registry.hookContract('calendarSource', 'getEvents').timeoutMs).toBe(5000);
});
it('RPCKIT-REG-029 asking for an undeclared pair throws and names it', () => {
const registry = createTestPluginRegistry([new Calendar()]);
expect(() => registry.hookContract('calendarSource', 'getNothing')).toThrow(
/no @PluginHook declaration for "calendarSource#getNothing"/,
);
});
});
+23 -5
View File
@@ -11,13 +11,15 @@ import zlib from 'node:zlib';
import { safeJoin, extractArchive, ExtractError } from '../../../src/nest/plugins/install/safe-extract';
// ── tiny archive builders ────────────────────────────────────────────────────
function tarHeader(name: string, size: number, typeflag = '0', linkname = ''): Buffer {
function tarHeader(name: string, size: number, typeflag = '0', linkname = '', rawSize?: string): Buffer {
const h = Buffer.alloc(512, 0);
h.write(name, 0);
h.write('0000644', 100);
h.write('0000000', 108);
h.write('0000000', 116);
h.write(size.toString(8).padStart(11, '0'), 124);
// rawSize writes the 12-byte field verbatim, for headers a real tar would never
// produce (negative, non-octal) — the reader has to survive those too.
h.write(rawSize !== undefined ? rawSize.padEnd(11, ' ') : size.toString(8).padStart(11, '0'), 124);
h.write('00000000000', 136);
h.write(' ', 148); // checksum placeholder = spaces
h.write(typeflag, 156);
@@ -29,12 +31,12 @@ function tarHeader(name: string, size: number, typeflag = '0', linkname = ''): B
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
return h;
}
function makeTarGz(entries: Array<{ name: string; data?: string; type?: string; link?: string }>): Buffer {
function makeTarGz(entries: Array<{ name: string; data?: string; type?: string; link?: string; rawSize?: string }>): Buffer {
const parts: Buffer[] = [];
for (const e of entries) {
const body = Buffer.from(e.data ?? '');
parts.push(tarHeader(e.name, e.type === '5' || e.type === '2' ? 0 : body.length, e.type ?? '0', e.link ?? ''));
if (e.type !== '5' && e.type !== '2') {
parts.push(tarHeader(e.name, e.type === '5' || e.type === '2' ? 0 : body.length, e.type ?? '0', e.link ?? '', e.rawSize));
if (e.rawSize === undefined && e.type !== '5' && e.type !== '2') {
parts.push(body);
const pad = (512 - (body.length % 512)) % 512;
if (pad) parts.push(Buffer.alloc(pad, 0));
@@ -130,4 +132,20 @@ describe('extractArchive', () => {
it('rejects an unsupported format', () => {
expect(() => extractArchive(Buffer.from('not an archive'), dest)).toThrow(/unsupported/);
});
// A size field is 12 ASCII bytes of octal, and parseInt honours a leading minus.
// The reader advanced by `512 + Math.ceil(size / 512) * 512`, which is zero at
// -512 and negative below it: the cursor never leaves the first header, and every
// pass appends another member until the heap is gone. Synchronous, on the main
// thread, from a 70-byte upload.
it('refuses a negative entry size instead of looping on it', () => {
const gz = makeTarGz([{ name: 'evil.txt', rawSize: '-1000' }]);
expect(() => extractArchive(gz, dest)).toThrow(ExtractError);
expect(() => extractArchive(gz, dest)).toThrow(/invalid entry size/);
});
it('refuses a size field that is not a number at all', () => {
const gz = makeTarGz([{ name: 'evil.txt', rawSize: 'not-octal' }]);
expect(() => extractArchive(gz, dest)).toThrow(/invalid entry size/);
});
});
@@ -17,13 +17,19 @@ const { testDb, dbMock } = vi.hoisted(() => {
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
import { readUserSettingsDecrypted, hasRequiredUserSettings, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
/** The host-side settings reads, over the same connection the test seeded. */
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
let uid: number;
@@ -56,7 +62,7 @@ describe('plugin settings are isolated from core and from each other', () => {
// its own blob, in its own table. The namespacing is structural, not by key naming.
const core = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(uid) as { value: string };
expect(core.value).toBe('https://core.example.com/real');
expect(readUserSettingsDecrypted('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
expect(userSettings().readAll('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
});
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', () => {
@@ -64,16 +70,16 @@ describe('plugin settings are isolated from core and from each other', () => {
declareField('b', 'token', { secret: true });
setUserConfig('b', { token: 'B-SECRET' });
expect(readUserSettingsDecrypted('a', uid)).toEqual({});
expect(readUserSettingDecrypted('a', uid, 'token')).toBeUndefined();
expect(readUserSettingDecrypted('b', uid, 'token')).toBe('B-SECRET');
expect(userSettings().readAll('a', uid)).toEqual({});
expect(userSettings().readOne('a', uid, 'token')).toBeUndefined();
expect(userSettings().readOne('b', uid, 'token')).toBe('B-SECRET');
});
it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => {
declareField('p', 'declared');
// An undeclared key that somehow reached the blob is not handed to the plugin.
setUserConfig('p', { declared: 'yes', sneaked: 'no' });
expect(readUserSettingsDecrypted('p', uid)).toEqual({ declared: 'yes' });
expect(userSettings().readAll('p', uid)).toEqual({ declared: 'yes' });
});
});
@@ -83,16 +89,16 @@ describe('settings keys cannot resolve off the prototype chain', () => {
(key) => {
declareField('evil', key, { required: true });
// The user has configured nothing at all.
expect(hasRequiredUserSettings('evil', uid)).toBe(false);
expect(readUserSettingsDecrypted('evil', uid)).toEqual({});
expect(userSettings().hasRequired('evil', uid)).toBe(false);
expect(userSettings().readAll('evil', uid)).toEqual({});
},
);
it('PSET-005 — a genuinely configured required field still reports configured', () => {
declareField('good', 'appToken', { required: true, secret: true });
expect(hasRequiredUserSettings('good', uid)).toBe(false);
expect(userSettings().hasRequired('good', uid)).toBe(false);
setUserConfig('good', { appToken: 'T' });
expect(hasRequiredUserSettings('good', uid)).toBe(true);
expect(userSettings().hasRequired('good', uid)).toBe(true);
});
it('PSET-006 — the manifest rejects such a key at install', () => {
@@ -120,7 +126,7 @@ describe('a plugin channel label is bounded by the host', () => {
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
const rt = new PluginRuntimeService();
const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn)), userSettings());
// Stand the plugin up as a granted, active notificationChannel provider.
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
@@ -17,8 +17,9 @@ function makeSupervisor() {
(s as any).spawn = spawn;
return { s, spawn, dispose };
}
// Reaches into the private `running` map so a case can assert on the live entry.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entry = (s: PluginSupervisor, id: string, status: string) => (s as any).running.get(id);
const entry = (s: PluginSupervisor, id: string): { status: string } => (s as any).running.get(id);
describe('supervisor re-activation after failure', () => {
const supers: PluginSupervisor[] = [];
@@ -310,3 +311,39 @@ describe('supervisor throttles plugin log/stderr volume (host-thread DoS guard)'
expect(sup.droppedLogs).toBe(0);
});
});
/**
* The 'message' listener is where a rejection would escape: an EventEmitter discards
* what its callback returns, onMessage is async, and the host has no
* unhandledRejection handler — so Node 22 would end the process. A plugin getting to
* kill the supervisor that contains it defeats the point of the supervisor.
*/
describe('supervisor message handling never escapes as a rejection', () => {
it('logs a failed message against the plugin and keeps running', async () => {
const onLog = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new PluginSupervisor((() => ({ dispose: vi.fn() })) as any, { onLog }, {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).onMessage = () => Promise.reject(new Error('malformed envelope'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => (s as any).handleChildMessage({ id: 'p' }, { k: 'req', id: 'x' })).not.toThrow();
await new Promise((r) => setTimeout(r, 0));
expect(onLog).toHaveBeenCalledWith('p', 'error', expect.stringContaining('malformed envelope'));
});
it('says nothing when the message is handled normally', async () => {
const onLog = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new PluginSupervisor((() => ({ dispose: vi.fn() })) as any, { onLog }, {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).onMessage = () => Promise.resolve();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).handleChildMessage({ id: 'p' }, { k: 'evt', topic: 'log' });
await new Promise((r) => setTimeout(r, 0));
expect(onLog).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
/**
* The three tenant-free plugin methods: weather, categories and exchange rates.
*
* They are grouped because what matters about them is the same property, and it is
* the one a migration could quietly lose: they carry no tenant data, so they work
* with NO acting user. Everything else in the plugin surface refuses that.
*/
import { describe, it, expect, vi } from 'vitest';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { CategoriesRpc } from '../../../src/nest/categories/categories.rpc';
import { CategoriesModule } from '../../../src/nest/categories/categories.module';
import { WeatherRpc } from '../../../src/nest/weather/weather.rpc';
import { WeatherModule } from '../../../src/nest/weather/weather.module';
import { ExchangeRatesRpc } from '../../../src/nest/budget/exchange-rates.rpc';
import { BudgetModule } from '../../../src/nest/budget/budget.module';
import type { CategoriesService } from '../../../src/nest/categories/categories.service';
import type { WeatherService } from '../../../src/nest/weather/weather.service';
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
function build() {
const categories = { list: vi.fn(() => [{ id: 1, name: 'Food' }]) } as unknown as CategoriesService & Record<string, ReturnType<typeof vi.fn>>;
const weather = { get: vi.fn(async () => ({ temp: 20 })) } as unknown as WeatherService & Record<string, ReturnType<typeof vi.fn>>;
const rates = { getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08 })) } as unknown as ExchangeRatesService & Record<string, ReturnType<typeof vi.fn>>;
const registry = createTestPluginRegistry([
new CategoriesRpc(categories),
new WeatherRpc(weather),
new ExchangeRatesRpc(rates),
]);
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), registry);
return { categories, weather, rates, host };
}
describe('tenant-free plugin methods', () => {
it('TENANTFREE-001 weather.get and categories.list work without a user', async () => {
const f = build();
expect((await f.host('weather:read').dispatch(req('weather.get', { lat: 48, lng: 11 }), undefined)).ok).toBe(true);
expect((await f.host('db:read:categories').dispatch(req('categories.list', {}), undefined)).ok).toBe(true);
});
it('TENANTFREE-002 rates.get works without a user and needs rates:read', async () => {
const f = build();
expect((await f.host('rates:read').dispatch(req('rates.get', { base: 'EUR' }), undefined)).ok).toBe(true);
expect(f.rates.getRates).toHaveBeenCalledWith('EUR');
const denied = (await f.host().dispatch(req('rates.get', { base: 'EUR' }), undefined)) as RpcError;
expect(denied.error.code).toBe('PERMISSION_DENIED');
});
it('TENANTFREE-003 weather coordinates are coerced, and the language stays pinned to en', async () => {
const f = build();
// Numeric strings are accepted, as the wire contract has always allowed.
await f.host('weather:read').dispatch(req('weather.get', { lat: '48.1', lng: '11.5', date: '2027-01-01' }), undefined);
expect(f.weather.get).toHaveBeenCalledWith('48.1', '11.5', '2027-01-01', 'en');
});
it('TENANTFREE-004 a non-numeric coordinate is BAD_PARAMS', async () => {
const res = (await build().host('weather:read').dispatch(req('weather.get', { lat: 'north', lng: 11 }), undefined)) as RpcError;
expect(res.error.code).toBe('BAD_PARAMS');
expect(res.error.message).toBe('lat must be a number');
});
it('TENANTFREE-005 a non-string base is BAD_PARAMS', async () => {
const res = (await build().host('rates:read').dispatch(req('rates.get', { base: 42 }), undefined)) as RpcError;
expect(res.error.code).toBe('BAD_PARAMS');
});
it('TENANTFREE-006 an absent date is passed through as undefined, not as a string', async () => {
const f = build();
await f.host('weather:read').dispatch(req('weather.get', { lat: 1, lng: 2 }), undefined);
expect(f.weather.get).toHaveBeenCalledWith('1', '2', undefined, 'en');
});
it('TENANTFREE-007 each class is listed in its module providers', () => {
expectRegisteredProvider(CategoriesModule, CategoriesRpc);
expectRegisteredProvider(WeatherModule, WeatherRpc);
expectRegisteredProvider(BudgetModule, ExchangeRatesRpc);
});
});
@@ -15,17 +15,18 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
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 { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { TripCardContributionsController } from '../../../src/nest/plugins/contributions/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),
invokeHook: vi.fn(async (id: string, _h: string, _fn: string, args: unknown[]) => invoke(id, args)),
} as unknown as PluginRuntimeService;
return { c: new TripCardContributionsController(runtime), runtime };
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 });
@@ -51,7 +52,7 @@ describe('TripCardContributionsController', () => {
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.invokeHook).toHaveBeenCalledWith('p1', 'tripCardProvider', 'getCards', [[1, 2]], 5, 5000);
expect(runtime.tripCards).toHaveBeenCalledWith('p1', [1, 2], 5);
});
it('drops a badge whose tripId was not requested / is inaccessible', async () => {
@@ -7,19 +7,20 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
vi.mock('../../../src/db/database', () => ({ db: {}, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { TripWarningsController } from '../../../src/nest/plugins/trip-warnings.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { TripWarningsController } from '../../../src/nest/plugins/contributions/trip-warnings.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(over: Partial<PluginRuntimeService> = {}) {
function controller(over: Partial<PluginHooks> = {}) {
const runtime = {
providersOf: vi.fn(() => ['p1', 'p2']),
invokeHook: vi.fn(async (id: string) =>
tripWarnings: vi.fn(async (id: string) =>
id === 'p2' ? [{ level: 'error', message: 'Day 3 is overpacked', dayId: 3 }] : [{ level: 'warning', message: 'Museum closed Mon', placeId: 7 }]),
...over,
} as unknown as PluginRuntimeService;
return { c: new TripWarningsController(runtime), runtime };
} as unknown as PluginHooks;
return { c: new TripWarningsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime };
}
describe('TripWarningsController', () => {
@@ -36,8 +37,8 @@ describe('TripWarningsController', () => {
it('merges provider warnings, tags each with the plugin id, defaults an odd level to warning', async () => {
const { c } = controller({
invokeHook: vi.fn(async (id: string) =>
id === 'p1' ? [{ level: 'bogus', message: 'Check timings' }, { message: '' }] : [{ level: 'info', message: 'Rainy week' }]) as unknown as PluginRuntimeService['invokeHook'],
tripWarnings: vi.fn(async (id: string) =>
id === 'p1' ? [{ level: 'bogus', message: 'Check timings' }, { message: '' }] : [{ level: 'info', message: 'Rainy week' }]) as unknown as PluginHooks['tripWarnings'],
});
const res = await c.get('1', req(5));
expect(res.warnings).toEqual([
@@ -48,17 +49,17 @@ describe('TripWarningsController', () => {
it('skips a provider that throws (graceful) and calls the hook with the trip + acting user', async () => {
const { c, runtime } = controller({
invokeHook: vi.fn(async (id: string) => { if (id === 'p2') throw new Error('slow'); return [{ level: 'warning', message: 'ok' }]; }) as unknown as PluginRuntimeService['invokeHook'],
tripWarnings: vi.fn(async (id: string) => { if (id === 'p2') throw new Error('slow'); return [{ level: 'warning', message: 'ok' }]; }) as unknown as PluginHooks['tripWarnings'],
});
const res = await c.get('1', req(5));
expect(res.warnings).toEqual([{ pluginId: 'p1', level: 'warning', message: 'ok', dayId: undefined, placeId: undefined }]);
expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'warningProvider', 'getWarnings', [1], 5, 5000);
expect(runtime.tripWarnings).toHaveBeenCalledWith('p1', 1, 5);
});
it('strips emojis from the warning message (rendered natively in TREK chrome)', async () => {
const { c } = controller({
providersOf: vi.fn(() => ['p1']),
invokeHook: vi.fn(async () => [{ level: 'error', message: '🔥 Overbooked!' }]) as unknown as PluginRuntimeService['invokeHook'],
tripWarnings: vi.fn(async () => [{ level: 'error', message: '🔥 Overbooked!' }]) as unknown as PluginHooks['tripWarnings'],
});
const res = await c.get('1', req(5));
expect(res.warnings[0].message).toBe('Overbooked!');
@@ -67,10 +68,10 @@ describe('TripWarningsController', () => {
it('caps a flooding provider at 20 warnings and truncates an oversized message', async () => {
const { c } = controller({
providersOf: vi.fn(() => ['flood']),
invokeHook: vi.fn(async () => [
tripWarnings: vi.fn(async () => [
{ level: 'warning', message: 'z'.repeat(1000) },
...Array.from({ length: 50 }, (_v, i) => ({ level: 'info', message: `w${i}` })),
]) as unknown as PluginRuntimeService['invokeHook'],
]) as unknown as PluginHooks['tripWarnings'],
});
const res = await c.get('1', req(5));
expect(res.warnings).toHaveLength(20); // per-provider count cap
@@ -14,17 +14,18 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { ViewContributionsController } from '../../../src/nest/plugins/view-contributions.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
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),
invokeHook: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginRuntimeService;
return { c: new ViewContributionsController(runtime), runtime };
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<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 });
@@ -114,6 +115,6 @@ describe('ViewContributionsController', () => {
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.invokeHook).toHaveBeenCalledWith('p1', 'tableContributor', 'getContributions', ['reservations', 1], 5, 5000);
expect(runtime.tableContributions).toHaveBeenCalledWith('p1', 'reservations', 1, 5);
});
});