mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
9b3d37ea29
Wires the registry into the host and takes the first domain across, so the kit lands with a worked example rather than as scaffolding nobody uses yet. The registry is an additive, optional fourth constructor parameter on PluginRpcHost, so all 107 existing construction sites keep compiling untouched; that is what makes the rest of the rollout a per-domain move instead of a big-bang one. bindInto runs LAST in the constructor, so a method still owned by one of the has() blocks collides loudly instead of being silently overridden. tags is the right pilot: four methods, a service that is already DI-native, and it exercises the read/write permission split, the userless refusal and a BadParams write. Handler bodies and messages are verbatim; only deps.foo(...) became this.tags... and uid became ctx.actingUserId. dispatch now strips the supervisor's _inv marker. The supervisor resolves the acting user from it before dispatch and then passes the request on untouched, so it has been visible to every handler and is in effect a reserved param name. Nothing reads it: auditResource takes named fields only, and the one handler that touches the params object as a whole (packing.setBagMembers) reads a named field off it. Dropping it keeps a future strict parse from rejecting calls every shipped plugin makes. Two tests carry the rollout from here. rpc-coverage.test.ts derives both owner sets from the code and proves they partition the method vocabulary exactly, so a PR that moves a method and forgets to delete the legacy registration fails in CI. And because a decorated class that no module registers is invisible to discovery, with every call to it coming back PERMISSION_DENIED and no other symptom, tags.rpc.test.ts asserts TagsRpc is actually listed in providers.
914 lines
58 KiB
TypeScript
914 lines
58 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');
|
|
});
|
|
|
|
it('db:read:trips reads a trip the acting user can access', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
// The acting user is bound by the HOST (2nd dispatch arg), never from params.
|
|
const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
// returns the ACTUAL trip row (title/start_date), not the access-check object
|
|
expect((res as RpcResponse).result).toMatchObject({ id: 1, title: 'Japan', start_date: '2027-01-01' });
|
|
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('FROM trips'));
|
|
});
|
|
|
|
it('db:read:trips is still RESOURCE_FORBIDDEN when the user is not a member', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 99);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('a trip read with NO bound acting user is RESOURCE_FORBIDDEN (jobs / forged calls)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
// A plugin-supplied asUserId is ignored; without a host-bound user, deny.
|
|
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.canAccessTrip).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('trips.getPlaces is membership-checked before the core read', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.db.prepare).not.toHaveBeenCalled();
|
|
|
|
const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42);
|
|
expect(ok(allowed)).toBe(true);
|
|
expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]);
|
|
});
|
|
|
|
it('db:read:packing / db:read:files delegate to the service, membership-checked, and stay separate scopes', async () => {
|
|
const packing = new PluginRpcHost('p', new Set(['db:read:packing']), deps);
|
|
// no access to trip 2 → refused before the service is called
|
|
expect(((await packing.dispatch(req('packing.list', { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.listPackingItems).not.toHaveBeenCalled();
|
|
const okP = await packing.dispatch(req('packing.list', { tripId: 1 }), 42);
|
|
expect(ok(okP)).toBe(true);
|
|
// the acting user is threaded through so packing's #858 private-item filter applies
|
|
expect(deps.listPackingItems).toHaveBeenCalledWith(1, 42);
|
|
// the packing scope does NOT unlock files
|
|
expect(((await packing.dispatch(req('files.list', { tripId: 1 }), 42)) as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
|
|
const files = new PluginRpcHost('p', new Set(['db:read:files']), deps);
|
|
const okF = await files.dispatch(req('files.list', { tripId: 1 }), 42);
|
|
expect((okF as RpcResponse).result).toEqual([{ id: 2, trip_id: 1, filename: 'visa.pdf' }]);
|
|
});
|
|
|
|
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('trips.getReservations is membership-checked and returns the hydrated list', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('trips.getReservations', { tripId: 1 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
// the REST-parity list (endpoints/day_positions), not a raw row scan
|
|
expect(deps.listTripReservations).toHaveBeenCalledWith(1);
|
|
expect(((res as RpcResponse).result as Array<{ endpoints: unknown[] }>)[0].endpoints).toEqual([]);
|
|
const forbidden = await host.dispatch(req('trips.getReservations', { tripId: 2 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('trips.getDays / trips.getAccommodations ride on db:read:trips, membership-checked', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const days = await host.dispatch(req('trips.getDays', { tripId: 1 }), 42);
|
|
expect(ok(days)).toBe(true);
|
|
expect(deps.listTripDays).toHaveBeenCalledWith(1);
|
|
const acc = await host.dispatch(req('trips.getAccommodations', { tripId: 1 }), 42);
|
|
expect(ok(acc)).toBe(true);
|
|
expect(deps.listTripAccommodations).toHaveBeenCalledWith(1);
|
|
for (const m of ['trips.getDays', 'trips.getAccommodations']) {
|
|
const forbidden = await host.dispatch(req(m, { tripId: 2 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const noUser = await host.dispatch(req(m, { tripId: 1 }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
}
|
|
});
|
|
|
|
it('trips.listMine returns the acting user\'s trips; a job (no user) is refused', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('trips.listMine'), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toHaveLength(2);
|
|
const noUser = await host.dispatch(req('trips.listMine'), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('reservations.listMine aggregates across accessible trips; refused without a user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('reservations.listMine'), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect((res as RpcResponse).result).toHaveLength(2);
|
|
expect((await host.dispatch(req('reservations.listMine'), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
it('wave-13 reads: collab / journal.getEntries / atlas.bucketList / files.getContent / rates / trips.create gated correctly', async () => {
|
|
// collab reads: membership-checked, need db:read:collab
|
|
const collab = new PluginRpcHost('p', new Set(['db:read:collab']), deps);
|
|
expect(ok(await collab.dispatch(req('collab.listNotes', { tripId: 1 }), 42))).toBe(true);
|
|
expect(ok(await collab.dispatch(req('collab.listMessages', { tripId: 1, before: 5 }), 42))).toBe(true);
|
|
expect((await collab.dispatch(req('collab.listPolls', { tripId: 2 }), 42)).ok).toBe(false); // no access to trip 2
|
|
// journal.getEntries: user-bound, needs db:read:journal; a job is refused
|
|
const journal = new PluginRpcHost('p', new Set(['db:read:journal']), deps);
|
|
expect(ok(await journal.dispatch(req('journal.getEntries', { journeyId: 7 }), 42))).toBe(true);
|
|
expect((await journal.dispatch(req('journal.getEntries', { journeyId: 7 }), undefined)).ok).toBe(false);
|
|
// atlas.bucketList: user-bound, needs db:read:atlas
|
|
const atlas = new PluginRpcHost('p', new Set(['db:read:atlas']), deps);
|
|
expect(ok(await atlas.dispatch(req('atlas.bucketList'), 42))).toBe(true);
|
|
expect((await atlas.dispatch(req('atlas.bucketList'), undefined)).ok).toBe(false);
|
|
// files.getContent: separate grant from files.list, membership-checked
|
|
const files = new PluginRpcHost('p', new Set(['db:read:files:content']), deps);
|
|
expect(ok(await files.dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42))).toBe(true);
|
|
expect((await files.dispatch(req('files.getContent', { tripId: 2, fileId: 2 }), 42)).ok).toBe(false);
|
|
// reading content is NOT unlocked by plain db:read:files
|
|
const filesList = new PluginRpcHost('p', new Set(['db:read:files']), deps);
|
|
expect((await filesList.dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42) as RpcError).error.code).toBe('PERMISSION_DENIED');
|
|
// rates.get: tenant-free (works without a user), needs rates:read
|
|
const rates = new PluginRpcHost('p', new Set(['rates:read']), deps);
|
|
expect(ok(await rates.dispatch(req('rates.get', { base: 'EUR' }), undefined))).toBe(true);
|
|
// trips.create: needs db:create:trips + the acting user's trip_create + a bound user
|
|
const create = new PluginRpcHost('p', new Set(['db:create:trips']), deps);
|
|
expect(ok(await create.dispatch(req('trips.create', { input: { title: 'Japan' } }), 42))).toBe(true);
|
|
expect((await create.dispatch(req('trips.create', { input: { title: 'x' } }), 7) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); // canCreateTrip false for 7
|
|
expect((await create.dispatch(req('trips.create', { input: { title: 'x' } }), undefined) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); // no user
|
|
expect((await create.dispatch(req('trips.create', { input: {} }), 42) as RpcError).error.code).toBe('BAD_PARAMS'); // title required
|
|
});
|
|
|
|
it('daynotes.list is membership-checked (trip-scoped)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:daynotes']), deps);
|
|
const good = await host.dispatch(req('daynotes.list', { tripId: 1, dayId: 5 }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.listDayNotes).toHaveBeenCalledWith(1, 5);
|
|
const forbidden = await host.dispatch(req('daynotes.list', { tripId: 999, dayId: 5 }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('daynotes.create needs db:write:daynotes + day_edit, membership-checked, text required', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:daynotes']), deps);
|
|
const good = await host.dispatch(req('daynotes.create', { tripId: 1, dayId: 5, input: { text: 'Pack sunscreen' } }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.createDayNote).toHaveBeenCalledWith(1, 5, expect.objectContaining({ text: 'Pack sunscreen' }));
|
|
const bad = await host.dispatch(req('daynotes.create', { tripId: 1, dayId: 5, input: { text: ' ' } }), 42);
|
|
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
|
|
const forbidden = await host.dispatch(req('daynotes.create', { tripId: 2, dayId: 5, input: { text: 'x' } }), 42);
|
|
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('daynotes.delete is gated the same way (edit + membership + bound user)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:daynotes']), deps);
|
|
const good = await host.dispatch(req('daynotes.delete', { tripId: 1, dayId: 5, noteId: 50 }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.deleteDayNote).toHaveBeenCalledWith(1, 5, 50);
|
|
const noUser = await host.dispatch(req('daynotes.delete', { tripId: 1, dayId: 5, noteId: 50 }), undefined);
|
|
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('packing.create needs db:write:packing + packing_edit, membership-checked, name required', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:packing']), deps);
|
|
const good = await host.dispatch(req('packing.create', { tripId: 1, input: { name: 'Socks' } }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.createPackingItem).toHaveBeenCalledWith(1, expect.objectContaining({ name: 'Socks' }), 42);
|
|
expect((await host.dispatch(req('packing.create', { tripId: 1, input: { name: '' } }), 42)).ok).toBe(false); // schema: name min 1
|
|
expect(((await host.dispatch(req('packing.create', { tripId: 2, input: { name: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await host.dispatch(req('packing.create', { tripId: 1, input: { name: 'x' } }), undefined)).ok).toBe(false); // no acting user
|
|
});
|
|
|
|
it('packing.update/delete are gated the same way; nothing without the grant', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:packing']), deps);
|
|
expect(ok(await host.dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { checked: true } }), 42))).toBe(true);
|
|
expect(deps.updatePackingItem).toHaveBeenCalledWith(1, 70, expect.objectContaining({ checked: true }), 42);
|
|
expect(ok(await host.dispatch(req('packing.delete', { tripId: 1, itemId: 70 }), 42))).toBe(true);
|
|
expect(deps.deletePackingItem).toHaveBeenCalledWith(1, 70);
|
|
const noGrant = new PluginRpcHost('p', new Set(['db:read:packing']), deps);
|
|
expect((await noGrant.dispatch(req('packing.create', { tripId: 1, input: { name: 'x' } }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
it('weather.get + categories.list are tenant-free (work without a user)', async () => {
|
|
const w = new PluginRpcHost('p', new Set(['weather:read']), deps);
|
|
expect(ok(await w.dispatch(req('weather.get', { lat: 48, lng: 11 }), undefined))).toBe(true);
|
|
const c = new PluginRpcHost('p', new Set(['db:read:categories']), deps);
|
|
expect(ok(await c.dispatch(req('categories.list', {}), undefined))).toBe(true);
|
|
});
|
|
|
|
// The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers.
|
|
|
|
it('trips.members + todos.list are trip-membership-gated', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:read:todos']), deps);
|
|
expect(ok(await host.dispatch(req('trips.members', { tripId: 1 }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('todos.list', { tripId: 1 }), 42))).toBe(true);
|
|
expect(((await host.dispatch(req('trips.members', { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('todos + packing-bags writes need the grant + edit right + a bound user', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:todos', 'db:write:packing']), deps);
|
|
expect(ok(await host.dispatch(req('todos.create', { tripId: 1, input: { name: 'Pack' } }), 42))).toBe(true);
|
|
expect((await host.dispatch(req('todos.create', { tripId: 1, input: { name: ' ' } }), 42)).ok).toBe(false);
|
|
expect(((await host.dispatch(req('todos.create', { tripId: 2, input: { name: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(ok(await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag' } }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [5, 6] }), 42))).toBe(true);
|
|
expect(deps.setPackingBagMembers).toHaveBeenCalledWith(1, 80, [5, 6]);
|
|
expect((await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag' } }), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('files.create validates name/content/size and is gated by file_upload', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:files']), deps);
|
|
const good = await host.dispatch(req('files.create', { tripId: 1, input: { name: 'itinerary.pdf', content_base64: 'aGk=' } }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.createTripFile).toHaveBeenCalledWith(1, expect.objectContaining({ name: 'itinerary.pdf' }), 42);
|
|
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: '', content_base64: 'aGk=' } }), 42)).ok).toBe(false);
|
|
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf' } }), 42)).ok).toBe(false); // no content
|
|
expect(((await host.dispatch(req('files.create', { tripId: 2, input: { name: 'a.pdf', content_base64: 'aGk=' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: 'aGk=' } }), undefined)).ok).toBe(false);
|
|
});
|
|
|
|
it('files link/update/softDelete run under file_edit / file_delete', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:files']), deps);
|
|
expect(ok(await host.dispatch(req('files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 7 } }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('files.update', { tripId: 1, fileId: 130, input: { description: 'x' } }), 42))).toBe(true);
|
|
expect(ok(await host.dispatch(req('files.softDelete', { tripId: 1, fileId: 130 }), 42))).toBe(true);
|
|
expect(deps.canDeleteFiles).toHaveBeenCalled();
|
|
});
|
|
|
|
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('trips.addMember is its own permission (member_manage), host-bound inviter', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:members']), deps);
|
|
const good = await host.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), 42);
|
|
expect(ok(good)).toBe(true);
|
|
expect(deps.addTripMember).toHaveBeenCalledWith(1, 6, 42); // inviter = HOST-bound acting user
|
|
expect(((await host.dispatch(req('trips.addMember', { tripId: 2, userId: 6 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await host.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), undefined)).ok).toBe(false);
|
|
// and NOT reachable via any other write grant
|
|
const otherGrant = new PluginRpcHost('p', new Set(['db:write:trips', 'db:write:collab']), deps);
|
|
expect((await otherGrant.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), 42)).ok).toBe(false);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('coerces numeric string params and tolerates a missing params object', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:own']), deps);
|
|
// tripId as a string -> coerced to a number; acting user is the bound host arg
|
|
const res = await host.dispatch(req('trips.getById', { tripId: '1' }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
// a request with no params object at all -> BAD_PARAMS (sql missing), not a crash
|
|
const noParams = await host.dispatch({ k: 'req', id: 'y', method: 'db.query', params: undefined });
|
|
expect((noParams as RpcError).error.code).toBe('BAD_PARAMS');
|
|
});
|
|
|
|
// ── 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) ---
|
|
it('db:write:places creates a place on a trip the acting user may edit', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:places']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 1, input: { name: 'Fushimi Inari' } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect(deps.createPlace).toHaveBeenCalledWith(1, expect.objectContaining({ name: 'Fushimi Inari' }));
|
|
});
|
|
|
|
it('places.create rejects an over-long string field (mirrors the REST STRING_LIMITS)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:places']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 1, input: { name: 'A'.repeat(201) } }), 42);
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
expect(deps.createPlace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('places.create is RESOURCE_FORBIDDEN on a trip the acting user cannot edit', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:places']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 2, input: { name: 'X' } }), 42);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deps.createPlace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('places.create with no bound acting user is RESOURCE_FORBIDDEN (jobs / forged calls)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:places']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 1, input: { name: 'X' } }), undefined);
|
|
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('places.create with an invalid payload (no name) is BAD_PARAMS', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:places']), deps);
|
|
const res = await host.dispatch(req('places.create', { tripId: 1, input: {} }), 42);
|
|
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
|
|
expect(deps.createPlace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('db:write:itinerary assigns a place to a day (day_edit gated)', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:itinerary']), deps);
|
|
const res = await host.dispatch(req('itinerary.assign', { tripId: 1, dayId: 3, placeId: 10 }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect(deps.assignPlaceToDay).toHaveBeenCalledWith(1, 3, 10, null);
|
|
});
|
|
|
|
it('db:write:trips updates a trip the acting user may edit', async () => {
|
|
const host = new PluginRpcHost('p', new Set(['db:write:trips']), deps);
|
|
const res = await host.dispatch(req('trips.update', { tripId: 1, input: { title: 'Renamed' } }), 42);
|
|
expect(ok(res)).toBe(true);
|
|
expect(deps.updateTrip).toHaveBeenCalledWith(1, 42, expect.objectContaining({ title: 'Renamed' }));
|
|
});
|
|
|
|
// --- 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('trips.getPlaces runs against the real places schema and returns the trip pool', async () => {
|
|
const realDb = new Database(':memory:');
|
|
createTables(realDb);
|
|
realDb.exec(`
|
|
INSERT INTO users (id, username, email, password_hash) VALUES (42, 'ada', 'ada@example.com', 'x');
|
|
INSERT INTO trips (id, user_id, title) VALUES (1, 42, 'Japan'), (2, 42, 'Peru');
|
|
INSERT INTO places (id, trip_id, name, created_at) VALUES
|
|
(1, 1, 'Older', '2027-01-01 10:00:00'),
|
|
(2, 1, 'Newer', '2027-01-02 10:00:00'),
|
|
(3, 2, 'Other trip', '2027-01-03 10:00:00');
|
|
`);
|
|
deps.db = realDb as unknown as HostDeps['db'];
|
|
|
|
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
|
const res = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42);
|
|
|
|
expect(ok(res)).toBe(true);
|
|
const rows = (res as RpcResponse).result as Array<{ id: number; name: string }>;
|
|
// only trip 1's places, newest first (mirrors the REST list's created_at DESC)
|
|
expect(rows.map(r => r.name)).toEqual(['Newer', 'Older']);
|
|
realDb.close();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|