/** * The trip plugin surface after it moved onto @PluginMethod: the trip-scoped reads, * the two cross-trip feeds, the roster, the update, the create and the two member * operations. * * Two things get their own cases. trips.update gates two individual FIELDS behind * permissions of their own on top of trip_edit, and adding a member GRANTS TRIP * ACCESS, which is why it sits behind its own permission rather than any other write. */ import { Places } from '../../../src/db/entities/Places.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service'; import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing'; import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { TripMembersService } from '../../../src/nest/trip-members/trip-members.service'; import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { TripsModule } from '../../../src/nest/trips/trips.module'; import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import { expectRegisteredProvider } from '../../helpers/module-providers'; import { makeDeps } from '../../helpers/rpc-host-deps'; import type { EntityManager } from '@mikro-orm/core'; import { MAX_TRIP_DAYS } from '@trek/shared'; import { describe, it, expect, vi } from 'vitest'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params, }); const ALL_TRIP_GRANTS = ['db:read:trips', 'db:write:trips', 'db:create:trips', 'db:write:members']; /** Trip 1 belongs to user 42. `allow` decides each individual permission check. */ export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error; createThrows?: Error } = {}) { const trips = { list: vi.fn(() => [{ id: 1, title: 'Japan' }]), updateTrip: vi.fn(() => { if (opts.updateThrows) throw opts.updateThrows; return { updatedTrip: { id: 1, title: 'Japan' } }; }), create: vi.fn((userId: number) => { if (opts.createThrows) throw opts.createThrows; return { trip: { id: 99, user_id: userId } }; }), removeMember: vi.fn(), } as unknown as TripsService & Record>; const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record>; const days = { list: vi.fn(() => ({ days: [{ id: 3 }] })), } as unknown as DaysService & Record>; // The stays left the day service when accommodations became their own domain. const accommodations = { list: vi.fn(() => [{ id: 11 }]), } as unknown as AccommodationsService & Record>; // Membership left TripsService for its own domain. const roster = { removeMember: vi.fn(), } as unknown as TripMembersService & Record>; const membership = { joinTripAsMember: vi.fn(() => ({ joined: true })) } as unknown as TripMembershipService & Record>; // `PluginGuards.canEditAs` (nest/plugins/host/, out of this task's scope) // also reads `UsersRepository.getRole` now (Plan 3j Task 1) for its own // per-field permission role lookup — the SAME repository method RP4 below // uses for `TripsRpc.update` itself, but through PluginGuards' own // constructor param, stubbed separately just below. const db = { findAccessible: vi.fn(async (tripId: number, userId: number) => tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined, ), } as unknown as TripsRepository; // Plan 3c Task 7: RP1-RP6's SQL-text-keyed `db.prepare` stub is gone — // every raw statement moved to a repository method, so this is a // repository-level stub, keyed on the entity CLASS `em.getRepository(...)` // was asked for, matching the shape the legacy raw `db.prepare` branch // covered: `Trips.findRaw` (RP1/getById), `Places.listForTripOrdered` // (RP2/getPlaces), `TripMembers.listRawUsernameAndDisplayName` // (RP3/members), `Users.getRole` (RP4/update) and `.findIdAndEmail` // (RP5/addMember — id 404 is the "unknown user" case every test below // exercises), `Trips.getOwnerId` (RP6/removeMember — trip 1's owner is 42). const em = { getRepository: vi.fn((entity: unknown) => { if (entity === Trips) { return { findRaw: vi.fn(async (id: number) => ({ id, title: 'Japan', feed_token: null })), getOwnerId: vi.fn(async () => 42), }; } if (entity === Places) { return { listForTripOrdered: vi.fn(async () => [{ id: 7, name: 'Place' }]) }; } if (entity === Users) { return { getRole: vi.fn(async () => 'user'), findIdAndEmail: vi.fn(async (id: number) => (id === 404 ? null : { id, email: `u${id}@example.test` })), }; } if (entity === TripMembers) { return { listRawUsernameAndDisplayName: vi.fn(async () => [ { id: 7, username: 'bob', display_name: null, avatar: null }, ]), }; } throw new Error(`build(): unexpected entity ${String(entity)}`); }), } as unknown as EntityManager; const permissions = { checkPermission: vi.fn((a: string) => (opts.allow ? opts.allow(a) : true)), } as unknown as PermissionsService; const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; const guards = new PluginGuards( db, permissions, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, { getRole: vi.fn(async () => 'user') } as unknown as UsersRepository, ); const rpc = new TripsRpc(trips, reservations, days, membership, realtime, guards, accommodations, roster, em); const host = (...grants: string[]) => new PluginRpcHost( 'p', new Set(grants.length ? grants : ALL_TRIP_GRANTS), makeDeps(), createTestPluginRegistry([rpc]), ); return { trips, reservations, days, accommodations, roster, membership, realtime, permissions, host }; } describe('TripsRpc reads', () => { it('TRIPS-RPC-001 every trip-scoped read is membership-checked', async () => { const f = build(); const host = f.host(); for (const method of [ 'trips.getById', 'trips.getPlaces', 'trips.getReservations', 'trips.getDays', 'trips.getAccommodations', 'trips.members', ]) { expect((await host.dispatch(req(method, { tripId: 1 }), 42)).ok).toBe(true); expect(((await host.dispatch(req(method, { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); } }); it('TRIPS-RPC-002 the cross-trip feeds refuse a userless context instead of listing everything', async () => { const f = build(); const host = f.host(); expect((await host.dispatch(req('trips.listMine'), 42)).ok).toBe(true); expect((await host.dispatch(req('reservations.listMine'), 42)).ok).toBe(true); expect(((await host.dispatch(req('trips.listMine'), undefined)) as RpcError).error.message).toBe( 'trip reads require an authenticated user context', ); expect(((await host.dispatch(req('reservations.listMine'), undefined)) as RpcError).error.message).toBe( 'reservation reads require an authenticated user context', ); expect(f.trips.list).toHaveBeenCalledWith(42, null); }); it('TRIPS-RPC-003 the reservation feed spans the accessible trips', async () => { const f = build(); await f.host().dispatch(req('reservations.listMine'), 42); expect(f.reservations.list).toHaveBeenCalledWith('1'); }); it('TRIPS-RPC-004 getDays returns the days array, not the wrapper the service returns', async () => { const f = build(); const res = await f.host().dispatch(req('trips.getDays', { tripId: 1 }), 42); expect((res as { result: unknown }).result).toEqual([{ id: 3 }]); }); // Task 7 review B's L5 (absorbed here — the RP1 `getById` feed_token strip // was untested): `build()`'s own `Trips.findRaw` stub always returns // `feed_token: null`, which can never distinguish "the key was stripped" // from "the key was never populated" — a fake returning a real-looking // token is the only way to prove `withoutFeedToken` actually ran. // Mutation: removing the `withoutFeedToken(...)` call in // `trips.rpc.ts::getById` turns this red (`result.feed_token` becomes // `'sekret'`). it("TRIPS-RPC-020 getById strips feed_token — the anonymous ICS feed's sole credential never reaches a plugin", async () => { const f = build(); const em = { getRepository: vi.fn((entity: unknown) => { if (entity === Trips) { return { findRaw: vi.fn(async (id: number) => ({ id, title: 'Japan', feed_token: 'sekret' })), getOwnerId: vi.fn(async () => 42), }; } throw new Error(`unexpected entity ${String(entity)}`); }), } as unknown as EntityManager; const db = { findAccessible: vi.fn(async (tripId: number, userId: number) => tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined, ), } as unknown as TripsRepository; const permissions = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService; const guards = new PluginGuards( db, permissions, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, { getRole: vi.fn(async () => 'user') } as unknown as UsersRepository, ); const rpc = new TripsRpc( f.trips, f.reservations, f.days, f.membership, f.realtime, guards, f.accommodations, f.roster, em, ); const host = new PluginRpcHost('p', new Set(ALL_TRIP_GRANTS), makeDeps(), createTestPluginRegistry([rpc])); const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 42); const result = (res as { result: Record }).result; expect(result.title).toBe('Japan'); expect('feed_token' in result).toBe(false); }); }); describe('TripsRpc writes', () => { it('TRIPS-RPC-005 update needs the grant, trip_edit and a bound user', async () => { const f = build(); const host = f.host(); expect((await host.dispatch(req('trips.update', { tripId: 1, input: { title: 'New' } }), 42)).ok).toBe(true); const noUser = (await host.dispatch( req('trips.update', { tripId: 1, input: { title: 'x' } }), undefined, )) as RpcError; expect(noUser.error.message).toBe('trip writes require an authenticated user context'); const readOnly = (await f .host('db:read:trips') .dispatch(req('trips.update', { tripId: 1, input: {} }), 42)) as RpcError; expect(readOnly.error.code).toBe('PERMISSION_DENIED'); }); it('TRIPS-RPC-006 archiving needs trip_archive on top of trip_edit', async () => { const f = build({ allow: (a) => a !== 'trip_archive' }); const res = (await f .host() .dispatch(req('trips.update', { tripId: 1, input: { is_archived: true } }), 42)) as RpcError; expect(res.error.message).toBe('no permission to archive trip 1'); expect(f.trips.updateTrip).not.toHaveBeenCalled(); }); it('TRIPS-RPC-007 changing the cover needs trip_cover_upload on top of trip_edit', async () => { const f = build({ allow: (a) => a !== 'trip_cover_upload' }); const res = (await f .host() .dispatch(req('trips.update', { tripId: 1, input: { cover_image: '/uploads/covers/a.jpg' } }), 42)) as RpcError; expect(res.error.message).toBe('no permission to change the cover of trip 1'); }); it('TRIPS-RPC-008 a plain edit is unaffected by those two field permissions', async () => { const f = build({ allow: (a) => a !== 'trip_archive' && a !== 'trip_cover_upload' }); expect((await f.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'New' } }), 42)).ok).toBe(true); }); it('TRIPS-RPC-009 service errors map onto the right RPC codes', async () => { const invalid = build({ updateThrows: new ValidationError('bad dates') }); expect( ((await invalid.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)) as RpcError) .error, ).toMatchObject({ code: 'BAD_PARAMS', message: 'bad dates' }); const missing = build({ updateThrows: new NotFoundError('trip not found') }); expect( ((await missing.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)) as RpcError) .error, ).toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'trip not found' }); }); it('TRIPS-RPC-010 an over-long title is capped like the REST controller caps it', async () => { const f = build(); const res = (await f .host() .dispatch(req('trips.update', { tripId: 1, input: { title: 'x'.repeat(201) } }), 42)) as RpcError; expect(res.error.message).toBe('title must be 200 characters or fewer'); }); it('TRIPS-RPC-011 create runs under trip_create and never broadcasts', async () => { const f = build(); expect((await f.host().dispatch(req('trips.create', { input: { title: 'Japan' } }), 42)).ok).toBe(true); expect(f.permissions.checkPermission).toHaveBeenCalledWith('trip_create', 'user', null, 42, false); // A new trip is only visible to its owner, who refetches. expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); it('TRIPS-RPC-012 without trip_create the trip is refused', async () => { const f = build({ allow: (a) => a !== 'trip_create' }); const res = (await f.host().dispatch(req('trips.create', { input: { title: 'Japan' } }), 42)) as RpcError; expect(res.error.message).toBe('no permission to create trips'); expect(f.trips.create).not.toHaveBeenCalled(); }); it('TRIPS-RPC-017 a range the service refuses comes back as BAD_PARAMS (#2403)', async () => { const f = build({ createThrows: new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`) }); const res = (await f .host() .dispatch( req('trips.create', { input: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' } }), 42, )) as RpcError; expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: `A trip can span at most ${MAX_TRIP_DAYS} days` }); }); }); describe('TripsRpc membership', () => { it('TRIPS-RPC-013 adding a member needs member_manage and records the inviter', async () => { const f = build(); expect((await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)).ok).toBe(true); expect(f.membership.joinTripAsMember).toHaveBeenCalledWith(1, 7, 42); }); it('TRIPS-RPC-014 member management is its own grant, separate from every other write', async () => { const f = build(); const res = (await f .host('db:write:trips') .dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)) as RpcError; // Adding a member GRANTS TRIP ACCESS, so db:write:trips must not be enough. expect(res.error.code).toBe('PERMISSION_DENIED'); }); it('TRIPS-RPC-015 an unknown target user is refused', async () => { const f = build(); const res = (await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 404 }), 42)) as RpcError; expect(res.error.message).toBe('no user 404'); expect(f.membership.joinTripAsMember).not.toHaveBeenCalled(); }); it('TRIPS-RPC-016 the owner can never be removed through this path', async () => { const f = build(); // The stub reports user 42 as the owner of trip 1. const res = (await f.host().dispatch(req('trips.removeMember', { tripId: 1, userId: 42 }), 42)) as RpcError; expect(res.error.message).toBe('cannot remove the trip owner'); expect(f.roster.removeMember).not.toHaveBeenCalled(); }); it('TRIPS-RPC-017 a non-owner member is removed', async () => { const f = build(); expect((await f.host().dispatch(req('trips.removeMember', { tripId: 1, userId: 7 }), 42)).ok).toBe(true); expect(f.roster.removeMember).toHaveBeenCalledWith(1, 7); }); it('TRIPS-RPC-018 without member_manage nothing changes', async () => { const f = build({ allow: (a) => a !== 'member_manage' }); const res = (await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)) as RpcError; expect(res.error.message).toBe('no permission to edit trip 1'); }); it('TRIPS-RPC-019 the class is listed in its module providers', () => { expectRegisteredProvider(TripsModule, TripsRpc); }); });