Move the trip surface onto the decorators

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.
This commit is contained in:
Maurice
2026-08-09 13:24:39 +02:00
committed by Maurice
parent c326d39a07
commit 2ec6d006b0
11 changed files with 455 additions and 422 deletions
@@ -451,19 +451,8 @@ describe('host-deps factory — planner write + metadata deps', () => {
// #1705: a plugin itinerary write has to reach open sessions exactly like the REST
// route. The delete payload needs the dayId the client reducer evicts by, and both
// directions run the same journey-skeleton reconcile the controller/MCP tool run.
it('trips.update: archive/cover need their own permission; service errors map to RPC codes', async () => {
const h = host('db:write:trips');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'T' } })).ok).toBe(true);
checkPermission.mockImplementation((action: string) => action !== 'trip_archive');
expect((await call(h, 'trips.update', { tripId: 1, input: { is_archived: 1 } })).error.code).toBe('RESOURCE_FORBIDDEN');
checkPermission.mockImplementation((action: string) => action !== 'trip_cover_upload');
expect((await call(h, 'trips.update', { tripId: 1, input: { cover_image: '/x.jpg' } })).error.code).toBe('RESOURCE_FORBIDDEN');
checkPermission.mockReturnValue(true);
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'gone' } })).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'crash' } })).error.code).toBe('HOST_ERROR'); // rethrow of an unknown error
});
// trips.*, the cross-trip feeds and member management left this factory with the
// decorator migration; the cases now run in tests/unit/trips/trips.rpc.test.ts.
it('metadata: round-trips and enforces the key/value/access limits', async () => {
const h = host('db:meta');
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true);
@@ -541,48 +530,6 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read
// daynotes.* left this factory with the decorator migration; the cases now run in
// tests/unit/days/day-notes.rpc.test.ts against DayNotesRpc.
it('cross-trip reads enumerate accessible trips and reservations', async () => {
const h = host('db:read:trips');
expect((await call(h, 'trips.listMine', {})).ok).toBe(true);
const r = await call(h, 'reservations.listMine', {});
expect(r.ok).toBe(true);
expect(r.result).toHaveLength(1);
});
it('wave-13 wiring: collab reads, journal entries, atlas bucket and trip create delegate correctly', async () => {
// collab reads delegate to collabService and require the collab addon
const collab = host('db:read:collab');
expect((await call(collab, 'collab.listNotes', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, title: 'Note' }]);
expect((await call(collab, 'collab.listMessages', { tripId: 1, before: 9 })).result).toEqual([{ id: 3, trip_id: 1, text: 'hi', _before: 9 }]);
isAddonEnabled.mockReturnValueOnce(false);
expect((await call(collab, 'collab.listPolls', { tripId: 1 })).error.code).toBeDefined(); // addon off -> refused
// journal.getEntries: delegates + self-gate (journey 88 = no access)
const journal = host('db:read:journal');
expect((await call(journal, 'journal.getEntries', { journeyId: 7 })).result).toEqual([{ id: 10, journey_id: 7, author_id: 5 }]);
expect((await call(journal, 'journal.getEntries', { journeyId: 88 })).error.code).toBe('RESOURCE_FORBIDDEN');
// atlas.bucketList
const atlas = host('db:read:atlas');
expect((await call(atlas, 'atlas.bucketList', {})).result).toEqual([{ id: 5, user_id: 5, name: 'Kyoto' }]);
// trips.create: owner = acting user, delegates to createTrip; validation error mapped
const create = host('db:create:trips');
expect((await call(create, 'trips.create', { input: { title: 'Japan' } })).result).toMatchObject({ id: 99, user_id: 5, title: 'Japan' });
expect((await call(create, 'trips.create', { input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
});
it('trip-scoped reads are wired to the hydrated services (days incl. assignments, reservations incl. endpoints)', async () => {
const h = host('db:read:trips');
const days = await call(h, 'trips.getDays', { tripId: 1 });
expect(days.ok).toBe(true);
// listDays returns { days: [...] }; the wiring unwraps to the bare array like getPlaces
expect(days.result).toEqual([{ id: 3, trip_id: 1, day_number: 1, assignments: [], notes_items: [] }]);
const res = await call(h, 'trips.getReservations', { tripId: 1 });
expect(res.ok).toBe(true);
expect(res.result).toEqual([{ id: 1, trip_id: 1, title: 'Flight' }]);
const acc = await call(h, 'trips.getAccommodations', { tripId: 1 });
expect(acc.ok).toBe(true);
expect(acc.result).toEqual([{ id: 11, trip_id: 1, place_name: 'Ryokan' }]);
});
it('accommodations create validates refs, creates via DaysService and emits the cascade broadcasts', async () => {
const h = host('db:write:accommodations');
const good = await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00' } });
@@ -626,30 +573,9 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read
// transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the
// PackingService methods the REST controller uses too, so there is one copy now.
describe('host-deps factory — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => {
const host = (...perms: string[]) => createRealRpcHost('w1', new Set(perms))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true) })
afterAll(() => closePluginDataDb('w1'))
// weather.get, categories.list and rates.get left this factory with the decorator
// migration; the cases now run in tests/unit/plugins/tenant-free.rpc.test.ts.
// tags.* left this factory with the decorator migration. The same cases now run in
// tests/unit/tags/tags.rpc.test.ts against TagsRpc.
it('trips.members returns the roster', async () => {
const r = await call(host('db:read:trips'), 'trips.members', { tripId: 1 }, 5)
expect(r.ok).toBe(true)
expect(Array.isArray(r.result)).toBe(true)
})
// todos.* left this factory with the decorator migration; the cases now run in
// tests/unit/todo/todo.rpc.test.ts against TodoRpc.
})
// The Wave 1 wiring suite is gone: weather, categories, rates, tags, todos, the
// roster and the packing bags all moved onto the decorators, so there was nothing
// left in it. Their cases live in the domain suites now.
describe('host-deps factory — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => {
const host = (...perms: string[]) => createRealRpcHost('w2', new Set(perms))
@@ -725,15 +651,6 @@ describe('host-deps factory — Wave 3 wiring (files write / collab / member-add
expect(((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('trips.addMember verifies the target user exists and reports owner-add as joined:false', async () => {
const h = host('db:write:members')
const r = await call(h, 'trips.addMember', { tripId: 1, userId: 6 })
expect(r.ok).toBe(true)
expect(r.result).toMatchObject({ joined: true })
const ownerAdd = await call(h, 'trips.addMember', { tripId: 1, userId: 5 }) // owner -> no-op
expect(ownerAdd.result).toMatchObject({ joined: false })
expect(((await call(h, 'trips.addMember', { tripId: 1, userId: 12345 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
})
describe('host-deps factory — Wave 4 wiring (notify / ai)', () => {
+14 -2
View File
@@ -54,8 +54,8 @@ describe('plugin RPC coverage ledger', () => {
// Update these two numbers in every rollout PR. They make the diff state the size
// of the move out loud, which is the number a reviewer wants to see.
const total = KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length;
expect(fromRegistry.size).toBe(38);
expect(legacy.size).toBe(total - 38);
expect(fromRegistry.size).toBe(50);
expect(legacy.size).toBe(total - 50);
});
it('RPCLEDGER-006 the migrated methods are exactly the ones this PR claims', () => {
@@ -89,6 +89,7 @@ describe('plugin RPC coverage ledger', () => {
'places.delete',
'places.update',
'rates.get',
'reservations.listMine',
'tags.create',
'tags.delete',
'tags.list',
@@ -97,6 +98,17 @@ describe('plugin RPC coverage ledger', () => {
'todos.delete',
'todos.list',
'todos.update',
'trips.addMember',
'trips.create',
'trips.getAccommodations',
'trips.getById',
'trips.getDays',
'trips.getPlaces',
'trips.getReservations',
'trips.listMine',
'trips.members',
'trips.removeMember',
'trips.update',
'weather.get',
]);
});
@@ -13,11 +13,25 @@ import { makeDeps } from '../../helpers/rpc-host-deps';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
const req = (method: string, params: Record<string, unknown>): RpcRequest => ({ k: 'req', id: 'x', method, params });
/** trips.getById lives on the decorators now, so the audit cases bind it through them. */
const tripsRegistry = () => {
const db = {
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined),
prepare: () => ({ get: () => ({ id: 1, title: 'Japan' }), all: () => [] }),
} as unknown as DatabaseService;
const guards = new PluginGuards(db, {} as never, {} as never);
return createTestPluginRegistry([
new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards),
]);
};
describe('dispatch strips the supervisor _inv marker', () => {
it('RPCINV-001 the handler never sees _inv', async () => {
const deps = makeDeps();
@@ -37,7 +51,7 @@ describe('dispatch strips the supervisor _inv marker', () => {
it('RPCINV-003 the audit row is unchanged, because auditResource reads named fields only', async () => {
const deps = makeDeps();
const audit = vi.fn();
const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit });
const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit }, tripsRegistry());
await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }), 42);
expect(audit).toHaveBeenCalledWith({
pluginId: 'p',
@@ -50,7 +64,7 @@ describe('dispatch strips the supervisor _inv marker', () => {
it('RPCINV-004 the acting user still comes from the host, not from the params', async () => {
const deps = makeDeps();
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps, tripsRegistry());
// No acting user bound: the trip read is refused even though _inv is present.
const res = await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }));
expect(res.ok).toBe(false);
+1 -159
View File
@@ -42,41 +42,7 @@ describe('PluginRpcHost — capability enforcement', () => {
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' }]);
});
// 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);
@@ -151,50 +117,6 @@ describe('PluginRpcHost — capability enforcement', () => {
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);
@@ -277,28 +199,6 @@ describe('PluginRpcHost — capability enforcement', () => {
}
});
it('wave-13 reads: collab / journal.getEntries / atlas.bucketList / 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);
// 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
});
// 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 () => {
@@ -314,12 +214,6 @@ describe('PluginRpcHost — capability enforcement', () => {
// The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers.
it('trips.members is trip-membership-gated', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
expect(ok(await host.dispatch(req('trips.members', { tripId: 1 }), 42))).toBe(true);
expect(((await host.dispatch(req('trips.members', { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
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);
@@ -372,18 +266,6 @@ describe('PluginRpcHost — capability enforcement', () => {
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);
@@ -478,16 +360,6 @@ describe('PluginRpcHost — capability enforcement', () => {
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 () => {
@@ -669,13 +541,6 @@ describe('PluginRpcHost — capability enforcement', () => {
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
});
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);
@@ -726,29 +591,6 @@ describe('PluginRpcHost — capability enforcement', () => {
// 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);