mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user