mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
010242b107
Eleven more methods, so the registry owns 61 of 113. rpc-host.ts is at 771 lines,
down from 1332.
costs is the first addon-gated domain, and the addon check sits in a different
place in each of the two reads: inside the membership callback for getByTrip, so a
caller without trip access never learns whether the addon is on, and before
everything for listMine, which has no trip to check. Both orderings are asserted.
The writes keep their own refusal wording ("no permission to edit costs on trip N")
instead of going through requireTripEdit, which words it differently.
The booking paths are cascade-heavy and stay that way: creating a reservation can
create an accommodation and a budget item, deleting one removes both again, and
deleting a lodging block takes its partner reservation and budget item with it.
Each cascade is a broadcast the web app relies on, so they are asserted by event
name and order.
CostsRpc reaches the trip list through trips.bridge rather than by injection.
BudgetModule cannot import TripsModule, because TripsModule already imports
BudgetModule; budget.mcp.ts uses the same escape for the same reason.
365 lines
23 KiB
TypeScript
365 lines
23 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();
|
|
});
|
|
|
|
// The costs, reservation and accommodation cases moved to their domain suites.
|
|
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 ────────────────────
|
|
|
|
// --- 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();
|
|
});
|
|
});
|