mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
2ec6d006b0
Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332 lines to 830 and the deps factory from 944 to 500. trips.update keeps its two field-level gates: archiving and changing the cover each need their own admin-configurable permission on top of trip_edit, so a plugin cannot do either on a trip it may otherwise edit. Member management keeps its own grant for the same kind of reason, since adding a member grants trip access, and the owner still cannot be removed through it because that would orphan the trip. canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's edit permission through it and has not moved yet.
607 lines
38 KiB
TypeScript
607 lines
38 KiB
TypeScript
/**
|
|
* The capability router is the plugin permission boundary (#plugins, M1). These
|
|
* tests prove that an ungranted method is never reachable, a granted method
|
|
* works, a granted trip read is still membership-checked against the acting
|
|
* user, and bad params / unknown methods are rejected — without ever spawning a
|
|
* child (the router runs in the host).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
|
import type { RpcRequest, RpcResponse, 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 ok = (r: RpcResponse | RpcError): r is RpcResponse => r.ok === true;
|
|
|
|
describe('PluginRpcHost — capability enforcement', () => {
|
|
let deps: HostDeps;
|
|
beforeEach(() => { deps = makeDeps(); });
|
|
|
|
it('registers only granted methods; an ungranted method is PERMISSION_DENIED', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const denied = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }));
|
|
expect(denied.ok).toBe(false);
|
|
expect((denied as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
expect(deps.canAccessTrip).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a granted db:own method runs against the plugin db', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] }));
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toEqual([{ n: 1 }]);
|
|
expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []);
|
|
});
|
|
|
|
it('an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const res = await host.dispatch(req('fs.readFile', { path: '/etc/passwd' }));
|
|
expect((res as RpcError).error.code).toBe('UNKNOWN_METHOD');
|
|
});
|
|
|
|
// The trips, roster and member cases moved to tests/unit/trips/trips.rpc.test.ts.
|
|
it('db:read:users returns only the public projection for a visible user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:users']), deps);
|
|
const res = await host.dispatch(req('users.getById', { id: 3 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toEqual({ id: 3, username: 'ada', display_name: 'Ada', avatar: null });
|
|
// the SELECT column list is host-controlled — no password/token columns
|
|
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('id, username, display_name, avatar'));
|
|
});
|
|
|
|
it('db:read:users is RESOURCE_FORBIDDEN for a user the acting user cannot see (no enumeration)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:users']), deps);
|
|
const forbidden = await host.dispatch(req('users.getById', { id: 999 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// and with no bound acting user (job / forged call), also denied
|
|
const noUser = await host.dispatch(req('users.getById', { id: 3 }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('ws:broadcast:trip forwards to the (namespaced) broadcaster for a member', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps);
|
|
const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { a: 1 });
|
|
});
|
|
|
|
it('ws:broadcast:trip is RESOURCE_FORBIDDEN for a non-member trip (no cross-tenant push)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps);
|
|
const forbidden = await host.dispatch(req('ws.broadcastToTrip', { tripId: 999, event: 'x', data: {} }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.broadcastToTrip).not.toHaveBeenCalled();
|
|
// and a broadcast with no bound acting user is denied too
|
|
const noUser = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('bad params are BAD_PARAMS', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const res = await host.dispatch(req('db.query', { args: [] }));
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
});
|
|
|
|
it('with no permissions, every real method is denied', async () => {
|
|
const host = new PluginRpcHost('p', new Set<string>(), deps);
|
|
for (const m of ['db.query', 'trips.getById', 'users.getById', 'ws.broadcastToTrip']) {
|
|
const res = await host.dispatch(req(m, { tripId: 1, asUserId: 42, id: 3, event: 'x', sql: 'SELECT 1' }));
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
}
|
|
});
|
|
|
|
it('ws:broadcast:user forwards only to the acting user (never an arbitrary one)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['ws:broadcast:user']), deps);
|
|
const res = await host.dispatch(req('ws.broadcastToUser', { userId: 42, event: 'poke', data: { x: 2 } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect(deps.broadcastToUser).toHaveBeenCalledWith(42, { event: 'poke', x: 2 });
|
|
// broadcasting to a DIFFERENT user is refused
|
|
const forbidden = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: {} }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('non-array db args are BAD_PARAMS; a primitive ws payload is wrapped', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own', 'ws:broadcast:trip']), deps);
|
|
const bad = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: 'nope' }));
|
|
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
|
|
|
|
await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }), 42);
|
|
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { value: 'primitive' });
|
|
});
|
|
|
|
it('dispose() closes the plugin data db', () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
host.dispose();
|
|
expect(deps.data.close).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reservations.create needs db:write:reservations + reservation_edit, membership-checked', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:reservations']), deps);
|
|
const good = await host.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel Tokyo' } }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.createReservation).toHaveBeenCalledWith(1, expect.objectContaining({ title: 'Hotel Tokyo' }), 42);
|
|
// a trip the acting user cannot edit -> forbidden, and the dep is never reached
|
|
const forbidden = await host.dispatch(req('reservations.create', { tripId: 2, input: { title: 'x' } }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('reservations.create refuses a job/onLoad (no acting user) and invalid input', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:reservations']), deps);
|
|
const noUser = await host.dispatch(req('reservations.create', { tripId: 1, input: { title: 'x' } }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const bad = await host.dispatch(req('reservations.create', { tripId: 1, input: {} }), 42);
|
|
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
|
|
expect(deps.createReservation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reservations.delete is gated the same way (edit permission + membership)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:reservations']), deps);
|
|
const good = await host.dispatch(req('reservations.delete', { tripId: 1, reservationId: 40 }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.deleteReservation).toHaveBeenCalledWith(1, 40, 42);
|
|
const forbidden = await host.dispatch(req('reservations.delete', { tripId: 2, reservationId: 1 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('reservations.create/update pin the endpoint STRUCTURE but stay permissive like the service', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:reservations']), deps);
|
|
const goodEp = [{ role: 'from', name: 'HND', lat: 35.55, lng: 139.78, code: 'HND' }];
|
|
const good = await host.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Flight', endpoints: goodEp } }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
// A structurally-wrong endpoint (bad role enum) or a non-array is refused up front.
|
|
for (const endpoints of [[{ role: 'banana', name: 'x', lat: 1, lng: 2 }], 'nope']) {
|
|
const bad = await host.dispatch(req('reservations.update', { tripId: 1, reservationId: 40, input: { endpoints } }), 42);
|
|
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
|
|
}
|
|
// But a coord-less endpoint is NOT rejected — it's accepted and dropped downstream,
|
|
// exactly like the REST/importer path (no breaking change vs 3.2.1).
|
|
const coordless = await host.dispatch(req('reservations.update', { tripId: 1, reservationId: 40, input: { endpoints: [{ role: 'to', name: 'x' }] } }), 42);
|
|
expect(ok(coordless)).toBe(true);
|
|
// [] is the documented delete-all and stays valid
|
|
const clear = await host.dispatch(req('reservations.update', { tripId: 1, reservationId: 40, input: { endpoints: [] } }), 42);
|
|
expect(ok(clear)).toBe(true);
|
|
});
|
|
|
|
it('accommodations.* need db:write:accommodations + day_edit, membership-checked', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:accommodations']), deps);
|
|
const input = { place_id: 7, start_day_id: 3, end_day_id: 4 };
|
|
const good = await host.dispatch(req('accommodations.create', { tripId: 1, input }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.createAccommodation).toHaveBeenCalledWith(1, expect.objectContaining(input));
|
|
// gated by day_edit (accommodations live in the day service), not reservation_edit
|
|
(deps.canEditDays as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
|
|
const noEdit = await host.dispatch(req('accommodations.create', { tripId: 1, input }), 42);
|
|
expect((noEdit as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const forbidden = await host.dispatch(req('accommodations.update', { tripId: 2, accommodationId: 11, input: {} }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const noUser = await host.dispatch(req('accommodations.delete', { tripId: 1, accommodationId: 11 }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const bad = await host.dispatch(req('accommodations.create', { tripId: 1, input: { place_id: 7 } }), 42);
|
|
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
|
|
const del = await host.dispatch(req('accommodations.delete', { tripId: 1, accommodationId: 11 }), 42);
|
|
expect(ok(del)).toBe(true);
|
|
expect(deps.deleteAccommodation).toHaveBeenCalledWith(1, 11);
|
|
// and none of it is reachable without the grant
|
|
const ungranted = new PluginRpcHost('p', new Set(['db:write:reservations']), deps);
|
|
const denied = await ungranted.dispatch(req('accommodations.create', { tripId: 1, input }), 42);
|
|
expect((denied as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('user-scoped addon reads (journal/atlas/vacay) need a bound user; a job is refused', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:journal', 'db:read:atlas', 'db:read:vacay']), deps);
|
|
expect(ok(await host.dispatch(req('journal.listMine'), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('atlas.visited'), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('vacay.mine'), 42))).toBe(true);
|
|
for (const m of ['journal.listMine', 'atlas.visited', 'vacay.mine']) {
|
|
expect((await host.dispatch(req(m), undefined)).ok).toBe(false);
|
|
}
|
|
});
|
|
|
|
// The daynotes cases moved to tests/unit/days/day-notes.rpc.test.ts.
|
|
|
|
it('collections reads are user-scoped and need db:read:collections + a bound user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:collections']), deps);
|
|
expect(ok(await host.dispatch(req('collections.listMine'), 42))).toBe(true);
|
|
const one = await host.dispatch(req('collections.get', { id: 1 }), 42);
|
|
expect(ok(one)).toBe(true);
|
|
expect(deps.getCollectionForUser).toHaveBeenCalledWith(42, 1);
|
|
expect((await host.dispatch(req('collections.listMine'), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
// weather.get, categories.list and rates.get moved to tests/unit/plugins/tenant-free.rpc.test.ts.
|
|
|
|
// The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers.
|
|
|
|
it('atlas writes are uid-bound (code-validated, userless refused)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:atlas']), deps);
|
|
expect(ok(await host.dispatch(req('atlas.markCountry', { code: 'jp' }), 42))).toBe(true);
|
|
expect(deps.markCountryVisited).toHaveBeenCalledWith(42, 'JP'); // uid host-bound, code normalized
|
|
expect(ok(await host.dispatch(req('atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('atlas.createBucketItem', { input: { name: 'Kyoto' } }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('atlas.markCountry', { code: 'JP' }), undefined)).ok).toBe(false); // no user
|
|
expect((await host.dispatch(req('atlas.markCountry', { code: 'not-a-code-way-too-long' }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('atlas.createBucketItem', { input: { name: '' } }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
it('vacay writes validate the date and are uid-bound', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:vacay']), deps);
|
|
expect(ok(await host.dispatch(req('vacay.toggleEntry', { date: '2026-08-01' }), 42))).toBe(true);
|
|
expect(deps.vacayToggleEntry).toHaveBeenCalledWith(42, '2026-08-01');
|
|
expect(ok(await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2026-12-24', note: 'Xmas' }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('vacay.toggleEntry', { date: 'tomorrow' }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('vacay.toggleEntry', { date: '2026-08-01' }), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
it('journal writes require entry_date on create and a bound user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:journal']), deps);
|
|
expect(ok(await host.dispatch(req('journal.createEntry', { journeyId: 3, input: { entry_date: '2026-08-01', story: 'x' } }), 42))).toBe(true);
|
|
expect(deps.createJournalEntry).toHaveBeenCalledWith(42, 3, expect.objectContaining({ entry_date: '2026-08-01' }));
|
|
expect((await host.dispatch(req('journal.createEntry', { journeyId: 3, input: { story: 'no date' } }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('journal.updateEntry', { entryId: 120, input: {} }), undefined)).ok).toBe(false);
|
|
expect(ok(await host.dispatch(req('journal.deleteEntry', { entryId: 120 }), 42))).toBe(true);
|
|
});
|
|
|
|
it('collections writes are schema-validated and uid-bound', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:collections']), deps);
|
|
expect(ok(await host.dispatch(req('collections.create', { input: { name: 'Tokyo eats' } }), 42))).toBe(true);
|
|
expect(deps.createCollectionForUser).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Tokyo eats' }));
|
|
expect(ok(await host.dispatch(req('collections.deletePlace', { placeId: 7 }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('collections.create', { input: { name: 'x' } }), undefined)).ok).toBe(false);
|
|
const noGrant = new PluginRpcHost('p', new Set(['db:read:collections']), deps);
|
|
expect((await noGrant.dispatch(req('collections.create', { input: { name: 'x' } }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
// The files cases moved to tests/unit/files/files.rpc.test.ts with the handlers.
|
|
it('collab writes are gated by collab_edit and validate their inputs', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:collab']), deps);
|
|
expect(ok(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: 'Ideas' } }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: '' } }), 42)).ok).toBe(false);
|
|
expect(ok(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A', 'B'] } }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A'] } }), 42)).ok).toBe(false);
|
|
expect(ok(await host.dispatch(req('collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 0 }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('collab.createMessage', { tripId: 1, text: '' }), 42)).ok).toBe(false);
|
|
expect(((await host.dispatch(req('collab.createNote', { tripId: 2, input: { title: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('notify.send forces the recipient to the acting user or a member trip; admin scope refused', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['notify:send']), deps);
|
|
expect(ok(await host.dispatch(req('notify.send', { input: { title: 'Delay', body: 'AB123 is late', scope: 'user', targetId: 42 } }), 42))).toBe(true);
|
|
expect(deps.sendPluginNotification).toHaveBeenCalledWith('p', expect.objectContaining({ scope: 'user', targetId: 42 }));
|
|
expect(ok(await host.dispatch(req('notify.send', { input: { title: 'Trip', body: 'x', scope: 'trip', targetId: 1 } }), 42))).toBe(true);
|
|
// another user (scope user, foreign targetId) → forbidden
|
|
expect(((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 99 } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// a trip the acting user isn't in → forbidden
|
|
expect(((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 2 } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// admin scope → bad params (not an allowed scope)
|
|
expect((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'admin', targetId: 0 } }), 42)).ok).toBe(false);
|
|
// a protocol-relative / absolute link → rejected
|
|
expect((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 42, link: '//evil.com' } }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('notify.send', { input: { title: '', body: 'b', scope: 'user', targetId: 42 } }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
it('ai.complete/extract require a configured provider + a bound user, with caps', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['ai:invoke']), deps);
|
|
expect(ok(await host.dispatch(req('ai.complete', { prompt: 'Summarize my trip' }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('ai.extract', { text: 'AB123 JFK 10:00', jsonSchema: { type: 'object' } }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('ai.complete', { prompt: '' }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('ai.extract', { text: 'x', jsonSchema: 'not-an-object' }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('ai.complete', { prompt: 'x'.repeat(20001) }), 42)).ok).toBe(false);
|
|
// user 7 has no provider configured → bad params (not a crash)
|
|
expect((await host.dispatch(req('ai.complete', { prompt: 'hi' }), 7)).ok).toBe(false);
|
|
// no user context at all → forbidden
|
|
expect((await host.dispatch(req('ai.complete', { prompt: 'hi' }), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
it('settings.get returns the acting user\'s own value; a userless context yields undefined', async () => {
|
|
// No permission required — it only ever returns THIS plugin's config for the acting user.
|
|
const host = new PluginRpcHost('p', new Set(), deps);
|
|
const bound = await host.dispatch(req('settings.get', { key: 'apiKey' }), 42);
|
|
expect(ok(bound)).toBe(true);
|
|
expect((bound as RpcResponse).result).toEqual({ value: 'secret-of-p' });
|
|
expect(deps.getUserSetting).toHaveBeenCalledWith('p', 42, 'apiKey');
|
|
const userless = await host.dispatch(req('settings.get', { key: 'apiKey' }), undefined);
|
|
expect((userless as RpcResponse).result).toEqual({ value: undefined });
|
|
});
|
|
|
|
it('oauth.getToken needs the grant + a bound user; returns only the access token', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['oauth:client']), deps);
|
|
const ok1 = await host.dispatch(req('oauth.getToken', {}), 42);
|
|
expect(ok(ok1)).toBe(true);
|
|
expect((ok1 as RpcResponse).result).toEqual({ accessToken: 'access-token-xyz' });
|
|
expect(deps.getOAuthToken).toHaveBeenCalledWith('p', 42);
|
|
// no bound user (a job/onLoad) → null, matching the SDK contract, not an error
|
|
const userless = await host.dispatch(req('oauth.getToken', {}), undefined);
|
|
expect(ok(userless)).toBe(true);
|
|
expect((userless as RpcResponse).result).toEqual({ accessToken: null });
|
|
// without the grant → not reachable
|
|
const noGrant = new PluginRpcHost('p', new Set(), deps);
|
|
expect((await noGrant.dispatch(req('oauth.getToken', {}), 42)).ok).toBe(false);
|
|
});
|
|
|
|
it('scheduler.set/cancel ride on jobs:run and are userless (no acting user needed)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['jobs:run']), deps);
|
|
// userless (no bound user) is fine — scheduled tasks are like jobs
|
|
const set = await host.dispatch(req('scheduler.set', { name: 'poll', dueAt: 123, everyMs: 60000, payload: { x: 1 } }), undefined);
|
|
expect(ok(set)).toBe(true);
|
|
expect(deps.schedulerSet).toHaveBeenCalledWith('poll', 123, 60000, { x: 1 });
|
|
const cancel = await host.dispatch(req('scheduler.cancel', { name: 'poll' }), undefined);
|
|
expect(ok(cancel)).toBe(true);
|
|
// without jobs:run → not reachable
|
|
const noGrant = new PluginRpcHost('p', new Set(), deps);
|
|
expect((await noGrant.dispatch(req('scheduler.set', { name: 'x', dueAt: 1 }), 42) as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('a read scope is denied without its own permission', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
expect((await host.dispatch(req('journal.listMine'), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('daynotes.list', { tripId: 1, dayId: 5 }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
it('an error thrown by a handler becomes HOST_ERROR', async () => {
|
|
(deps.data.query as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
throw new Error('disk gone');
|
|
});
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] }));
|
|
expect((res as RpcError).error.code).toBe('HOST_ERROR');
|
|
expect((res as RpcError).error.message).toBe('disk gone');
|
|
});
|
|
|
|
it('a non-Error thrown by a handler still maps to HOST_ERROR', async () => {
|
|
(deps.data.query as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
throw 'raw string';
|
|
});
|
|
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
|
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1' }));
|
|
expect((res as RpcError).error.code).toBe('HOST_ERROR');
|
|
expect((res as RpcError).error.message).toBe('internal error');
|
|
});
|
|
|
|
// ── Costs (budget items): db:read:costs / db:write:costs ────────────────────
|
|
|
|
it('db:read:costs reads a trip the acting user can access', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.getByTrip', { tripId: 1 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toEqual([{ id: 5, trip_id: 1, name: 'Hotel', total_price: 100 }]);
|
|
expect(deps.listCostsForTrip).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('db:read:costs is membership-checked before the read (non-member → RESOURCE_FORBIDDEN)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.getByTrip', { tripId: 1 }), 99);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.listCostsForTrip).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs are RESOURCE_FORBIDDEN when the Costs addon is disabled', async () => {
|
|
(deps.budgetAddonEnabled as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.getByTrip', { tripId: 1 }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.listCostsForTrip).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.listMine returns costs across every accessible trip', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.listMine', {}), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toEqual([
|
|
{ id: 5, trip_id: 1, name: 'Hotel' },
|
|
{ id: 6, trip_id: 2, name: 'Food' },
|
|
]);
|
|
expect(deps.listCostsForUser).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it('costs.listMine with no bound acting user is RESOURCE_FORBIDDEN (jobs / forged calls)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.listMine', {}), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.listCostsForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.getByTrip is PERMISSION_DENIED without db:read:costs', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('costs.getByTrip', { tripId: 1 }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('db:write:costs creates a cost when the user may edit the trip', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { name: 'Hotel', total_price: 120 } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toMatchObject({ id: 9, trip_id: 1, name: 'Hotel', total_price: 120 });
|
|
expect(deps.createCost).toHaveBeenCalledWith(1, expect.objectContaining({ name: 'Hotel', total_price: 120 }));
|
|
});
|
|
|
|
it('db:write:costs is RESOURCE_FORBIDDEN without the budget_edit permission', async () => {
|
|
(deps.canEditCosts as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { name: 'Hotel' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.createCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('db:write:costs on a trip the user cannot access is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { name: 'Hotel' } }), 99);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.createCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('db:write:costs with an invalid payload is BAD_PARAMS', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
// name is required (min length 1) by budgetCreateItemRequestSchema
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { total_price: 5 } }), 42);
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
expect(deps.createCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('db:write:costs with no bound acting user is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { name: 'Hotel' } }), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.createCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.create is PERMISSION_DENIED without db:write:costs', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.create', { tripId: 1, input: { name: 'Hotel' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('db:write:costs updates a cost when the user may edit the trip', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { name: 'Hostel' } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toMatchObject({ id: 5, trip_id: 1, name: 'Hostel' });
|
|
expect(deps.updateCost).toHaveBeenCalledWith(1, 5, expect.objectContaining({ name: 'Hostel' }));
|
|
});
|
|
|
|
it('costs.update is RESOURCE_FORBIDDEN without the budget_edit permission', async () => {
|
|
(deps.canEditCosts as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { name: 'X' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.updateCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.update on a trip the user cannot access is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { name: 'X' } }), 99);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.updateCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.update with an invalid payload is BAD_PARAMS', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
// total_price must be a number per budgetUpdateItemRequestSchema.
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { total_price: 'nope' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
expect(deps.updateCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.update with no bound acting user is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { name: 'X' } }), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.updateCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.update is PERMISSION_DENIED without db:write:costs', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { name: 'X' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('db:write:costs deletes a cost when the user may edit the trip', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.delete', { tripId: 1, itemId: 5 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toMatchObject({ deleted: true });
|
|
expect(deps.deleteCost).toHaveBeenCalledWith(1, 5);
|
|
});
|
|
|
|
it('costs.delete is RESOURCE_FORBIDDEN without the budget_edit permission', async () => {
|
|
(deps.canEditCosts as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.delete', { tripId: 1, itemId: 5 }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.deleteCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.delete on a trip the user cannot access is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.delete', { tripId: 1, itemId: 5 }), 99);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.deleteCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.delete with no bound acting user is RESOURCE_FORBIDDEN', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:costs']), deps);
|
|
const res = await host.dispatch(req('costs.delete', { tripId: 1, itemId: 5 }), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.deleteCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('costs.delete is PERMISSION_DENIED without db:write:costs', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:costs']), deps);
|
|
const res = await host.dispatch(req('costs.delete', { tripId: 1, itemId: 5 }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
// --- Planner writes (#1429) ---
|
|
// The places, days and itinerary cases moved to their own domain suites.
|
|
it('places.create is PERMISSION_DENIED without db:write:places', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 1, input: { name: 'X' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
// --- Plugin metadata (db:meta) ---
|
|
it('db:meta stores and reads namespaced metadata on an accessible entity', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:meta']), deps);
|
|
const set = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'rating', value: 5 }), 42);
|
|
expect(ok(set)).toBe(true);
|
|
expect(deps.metaSet).toHaveBeenCalledWith('trip', 1, 'rating', 5);
|
|
const placeOk = await host.dispatch(req('meta.get', { entityType: 'place', entityId: 7, key: 'x' }), 42);
|
|
expect(ok(placeOk)).toBe(true); // place 7 resolves to accessible trip 1
|
|
});
|
|
|
|
it('meta.set is PERMISSION_DENIED without db:meta', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'x', value: 1 }), 42);
|
|
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
});
|
|
|
|
it('meta is RESOURCE_FORBIDDEN on an entity the acting user cannot access', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:meta']), deps);
|
|
const res = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 2, key: 'x', value: 1 }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.metaSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('meta with an unknown entityType is BAD_PARAMS', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:meta']), deps);
|
|
const res = await host.dispatch(req('meta.set', { entityType: 'user', entityId: 1, key: 'x', value: 1 }), 42);
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
});
|
|
|
|
it('meta writes resolve the entity edit permission per type (place→place_edit, day→day_edit) and refuse no-user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:meta']), deps);
|
|
expect(ok(await host.dispatch(req('meta.set', { entityType: 'place', entityId: 7, key: 'k', value: 1 }), 42))).toBe(true);
|
|
expect(deps.canEditPlaces).toHaveBeenCalled();
|
|
expect(ok(await host.dispatch(req('meta.set', { entityType: 'day', entityId: 3, key: 'k', value: 1 }), 42))).toBe(true);
|
|
expect(deps.canEditDays).toHaveBeenCalled();
|
|
// reservation metadata uses reservation_edit; accommodation uses day_edit
|
|
expect(ok(await host.dispatch(req('meta.set', { entityType: 'reservation', entityId: 40, key: 'ext_id', value: 'AB1' }), 42))).toBe(true);
|
|
expect(deps.canEditReservations).toHaveBeenCalled();
|
|
expect(ok(await host.dispatch(req('meta.set', { entityType: 'accommodation', entityId: 11, key: 'k', value: 1 }), 42))).toBe(true);
|
|
// an entity of another trip is refused
|
|
expect((await host.dispatch(req('meta.get', { entityType: 'reservation', entityId: 999, key: 'k' }), 42) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// no host-bound acting user (a job / forged call) → refused
|
|
const noUser = await host.dispatch(req('meta.get', { entityType: 'trip', entityId: 1, key: 'k' }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
// Regression (#getPlaces): the mocked `db.prepare` above never parses the SQL, so an
|
|
// invalid column sails through. Drive the trip reads against a REAL schema-loaded
|
|
// SQLite instead — `places` has no day_id/position (those live on day_assignments),
|
|
// so the old `ORDER BY day_id, position` threw `no such column` on every call.
|
|
it('meta WRITES need the entity edit permission — a read-only member is RESOURCE_FORBIDDEN', async () => {
|
|
// Member can access the trip but not edit it (viewer role).
|
|
(deps.canEditTrip as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
const host = new PluginRpcHost('p', new Set(['db:meta']), deps);
|
|
const write = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'x', value: 1 }), 42);
|
|
expect((write as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.metaSet).not.toHaveBeenCalled();
|
|
// …but a READ is only access-gated, so it still works.
|
|
const read = await host.dispatch(req('meta.get', { entityType: 'trip', entityId: 1, key: 'x' }), 42);
|
|
expect(ok(read)).toBe(true);
|
|
expect(deps.metaGet).toHaveBeenCalled();
|
|
});
|
|
});
|