/** * Share link integration tests. * Covers SHARE-001 to SHARE-009. */ import { buildApp } from '../../src/bootstrap'; import { db as testDb } from '../../src/db/database'; import { db as sharedDb } from '../../src/db/database'; import { AppSettings } from '../../src/db/entities/AppSettings.entity'; import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity'; import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; import { GooglePlacePhotoMeta } from '../../src/db/entities/GooglePlacePhotoMeta.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { ReservationDayPositions } from '../../src/db/entities/ReservationDayPositions.entity'; import { Reservations } from '../../src/db/entities/Reservations.entity'; import { Settings } from '../../src/db/entities/Settings.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import { LocalDriver } from '../../src/nest/storage/drivers/local.driver'; import { DEFAULT_UPLOADS_ROOT, GLOBAL_TEMP_DIR } from '../../src/nest/storage/storage-paths'; import type { StorageRegistryService, ResolvedCategory } from '../../src/nest/storage/storage-registry.service'; import { StorageService } from '../../src/nest/storage/storage.service'; import { authCookie } from '../helpers/auth'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote, } from '../helpers/factories'; import { makeCollabMessage } from '../helpers/factories/collab'; import type { FactoryOrm } from '../helpers/factories/context'; import { makePackingItem } from '../helpers/factories/packing'; import { deleteRows, insertRow, updateRows } from '../helpers/factories/rows'; import { setAppSetting } from '../helpers/factories/settings'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createTestOrm } from '../helpers/test-orm'; import { MikroORM } from '@mikro-orm/core'; import type { INestApplication } from '@nestjs/common'; import type { Application } from 'express'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import request from 'supertest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); // A real instance over the same connection the app uses — these cases write a // cache entry and then read it back through the HTTP route, so the stub // storage must be rooted where the app's registry serves 'photos-google' // from (mode A: uploads/photos/google). const uploadsDriver = new LocalDriver({ id: 'share-test-local', root: DEFAULT_UPLOADS_ROOT }); uploadsDriver.init({ ensurePrefixes: ['photos/google/'], cleanSpool: false }); const testStorage = new StorageService({ resolve: (): ResolvedCategory => ({ driver: uploadsDriver, keyPrefix: 'photos/google/', backendName: 'share-test-local', }), tempDir: () => GLOBAL_TEMP_DIR, replicaFailures: () => [], } as unknown as StorageRegistryService); // Plan 3c Task 1: PlacePhotoCacheService now needs two repositories — a // second, throwaway MikroORM bound to the SAME `sharedDb` connection // (`allowGlobalContext: true`, the same shape `place-photo-cache.service // .test.ts` uses), since this helper's two call sites below run outside any // HTTP request the app's own `withRequestContext` would wrap. let placePhotoCache: PlacePhotoCacheService; let nestApp: INestApplication; let app: Application; let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); orm = nestApp.get(MikroORM); const t = await createTestOrm(sharedDb, { allowGlobalContext: true }); placePhotoCache = new PlacePhotoCacheService( testStorage, t.repo(GooglePlacePhotoMeta), t.repo(Places), t.repo(CollectionPlaces), ); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); }); afterAll(async () => { await nestApp.close(); testDb.close(); }); describe('Share link CRUD', () => { it('SHARE-001 — POST creates share link with default permissions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); expect(res.status).toBe(201); expect(res.body.token).toBeDefined(); expect(typeof res.body.token).toBe('string'); }); it('SHARE-002 — POST creates share link with custom permissions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: false, share_packing: true }); expect(res.status).toBe(201); expect(res.body.token).toBeDefined(); }); it('SHARE-003 — POST again updates share link permissions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const first = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: true }); const second = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: false }); // Same token (update, not create) expect(second.body.token).toBe(first.body.token); }); it('SHARE-004 — GET returns share link status', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.token).toBeDefined(); }); it('SHARE-004 — GET returns null token when no share link exists', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.token).toBeNull(); }); it('SHARE-005 — DELETE removes share link', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); const del = await request(app).delete(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); const status = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)); expect(status.body.token).toBeNull(); }); }); describe('Shared trip access', () => { it('SHARE-006 — GET /shared/:token returns trip data with all sections', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Adventure' }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: true, share_packing: true }); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.trip).toBeDefined(); expect(res.body.trip.title).toBe('Paris Adventure'); }); it('SHARE-007 — GET /shared/:token hides budget when share_budget=false', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: false }); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); // Budget should be an empty array when share_budget is false expect(Array.isArray(res.body.budget)).toBe(true); expect(res.body.budget).toHaveLength(0); }); // Regression: a co-member's private packing item (#858) must never reach a public share. it('SHARE-026 — hides private packing items from the public payload', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await makePackingItem(orm, trip.id, { name: 'Private thing', category: 'Misc', checked: 0, is_private: 1, owner: user.id, }); await makePackingItem(orm, trip.id, { name: 'Common thing', category: 'Misc', checked: 0, is_private: 0, owner: user.id, }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_packing: true }); const res = await request(app).get(`/api/shared/${create.body.token}`); expect(res.status).toBe(200); const names = (res.body.packing || []).map((p: any) => p.name); expect(names).toContain('Common thing'); expect(names).not.toContain('Private thing'); }); // Regression — GHSA-9hc8-p7gm-p7mx: share_map must be enforced server-side, not // just hidden in the client. When the owner disables the map, the itinerary and // every place (with coordinates) must be withheld from the public payload. it('SHARE-024 — hides itinerary and places when share_map=false', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Secret Route' }); const day = createDay(testDb, trip.id, { date: '2025-06-01' }); const place = createPlace(testDb, trip.id, { name: 'Safehouse', lat: 12.3456, lng: 65.4321 }); createDayAssignment(testDb, day.id, place.id); createDayNote(testDb, day.id, trip.id, { text: 'Do not share' }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_map: false, share_packing: true }); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.permissions.share_map).toBe(false); // Itinerary + place data withheld… expect(res.body.days).toHaveLength(0); expect(res.body.places).toHaveLength(0); expect(res.body.assignments).toEqual({}); expect(res.body.dayNotes).toEqual({}); // …and the coordinates never appear anywhere in the response. expect(JSON.stringify(res.body)).not.toContain('12.3456'); expect(JSON.stringify(res.body)).not.toContain('65.4321'); }); it('SHARE-008 — GET /shared/:invalid-token returns 404', async () => { const res = await request(app).get('/api/shared/invalid-token-xyz'); expect(res.status).toBe(404); }); it('SHARE-009 — non-member cannot create share link', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(other.id)) .send({}); expect(res.status).toBe(404); }); it('SHARE-026 — a plain member cannot read the share token', async () => { // Reading the link hands out the credential for the anonymous /api/shared // page, which outlives the membership that was used to fetch it. Under the // default policy share_manage stays with the owner. const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(owner.id)).send({}); const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(member.id)); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'No permission' }); }); it('SHARE-027 — the owner still reads it, and a non-member still gets 404 rather than 403', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(owner.id)).send({}); const mine = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(owner.id)); expect(mine.status).toBe(200); expect(mine.body.token).toBeTruthy(); // 404, not 403: a stranger must not learn that the trip id exists. const theirs = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(stranger.id)); expect(theirs.status).toBe(404); }); it('SHARE-028 — a member may read it once the instance lowers share_manage to trip_member', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(owner.id)).send({}); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await setAppSetting(orm, 'perm_share_manage', 'trip_member'); await invalidatePermissionsCache(); try { const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); expect(res.body.token).toBeTruthy(); } finally { // Module-scoped cache: leaving it set would decide the next file's tests. await deleteRows(orm, AppSettings, { key: 'perm_share_manage' }); await invalidatePermissionsCache(); } }); }); describe('Shared trip — day assignments and notes', () => { it('SHARE-010 — shared trip with days and assignments includes place data in assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome Trip' }); const day = createDay(testDb, trip.id, { date: '2025-06-01' }); const place = createPlace(testDb, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 }); createDayAssignment(testDb, day.id, place.id, { notes: 'Amazing site' }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({}); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.days).toHaveLength(1); const dayAssignments = res.body.assignments[day.id]; expect(Array.isArray(dayAssignments)).toBe(true); expect(dayAssignments).toHaveLength(1); expect(dayAssignments[0].place.name).toBe('Colosseum'); expect(dayAssignments[0].place.lat).toBe(41.89); }); it('SHARE-011 — shared trip with day notes includes notes in response', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Notes Trip' }); const day = createDay(testDb, trip.id, { date: '2025-07-01' }); createDayNote(testDb, day.id, trip.id, { text: 'Meet at the station' }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({}); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); const dayNotes = res.body.dayNotes[day.id]; expect(Array.isArray(dayNotes)).toBe(true); expect(dayNotes).toHaveLength(1); expect(dayNotes[0].text).toBe('Meet at the station'); }); it('SHARE-012 — share_collab=true includes collab messages in response', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await makeCollabMessage(orm, trip.id, user.id, { text: 'Hello team!', deleted: 0 }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_collab: true }); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(Array.isArray(res.body.collab)).toBe(true); expect(res.body.collab).toHaveLength(1); expect(res.body.collab[0].text).toBe('Hello team!'); }); it('SHARE-013 — assignments empty when days have no assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createDay(testDb, trip.id, { date: '2025-08-01' }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({}); const token = create.body.token; const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.days).toHaveLength(1); expect(res.body.assignments).toEqual({}); }); }); describe('Shared trip — ordering parity (issue #981)', () => { it('SHARE-014 — assignments with same order_index are ordered by created_at (tiebreaker)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2025-09-01' }); const place1 = createPlace(testDb, trip.id, { name: 'First Created' }); const place2 = createPlace(testDb, trip.id, { name: 'Second Created' }); // Both with order_index = 0 (schema default) but different created_at await insertRow(orm, DayAssignments, { day: day.id, place: place1.id, order_index: 0, created_at: '2025-01-01T10:00:00', }); await insertRow(orm, DayAssignments, { day: day.id, place: place2.id, order_index: 0, created_at: '2025-01-01T11:00:00', }); const { body: { token }, } = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); const assignments = res.body.assignments[day.id]; expect(assignments).toHaveLength(2); expect(assignments[0].place.name).toBe('First Created'); expect(assignments[1].place.name).toBe('Second Created'); }); it('SHARE-015 — reservations include day_positions map from reservation_day_positions table', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2025-09-01' }); const reservationId = await insertRow(orm, Reservations, { trip: trip.id, title: 'Test Flight', type: 'flight', day: day.id, reservation_time: '2025-09-01T09:00:00', }); // Insert a per-day position await insertRow(orm, ReservationDayPositions, { reservation: reservationId, day: day.id, position: 1.5 }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_bookings: true }); const shareRes = await request(app).get(`/api/shared/${token}`); expect(shareRes.status).toBe(200); const reservation = shareRes.body.reservations.find((r: any) => r.id === reservationId); expect(reservation).toBeDefined(); expect(reservation.day_positions).toBeDefined(); expect(reservation.day_positions[day.id]).toBe(1.5); }); }); describe('Shared trip — display currency (issue #1361)', () => { it("SHARE-021 — baseCurrency resolves from the share owner's default_currency setting", async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Trip keeps the EUR default; the owner's Costs display currency is CAD. await insertRow(orm, Settings, { user: user.id, key: 'default_currency', value: JSON.stringify('CAD') }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: true }); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.baseCurrency).toBe('CAD'); }); it('SHARE-022 — baseCurrency falls back to the trip currency when the owner has no setting', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await updateRows(orm, Trips, { id: trip.id }, { currency: 'GBP' }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: true }); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.baseCurrency).toBe('GBP'); }); it('SHARE-023 — baseCurrency uses the admin instance default when the owner has no per-user setting', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // EUR trip default, no user setting await insertRow(orm, AppSettings, { key: 'default_user_setting_default_currency', value: JSON.stringify('USD') }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_budget: true }); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.baseCurrency).toBe('USD'); }); }); describe('Shared trip: CARTO tile key (issue #2054)', () => { it("SHARE-029: the payload carries the owner's carto_api_key, behind it the admin instance default", async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await insertRow(orm, AppSettings, { key: 'default_user_setting_carto_api_key', value: 'instance-key' }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_map: true }); const inherited = await request(app).get(`/api/shared/${token}`); expect(inherited.status).toBe(200); expect(inherited.body.cartoApiKey).toBe('instance-key'); await insertRow(orm, Settings, { user: user.id, key: 'carto_api_key', value: 'owner-key' }); const own = await request(app).get(`/api/shared/${token}`); expect(own.body.cartoApiKey).toBe('owner-key'); }); }); describe('Shared trip — place photos in shared links (issue #1100)', () => { const PLACE_ID = 'ChIJsharedPhoto1100'; const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`; const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); let cachedFilePath: string; afterAll(() => { try { if (cachedFilePath) fs.unlinkSync(cachedFilePath); } catch { /* ignore */ } }); async function setupSharedPlaceWithPhoto() { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Photo Place' }); await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL, google_place_id: PLACE_ID }); const { body: { token }, } = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); return { token, place }; } it('SHARE-016 — shared payload rewrites place image_url to the public token-scoped proxy', async () => { const { token } = await setupSharedPlaceWithPhoto(); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); const place = res.body.places.find((p: any) => p.image_url); expect(place.image_url).toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`); expect(place.image_url.startsWith('/api/maps/')).toBe(false); }); it('SHARE-017 — shared payload rewrites assignment place image_url too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2025-10-01' }); const place = createPlace(testDb, trip.id, { name: 'Assigned Photo Place' }); await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL }); createDayAssignment(testDb, day.id, place.id, {}); const { body: { token }, } = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({}); const res = await request(app).get(`/api/shared/${token}`); expect(res.status).toBe(200); expect(res.body.assignments[day.id][0].place.image_url).toBe( `/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`, ); }); it('SHARE-018 — public proxy streams cached bytes for a valid token + place (no cookie)', async () => { const { token } = await setupSharedPlaceWithPhoto(); await placePhotoCache.put(PLACE_ID, photoBytes, null); cachedFilePath = path.join( DEFAULT_UPLOADS_ROOT, 'photos/google', `${crypto.createHash('sha1').update(PLACE_ID).digest('hex')}.jpg`, ); const res = await request(app).get(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/jpeg'); expect(Buffer.from(res.body)).toEqual(photoBytes); }); // Every miss shape answers the same empty 204 (fix: mirrors the maps proxy, // #1727 extended to shared pages). The service still collapses "not in // trip" / "bad token" / "map disabled" into one indistinguishable null, so // no authorization detail leaks through the status change. it('SHARE-019 — public proxy answers an empty 204 for a placeId not in the shared trip', async () => { const { token } = await setupSharedPlaceWithPhoto(); const res = await request(app).get(`/api/shared/${token}/place-photo/ChIJnotInTrip/bytes`); expect(res.status).toBe(204); expect(res.headers['cache-control']).toBe('no-store'); expect(res.text ?? '').toBe(''); }); it('SHARE-020 — public proxy answers an empty 204 for an invalid token', async () => { await setupSharedPlaceWithPhoto(); const res = await request(app).get(`/api/shared/bad-token/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`); expect(res.status).toBe(204); expect(res.headers['cache-control']).toBe('no-store'); expect(res.text ?? '').toBe(''); }); // Regression — GHSA-9hc8 sibling: place photos are part of the map/itinerary, // so the proxy must stream nothing when the owner disabled the map, even // with a valid token + cached bytes. it('SHARE-025 — public place-photo proxy streams nothing when share_map=false', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Hidden Photo Place' }); await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL, google_place_id: PLACE_ID }); const { body: { token }, } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) .send({ share_map: false }); await placePhotoCache.put(PLACE_ID, photoBytes, null); cachedFilePath = path.join( DEFAULT_UPLOADS_ROOT, 'photos/google', `${crypto.createHash('sha1').update(PLACE_ID).digest('hex')}.jpg`, ); const res = await request(app).get(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`); expect(res.status).toBe(204); expect(res.text ?? '').toBe(''); }); });