Files
TREK/server/tests/integration/share.test.ts
T
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00

431 lines
17 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';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import * as placePhotoCache from '../../src/services/placePhotoCache';
import fs from 'node:fs';
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
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);
});
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);
});
});
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);
testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!');
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
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
).run(day.id, place1.id);
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
).run(day.id, place2.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);
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 res1 = testDb.prepare(
"INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
const reservationId = Number(res1.lastInsertRowid);
// Insert a per-day position
testDb.prepare(
'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
).run(reservationId, day.id, 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 — 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' });
testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, 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' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(PROXY_URL, place.id);
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();
const cached = await placePhotoCache.put(PLACE_ID, photoBytes, null);
cachedFilePath = cached.filePath;
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);
});
it('SHARE-019 — public proxy 404s 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(404);
expect(res.body).toEqual({ error: 'Photo not cached' });
});
it('SHARE-020 — public proxy 404s 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(404);
expect(res.body).toEqual({ error: 'Photo not cached' });
});
});