fix(trips): scope the offline bundle's packing items to the viewer (#858)

This commit is contained in:
jubnl
2026-07-26 16:57:02 +02:00
parent 934848e114
commit 7242812b27
5 changed files with 31 additions and 8 deletions
+1 -1
View File
@@ -375,7 +375,7 @@ export class TripsController {
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return this.trips.bundle(id, trip);
return this.trips.bundle(id, trip, user.id);
}
@Get(':id/export.ics')
+4 -2
View File
@@ -130,14 +130,16 @@ export class TripsService {
}
/** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */
bundle(tripId: string, trip: { user_id: number }) {
bundle(tripId: string, trip: { user_id: number }, viewerId: number) {
const { days } = listDays(tripId);
const { owner, members } = this.listMembers(tripId, trip.user_id);
return {
trip,
days,
places: listPlaces(String(tripId), {}),
packingItems: this.packing.listItems(tripId),
// Scope to the requesting member so other members' private packing items
// (#858) never land in this viewer's offline cache.
packingItems: this.packing.listItems(tripId, viewerId),
todoItems: this.todo.listItems(tripId),
budgetItems: listBudgetItems(tripId),
reservations: listReservations(tripId),
+4 -1
View File
@@ -25,12 +25,15 @@ const { db } = vi.hoisted(() => {
name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
due_date TEXT, description TEXT, assigned_user_id INTEGER, priority INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// bundle()'s packingItems now runs PackingService's real SQL (DI-injected, no mock).
// bundle()'s packingItems now runs PackingService's real SQL (DI-injected, no
// mock) — viewer-scoped (#858), so the recipients table must exist too.
tmp.exec(`CREATE TABLE packing_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
name TEXT NOT NULL, checked INTEGER DEFAULT 0, category TEXT, sort_order INTEGER DEFAULT 0,
weight_grams INTEGER, bag_id INTEGER, quantity INTEGER NOT NULL DEFAULT 1,
is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE packing_item_recipients (item_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
PRIMARY KEY (item_id, user_id));`);
return { db: tmp };
});
+15
View File
@@ -1245,4 +1245,19 @@ describe('Trip bundle', () => {
expect(res.status).toBe(401);
});
it('BUNDLE-006 — packingItems are scoped to the viewer: another member\'s private item stays out (#858)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Tent');
testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order, is_private, owner_id) VALUES (?, ?, 0, 1, 1, ?)').run(trip.id, 'Secret gift', owner.id);
const ownerView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(owner.id));
expect(ownerView.body.packingItems.map((i: { name: string }) => i.name).sort()).toEqual(['Secret gift', 'Tent']);
const memberView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(member.id));
expect(memberView.body.packingItems.map((i: { name: string }) => i.name)).toEqual(['Tent']);
});
});
+7 -4
View File
@@ -40,7 +40,8 @@ import type { PackingService } from '../../../src/nest/packing/packing.service';
import { rebaseTripCurrency } from '../../../src/services/budgetService';
const todoStub = { listItems: () => [] } as unknown as TodoService;
const packingStub = { listItems: () => [] } as unknown as PackingService;
const packingListItems = vi.fn(() => []);
const packingStub = { listItems: packingListItems } as unknown as PackingService;
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub); }
beforeEach(() => vi.clearAllMocks());
@@ -99,14 +100,16 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () =>
expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM trips t'));
});
it('bundle aggregates every sub-collection + the member list', () => {
const result = svc().bundle('9', { user_id: 1 });
it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => {
const result = svc().bundle('9', { user_id: 1 }, 7);
expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] });
// The viewer id is forwarded so other members' private items stay out of the offline cache.
expect(packingListItems).toHaveBeenCalledWith('9', 7);
});
it('bundle tolerates a null member list', () => {
tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null });
const result = svc().bundle('9', { user_id: 1 });
const result = svc().bundle('9', { user_id: 1 }, 1);
expect(result).toMatchObject({ members: [{ id: 1 }] });
});