mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
679 lines
28 KiB
TypeScript
679 lines
28 KiB
TypeScript
/**
|
|
* 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('');
|
|
});
|
|
});
|