Files
TREK/server/tests/e2e/share.e2e.test.ts
T
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00

214 lines
10 KiB
TypeScript

/**
* Share-link e2e — exercises the migrated /api/trips/:tripId/share-link and the
* public /api/shared/:token endpoints through the real JwtAuthGuard against a
* temp SQLite db. ShareService runs its real SQL via DatabaseModule (the
* DATABASE_CONNECTION factory picks up the mocked db singleton); trip access
* resolves through a real-SQL canAccessTrip over the temp db. Only the
* permission check and the photo-cache path lookup stay mocked. Focuses on
* auth, trip-access 404, permission 403, the create-201-vs-update-200 split
* and the unguarded public read.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db,
// Real-SQL trip access over the temp db — ShareService.verifyTripAccess and
// DatabaseModule both read the mocked singleton.
canAccessTrip: (tripId: number | string, 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: () => false,
getPlaceWithTags: () => null,
closeDb: () => {},
reinitialize: () => {},
}));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
// Since the permissions DI migration, the check is a spy on the container's
// PermissionsService singleton (created in beforeAll, after build()).
let checkPermission: MockInstance;
// Overridden in the container rather than path-mocked: the cache is a provider now.
// serveKey hands the controller a bare photos-google storage name (slice 3).
const serveKey = vi.fn();
import path from 'node:path';
import fs from 'node:fs';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { ShareModule } from '../../src/nest/share/share.module';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ShareModule] })
.overrideProvider(PlacePhotoCacheService).useValue({ serveKey })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): the DTO-typed body
// validates by metatype, exactly as it does under buildApp().
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db);
runMigrations(db);
// seedUser() omits password_hash, which the real schema requires NOT NULL.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')",
).run();
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(() => {
db.exec('DELETE FROM share_tokens');
db.exec('DELETE FROM places');
db.exec('DELETE FROM trip_members');
db.exec('DELETE FROM trips');
tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid);
checkPermission.mockReturnValue(true);
serveKey.mockReset();
});
afterAll(async () => {
await app.close();
});
function tokenRow() {
return db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
}
it('401 without a session cookie', async () => {
expect((await request(server).get(`/api/trips/${tripId}/share-link`)).status).toBe(401);
});
it('201 on first create, 200 on a subsequent update', async () => {
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_map: true });
expect(created.status).toBe(201);
expect(created.body).toEqual({ token: tokenRow().token });
const updated = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
expect(updated.status).toBe(200);
expect(updated.body).toEqual({ token: created.body.token });
});
it('403 without share_manage', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
expect(tokenRow()).toBeUndefined();
});
it('404 when the trip is not accessible', async () => {
const res = await request(server).get('/api/trips/999999/share-link').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('GET returns the stored flags, DELETE revokes the link', async () => {
await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_budget: true });
const info = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
expect(info.status).toBe(200);
expect(info.body).toEqual(expect.objectContaining({ token: tokenRow().token, share_budget: true, share_packing: false }));
const removed = await request(server).delete(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
expect(removed.status).toBe(200);
expect(removed.body).toEqual({ success: true });
expect(tokenRow()).toBeUndefined();
const after = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
expect(after.body).toEqual({ token: null });
});
it('carries the narrowing options through to the public read (#1712)', async () => {
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1))
.send({ share_travel_only: true, share_hide_images: true });
const info = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
expect(info.body).toEqual(expect.objectContaining({ share_travel_only: true, share_hide_images: true }));
const res = await request(server).get(`/api/shared/${created.body.token}`);
expect(res.body.permissions).toEqual(expect.objectContaining({ share_travel_only: true, share_hide_images: true }));
expect(res.body.dayNotes).toEqual({});
const bad = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_hide_images: 'yes' });
expect(bad.status).toBe(400);
});
it('public shared read is unguarded (200, no cookie)', async () => {
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
const res = await request(server).get(`/api/shared/${created.body.token}`);
expect(res.status).toBe(200);
expect(res.body.trip).toEqual(expect.objectContaining({ id: tripId, title: 'Trip' }));
expect(res.body.permissions).toEqual(expect.objectContaining({ share_map: true, share_budget: false }));
});
it('public shared read 404 for an invalid token', async () => {
const res = await request(server).get('/api/shared/bad');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Invalid or expired link' });
});
describe('public place-photo proxy (/api/shared/:token/place-photo/:placeId/bytes)', () => {
// The controller streams (category 'photos-google', name) through the real
// storage facade, so the fixture has to live in the real photos-google root.
const photoName = 'trek-share-photo.e2e.jpg';
const photoFile = path.join(__dirname, '../../uploads/photos/google', photoName);
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG-ish header
beforeAll(() => {
fs.mkdirSync(path.dirname(photoFile), { recursive: true });
fs.writeFileSync(photoFile, photoBytes);
});
afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } });
it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => {
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
db.prepare('INSERT INTO places (trip_id, name, image_url) VALUES (?, ?, ?)')
.run(tripId, 'Louvre', '/api/maps/place-photo/ChIJabc/bytes');
serveKey.mockReturnValueOnce(photoName);
const res = await request(server).get(`/api/shared/${created.body.token}/place-photo/ChIJabc/bytes`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(res.headers['cache-control']).toContain('immutable');
expect(Buffer.from(res.body)).toEqual(photoBytes);
expect(serveKey).toHaveBeenCalledWith('ChIJabc');
});
it('answers an empty 204 when the token/place does not resolve to a cached photo', async () => {
const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes');
expect(res.status).toBe(204);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.text ?? '').toBe('');
});
});
});