/** * The packing plugin surface after it moved onto @PluginMethod. * * The four public/private transitions (#858) are the reason this file is long. Each * one is asserted separately, because getting any of them wrong leaks a private item * to the whole trip room, and because the logic now lives once in PackingService * instead of three times over. */ 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 { AddonsService } from '../../../src/nest/addons/addons.service'; import { PackingModule } from '../../../src/nest/packing/packing.module'; import { PackingRpc } from '../../../src/nest/packing/packing.rpc'; import { PackingService } from '../../../src/nest/packing/packing.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 { expectRegisteredProvider } from '../../helpers/module-providers'; import { notificationsStub } from '../../helpers/notifications'; import { makeDeps } from '../../helpers/rpc-host-deps'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params, }); type Item = { id: number; name?: string; is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[]; }; /** * A REAL PackingService over a fake realtime, so the broadcast fan-out under test is * the production one; only the data methods are stubbed. */ function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: Item | null } = {}) { const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; const packing = new PackingService( {} as never, realtime, notificationsStub(), {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); const data = { listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]), // The bodies here are the boring fixture; the item writes are typed as Item so a // case can hand back the privacy fields the real service selects (#858), which the // fixture itself never bothers to set. createItem: vi.fn((_t: string, i: Record): Item => ({ id: 70, ...i })), getItemPrivacy: vi.fn(() => opts.before), updateItem: vi.fn(() => (opts.updated === undefined ? { id: 70, is_private: 0 } : opts.updated)), // Plan 4 Task 8b (U6) — the id/bagId param is now a real number (num()-parsed // in packing.rpc.ts, no longer String()-wrapped), so the fixture matches on 70/80. deleteItem: vi.fn((_t: string, id: number): Item | null => (id === 70 ? { id: 70, is_private: 0 } : null)), listBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]), createBag: vi.fn((_t: string, b: Record) => ({ id: 80, ...b })), updateBag: vi.fn((_t: string, id: number) => (id === 80 ? { id: 80 } : null)), deleteBag: vi.fn((_t: string, id: number) => id === 80), setBagMembers: vi.fn((_t: string, id: number, ids: number[]) => (id === 80 ? { bagId: 80, members: ids } : null)), }; Object.assign(packing, data); const guards = new PluginGuards( { findAccessible: vi.fn(async (tripId: number, userId: number) => tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined, ), } as unknown as TripsRepository, { checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, { getRole: vi.fn(async () => 'user') } as unknown as UsersRepository, ); const rpc = new PackingRpc(packing, realtime, guards); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc])); return { packing, data, realtime, rpc, host }; } /** Every (event, onlyUserId) pair the fan-out produced, in order. */ const fanout = (realtime: { broadcast: ReturnType }) => realtime.broadcast.mock.calls.map((c) => [c[1], c[4]]); describe('PackingRpc through the router', () => { it('PACKING-RPC-001 packing.list is membership-checked and scoped to the acting user', async () => { const f = build(); const host = f.host('db:read:packing'); expect((await host.dispatch(req('packing.list', { tripId: 1 }), 42)).ok).toBe(true); // The user id is handed through so the #858 visibility filter applies. expect(f.data.listItems).toHaveBeenCalledWith(1, 42); expect(((await host.dispatch(req('packing.list', { tripId: 2 }), 42)) as RpcError).error.code).toBe( 'RESOURCE_FORBIDDEN', ); }); it('PACKING-RPC-002 create needs the write grant, the edit right and a bound user', async () => { const f = build(); const host = f.host('db:write:packing'); expect((await host.dispatch(req('packing.create', { tripId: 1, input: { name: 'Socks' } }), 42)).ok).toBe(true); expect((await host.dispatch(req('packing.create', { tripId: 1, input: { name: '' } }), 42)).ok).toBe(false); expect( ((await host.dispatch(req('packing.create', { tripId: 2, input: { name: 'x' } }), 42)) as RpcError).error.code, ).toBe('RESOURCE_FORBIDDEN'); const noUser = (await host.dispatch( req('packing.create', { tripId: 1, input: { name: 'x' } }), undefined, )) as RpcError; expect(noUser.error.message).toBe('packing item writes require an authenticated user context'); }); it('PACKING-RPC-003 db:read:packing does not unlock a write', async () => { const f = build(); const res = (await f .host('db:read:packing') .dispatch(req('packing.create', { tripId: 1, input: { name: 'x' } }), 42)) as RpcError; expect(res.error.code).toBe('PERMISSION_DENIED'); }); it('PACKING-RPC-004 a missing item is RESOURCE_FORBIDDEN, naming it', async () => { const f = build({ updated: null }); const res = (await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 404, input: { name: 'x' } }), 42)) as RpcError; expect(res.error.message).toBe('no packing item 404 on trip 1'); }); it('PACKING-RPC-005 bags are gated on the write grant too, listBags included', async () => { const f = build(); const host = f.host('db:write:packing'); expect((await host.dispatch(req('packing.listBags', { tripId: 1 }), 42)).ok).toBe(true); expect((await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag' } }), 42)).ok).toBe(true); expect((await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [5, 6] }), 42)).ok).toBe( true, ); expect(f.data.setBagMembers).toHaveBeenCalledWith('1', 80, [5, 6]); }); it('PACKING-RPC-006 a bag name is required, and a missing bag is named', async () => { const f = build(); const host = f.host('db:write:packing'); expect( ((await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: ' ' } }), 42)) as RpcError).error .message, ).toBe('bag name is required'); expect( ((await host.dispatch(req('packing.updateBag', { tripId: 1, bagId: 404, input: {} }), 42)) as RpcError).error .message, ).toBe('no packing bag 404 on trip 1'); }); it('PACKING-RPC-007 non-numeric entries in userIds are dropped', async () => { const f = build(); await f .host('db:write:packing') .dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [5, 'six', null, 6] }), 42); expect(f.data.setBagMembers).toHaveBeenCalledWith('1', 80, [5, 6]); }); it('PACKING-RPC-007b a non-string bag colour is dropped rather than stored', async () => { const f = build(); await f .host('db:write:packing') .dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag', color: 42 } }), 42); expect(f.data.createBag).toHaveBeenCalledWith('1', { name: 'Bag', color: undefined }); }); it('PACKING-RPC-007c a non-array userIds becomes an empty list', async () => { const f = build(); await f .host('db:write:packing') .dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: 'nope' }), 42); expect(f.data.setBagMembers).toHaveBeenCalledWith('1', 80, []); }); it('PACKING-RPC-007d a missing bag is refused across every bag write', async () => { const f = build(); const host = f.host('db:write:packing'); for (const [method, params] of [ ['packing.updateBag', { tripId: 1, bagId: 404, input: {} }], ['packing.deleteBag', { tripId: 1, bagId: 404 }], ['packing.setBagMembers', { tripId: 1, bagId: 404, userIds: [] }], ] as const) { expect(((await host.dispatch(req(method, params), 42)) as RpcError).error.message).toBe( 'no packing bag 404 on trip 1', ); } }); it('PACKING-RPC-007f deleting a bag also pings the weights (#2191)', async () => { // packing_items.bag_id is ON DELETE SET NULL, so the bag's contents land in // the unassigned pile: both the bag figure and the loose figure move. const f = build(); await f.host('db:write:packing').dispatch(req('packing.deleteBag', { tripId: 1, bagId: 80 }), 42); expect(fanout(f.realtime)).toEqual([ ['packing:bag-deleted', undefined], ['packing:bag-totals', undefined], ]); }); it('PACKING-RPC-007e an invalid item payload is BAD_PARAMS on create and update alike', async () => { const f = build(); const host = f.host('db:write:packing'); expect( ((await host.dispatch(req('packing.create', { tripId: 1, input: { name: 42 } }), 42)) as RpcError).error.code, ).toBe('BAD_PARAMS'); expect( ((await host.dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { name: 42 } }), 42)) as RpcError) .error.code, ).toBe('BAD_PARAMS'); }); it('PACKING-RPC-007f deleting a missing item is refused', async () => { const f = build(); const res = (await f .host('db:write:packing') .dispatch(req('packing.delete', { tripId: 1, itemId: 404 }), 42)) as RpcError; expect(res.error.message).toBe('no packing item 404 on trip 1'); }); it('PACKING-RPC-018 create hands the widened fields through the schema (#2154)', async () => { const f = build(); await f .host('db:write:packing') .dispatch( req('packing.create', { tripId: 1, input: { name: 'Tent', weight_grams: 250, bag_id: 19, quantity: 3 } }), 42, ); expect(f.data.createItem).toHaveBeenCalledWith( '1', { name: 'Tent', weight_grams: 250, bag_id: 19, quantity: 3 }, 42, ); }); it('PACKING-RPC-019 an off-trip bag is BAD_PARAMS on create and update alike, broadcasting nothing (#2154)', async () => { const f = build(); const host = f.host('db:write:packing'); f.data.createItem.mockReturnValueOnce({ invalidBag: true } as never); const created = (await host.dispatch( req('packing.create', { tripId: 1, input: { name: 'Tent', bag_id: 404 } }), 42, )) as RpcError; expect(created.error.code).toBe('BAD_PARAMS'); expect(created.error.message).toBe('no packing bag 404 on trip 1'); f.data.updateItem.mockReturnValueOnce({ invalidBag: true } as never); const updated = (await host.dispatch( req('packing.update', { tripId: 1, itemId: 70, input: { bag_id: 404 } }), 42, )) as RpcError; expect(updated.error.code).toBe('BAD_PARAMS'); expect(updated.error.message).toBe('no packing bag 404 on trip 1'); // The sentinel must never leave the process as an item. expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); it('PACKING-RPC-020 createBag forwards a numeric weight limit and drops a non-number (#2154)', async () => { const f = build(); const host = f.host('db:write:packing'); await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag', weight_limit_grams: 23000 } }), 42); expect(f.data.createBag).toHaveBeenCalledWith('1', { name: 'Bag', color: undefined, weight_limit_grams: 23000 }); await host.dispatch( req('packing.createBag', { tripId: 1, input: { name: 'Bag', weight_limit_grams: 'heavy' } }), 42, ); expect(f.data.createBag).toHaveBeenLastCalledWith('1', { name: 'Bag', color: undefined, weight_limit_grams: undefined, }); }); it('PACKING-RPC-008 the class is listed in its module providers', () => { expectRegisteredProvider(PackingModule, PackingRpc); }); }); describe('PackingRpc keeps private items off the room (#858)', () => { it('PACKING-RPC-009 a common item is created for the whole room', async () => { const f = build(); await f.host('db:write:packing').dispatch(req('packing.create', { tripId: 1, input: { name: 'Socks' } }), 42); // The trailing bag-totals ping is room-wide and content-free (#2191): bag // weights are summed server-side, so every member has to re-read them even // when the item itself is none of their business. expect(fanout(f.realtime)).toEqual([ ['packing:created', undefined], ['packing:bag-totals', undefined], ]); }); it('PACKING-RPC-010 a private item is created only for its owner and recipients', async () => { const f = build(); f.data.createItem.mockReturnValueOnce({ id: 70, is_private: 1, owner_id: 42, recipients: [{ user_id: 7 }] }); await f .host('db:write:packing') .dispatch(req('packing.create', { tripId: 1, input: { name: 'Gift', is_private: true } }), 42); // The item goes only to its viewers; the weight ping goes to the room and // says nothing at all (#2191) — that is exactly what makes it safe here. expect(fanout(f.realtime)).toEqual([ ['packing:created', 42], ['packing:created', 7], ['packing:bag-totals', undefined], ]); }); it('PACKING-RPC-011 private stays private: an owner-only update, never a room event', async () => { const f = build({ before: { id: 70, is_private: 1 }, updated: { id: 70, is_private: 1, owner_id: 42 } }); await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { name: 'x' } }), 42); expect(fanout(f.realtime)).toEqual([['packing:updated', 42]]); }); it('PACKING-RPC-012 public to private: dropped from the room FIRST, then re-added for the owner', async () => { const f = build({ before: { id: 70, is_private: 0 }, updated: { id: 70, is_private: 1, owner_id: 42 } }); await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { is_private: true } }), 42); // The order matters: a member who already had the item must lose it before the // owner gets the private copy. expect(fanout(f.realtime)).toEqual([ ['packing:deleted', undefined], ['packing:created', 42], ]); }); it('PACKING-RPC-013 private to public: created for the members who lacked it, then updated for all', async () => { const f = build({ before: { id: 70, is_private: 1 }, updated: { id: 70, is_private: 0 } }); await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { is_private: false } }), 42); expect(fanout(f.realtime)).toEqual([ ['packing:created', undefined], ['packing:updated', undefined], ]); }); it('PACKING-RPC-014 public stays public: one plain update to everyone', async () => { const f = build({ before: { id: 70, is_private: 0 }, updated: { id: 70, is_private: 0 } }); await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { name: 'x' } }), 42); expect(fanout(f.realtime)).toEqual([['packing:updated', undefined]]); }); it('PACKING-RPC-015 the privacy is read BEFORE the write, not after', async () => { const f = build({ before: { id: 70, is_private: 0 }, updated: { id: 70, is_private: 1, owner_id: 42 } }); await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { is_private: true } }), 42); // Reading it afterwards would report "already private" and skip the room delete, // leaving the item on every member's screen. expect(f.data.getItemPrivacy.mock.invocationCallOrder[0]).toBeLessThan( f.data.updateItem.mock.invocationCallOrder[0], ); }); it('PACKING-RPC-016 deleting a private item tells only its viewers', async () => { const f = build(); f.data.deleteItem.mockReturnValueOnce({ id: 70, is_private: 1, owner_id: 42, recipients: [{ user_id: 7 }] }); await f.host('db:write:packing').dispatch(req('packing.delete', { tripId: 1, itemId: 70 }), 42); // Same split as the create above: private item to its viewers, content-free // weight ping to the room (#2191). expect(fanout(f.realtime)).toEqual([ ['packing:deleted', 42], ['packing:deleted', 7], ['packing:bag-totals', undefined], ]); }); it('PACKING-RPC-016b a stale-write conflict is BAD_PARAMS and broadcasts nothing', async () => { const f = build({ before: { id: 70, is_private: 0 }, updated: { conflict: true, server: { id: 70 } } as never }); const res = (await f .host('db:write:packing') .dispatch(req('packing.update', { tripId: 1, itemId: 70, input: { name: 'x' } }), 42)) as RpcError; expect(res.error.code).toBe('BAD_PARAMS'); expect(res.error.message).toBe('packing item was modified concurrently'); expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); it('PACKING-RPC-017 a refused write broadcasts nothing at all', async () => { const f = build({ canEdit: false }); await f.host('db:write:packing').dispatch(req('packing.create', { tripId: 1, input: { name: 'x' } }), 42); expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); });