mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fa0edfd714
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
629 lines
28 KiB
TypeScript
629 lines
28 KiB
TypeScript
/**
|
|
* Share link integration tests.
|
|
* Covers SHARE-001 to SHARE-009.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import type { Application } from 'express';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import type { FactoryOrm } from '../helpers/factories/context';
|
|
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
|
import { authCookie } from '../helpers/auth';
|
|
import { deleteRows, insertRow, updateRows } from '../helpers/factories/rows';
|
|
import { setAppSetting } from '../helpers/factories/settings';
|
|
import { makePackingItem } from '../helpers/factories/packing';
|
|
import { makeCollabMessage } from '../helpers/factories/collab';
|
|
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.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 { db as sharedDb } from '../../src/db/database';
|
|
import { LocalDriver } from '../../src/nest/storage/drivers/local.driver';
|
|
import { StorageService } from '../../src/nest/storage/storage.service';
|
|
import type { StorageRegistryService, ResolvedCategory } from '../../src/nest/storage/storage-registry.service';
|
|
import { DEFAULT_UPLOADS_ROOT, GLOBAL_TEMP_DIR } from '../../src/nest/storage/storage-paths';
|
|
import { createTestOrm } from '../helpers/test-orm';
|
|
import { GooglePlacePhotoMeta } from '../../src/db/entities/GooglePlacePhotoMeta.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity';
|
|
|
|
// 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;
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
|
|
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('');
|
|
});
|
|
});
|