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