mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +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.
596 lines
27 KiB
TypeScript
596 lines
27 KiB
TypeScript
/**
|
|
* Trip Files integration tests.
|
|
* Covers FILE-001 to FILE-021.
|
|
*
|
|
* Notes:
|
|
* - Tests use fixture files from tests/fixtures/
|
|
* - File uploads create real files in uploads/files/ — tests clean up after themselves where possible
|
|
* - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { authCookie, generateToken } from '../helpers/auth';
|
|
import { createUser, createTrip, createReservation, createPlace, addTripMember } from '../helpers/factories';
|
|
import type { FactoryOrm } from '../helpers/factories/context';
|
|
import { setAppSetting } from '../helpers/factories/settings';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import type { Application } from 'express';
|
|
import fs from 'fs';
|
|
import path from '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());
|
|
});
|
|
|
|
let nestApp: INestApplication;
|
|
let app: Application;
|
|
let orm: FactoryOrm;
|
|
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
|
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
|
|
|
// Ensure uploads/files dir exists
|
|
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
|
|
|
beforeAll(async () => {
|
|
nestApp = await buildApp();
|
|
app = nestApp.getHttpAdapter().getInstance();
|
|
orm = nestApp.get(MikroORM);
|
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
|
// Seed allowed_file_types to include common types (wildcard)
|
|
await setAppSetting(orm, 'allowed_file_types', '*');
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(testDb);
|
|
await resetRateLimits(nestApp);
|
|
// Re-seed allowed_file_types after reset
|
|
await setAppSetting(orm, 'allowed_file_types', '*');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
testDb.close();
|
|
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Helper to upload a file and return the file object
|
|
async function uploadFile(tripId: number, userId: number, fixturePath = FIXTURE_PDF) {
|
|
const res = await request(app)
|
|
.post(`/api/trips/${tripId}/files`)
|
|
.set('Cookie', authCookie(userId))
|
|
.attach('file', fixturePath);
|
|
return res;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Upload file
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Upload file', () => {
|
|
it('FILE-001 — POST uploads a file and returns file metadata', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.file).toBeDefined();
|
|
expect(res.body.file.id).toBeDefined();
|
|
expect(res.body.file.filename).toBeDefined();
|
|
});
|
|
|
|
it('FILE-002 — uploading a blocked extension (.svg) is rejected', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
// Create a temp .svg file
|
|
const svgPath = path.join(uploadsDir, 'test_blocked.svg');
|
|
fs.writeFileSync(svgPath, '<svg></svg>');
|
|
try {
|
|
const res = await request(app)
|
|
.post(`/api/trips/${trip.id}/files`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.attach('file', svgPath);
|
|
expect(res.status).toBe(400);
|
|
// The error is an i18n key the client resolves via t() (issue #1363).
|
|
expect(res.body.error).toBe('files.uploadErrorType');
|
|
} finally {
|
|
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
|
}
|
|
});
|
|
|
|
it('FILE-P01 — stored filename is a bare uuid name and the bytes land in uploads/files', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.file.filename).toMatch(/^[0-9a-f-]{36}\.pdf$/);
|
|
expect(fs.existsSync(path.join(uploadsDir, res.body.file.filename))).toBe(true);
|
|
// Nothing left behind in the spool after a successful commit.
|
|
const spoolDir = path.join(__dirname, '../../uploads/.tmp');
|
|
if (fs.existsSync(spoolDir)) {
|
|
expect(fs.readdirSync(spoolDir)).not.toContain(res.body.file.filename);
|
|
}
|
|
});
|
|
|
|
it('FILE-P02 — non-video over 50 MB is rejected with no bytes left anywhere', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const BIG = 51 * 1024 * 1024;
|
|
// Other suites share the real uploads/ tree when the whole tier runs in
|
|
// parallel, so the residue checks key on this upload's unique size rather
|
|
// than comparing whole-directory listings.
|
|
const bigFilesIn = (dir: string) =>
|
|
fs.existsSync(dir)
|
|
? fs.readdirSync(dir).filter((f) => {
|
|
try {
|
|
return fs.statSync(path.join(dir, f)).size === BIG;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
: [];
|
|
const res = await request(app)
|
|
.post(`/api/trips/${trip.id}/files`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.attach('file', Buffer.alloc(BIG), { filename: 'big.pdf', contentType: 'application/pdf' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('File is too large');
|
|
expect(bigFilesIn(uploadsDir)).toEqual([]);
|
|
expect(bigFilesIn(path.join(__dirname, '../../uploads/.tmp'))).toEqual([]);
|
|
});
|
|
|
|
it('FILE-P03 — non-ASCII original filenames are preserved (defParamCharset utf8)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/trips/${trip.id}/files`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.attach('file', Buffer.from('utf8 name test'), { filename: 'résumé.pdf', contentType: 'application/pdf' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.file.original_name).toBe('résumé.pdf');
|
|
});
|
|
|
|
it('FILE-021 — non-member cannot upload file', 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}/files`)
|
|
.set('Cookie', authCookie(other.id))
|
|
.attach('file', FIXTURE_PDF);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// List files
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('List files', () => {
|
|
it('FILE-006 — GET returns all non-trashed files', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
|
|
|
const res = await request(app).get(`/api/trips/${trip.id}/files`).set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.files.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('FILE-007 — GET ?trash=true returns only trashed files', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
// Soft-delete it
|
|
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
|
|
|
const trash = await request(app).get(`/api/trips/${trip.id}/files?trash=true`).set('Cookie', authCookie(user.id));
|
|
expect(trash.status).toBe(200);
|
|
const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
|
|
expect(trashIds).toContain(fileId);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Star / unstar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Star/unstar file', () => {
|
|
it('FILE-011 — PATCH /:id/star toggles starred status', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.file.starred).toBe(1);
|
|
|
|
// Toggle back
|
|
const res2 = await request(app)
|
|
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(res2.body.file.starred).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Soft delete / restore / permanent delete
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Soft delete, restore, permanent delete', () => {
|
|
it('FILE-012 — DELETE moves file to trash', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const del = await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
|
expect(del.status).toBe(200);
|
|
expect(del.body.success).toBe(true);
|
|
|
|
// Should not appear in normal list
|
|
const list = await request(app).get(`/api/trips/${trip.id}/files`).set('Cookie', authCookie(user.id));
|
|
const ids = (list.body.files as any[]).map((f: any) => f.id);
|
|
expect(ids).not.toContain(fileId);
|
|
});
|
|
|
|
it('FILE-013 — POST /:id/restore restores from trash', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
|
|
|
const restore = await request(app)
|
|
.post(`/api/trips/${trip.id}/files/${fileId}/restore`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(restore.status).toBe(200);
|
|
expect(restore.body.file.id).toBe(fileId);
|
|
});
|
|
|
|
it('FILE-014 — DELETE /:id/permanent permanently deletes from trash', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
|
|
|
const perm = await request(app)
|
|
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(perm.status).toBe(200);
|
|
expect(perm.body.success).toBe(true);
|
|
});
|
|
|
|
it('FILE-015 — DELETE /:id/permanent on non-trashed file returns 404', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
// Not trashed — should 404
|
|
const res = await request(app)
|
|
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('FILE-016 — DELETE /trash/empty empties all trash', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const f1 = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const f2 = await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
|
|
|
await request(app).delete(`/api/trips/${trip.id}/files/${f1.body.file.id}`).set('Cookie', authCookie(user.id));
|
|
await request(app).delete(`/api/trips/${trip.id}/files/${f2.body.file.id}`).set('Cookie', authCookie(user.id));
|
|
|
|
const empty = await request(app)
|
|
.delete(`/api/trips/${trip.id}/files/trash/empty`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(empty.status).toBe(200);
|
|
|
|
const trash = await request(app).get(`/api/trips/${trip.id}/files?trash=true`).set('Cookie', authCookie(user.id));
|
|
expect(trash.body.files).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Update file metadata
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Update file metadata', () => {
|
|
it('FILE-017 — PUT updates description', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const res = await request(app)
|
|
.put(`/api/trips/${trip.id}/files/${fileId}`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ description: 'My important document' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.file.description).toBe('My important document');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// File links
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('File links', () => {
|
|
it('FILE-018/019/020 — link file to reservation, list links, unlink', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const resv = createReservation(testDb, trip.id, { title: 'My Flight', type: 'flight' });
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
// Link (POST /:id/link)
|
|
const link = await request(app)
|
|
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ reservation_id: resv.id });
|
|
expect(link.status).toBe(200);
|
|
expect(link.body.success).toBe(true);
|
|
|
|
// List links (GET /:id/links)
|
|
const links = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/links`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(links.status).toBe(200);
|
|
expect(links.body.links.some((l: any) => l.reservation_id === resv.id)).toBe(true);
|
|
|
|
// Unlink (DELETE /:id/link/:linkId — use the link id from the list)
|
|
const linkId = links.body.links.find((l: any) => l.reservation_id === resv.id)?.id;
|
|
expect(linkId).toBeDefined();
|
|
const unlink = await request(app)
|
|
.delete(`/api/trips/${trip.id}/files/${fileId}/link/${linkId}`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(unlink.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Cross-trip link isolation (GHSA — reservation title disclosure)
|
|
//
|
|
// A file may only point at reservations / assignments / places from its own
|
|
// trip. The reservation JOIN returns the reservation title, so a member of one
|
|
// trip linking a file to another private trip's reservation id used to read the
|
|
// foreign title back. Every write path (link, upload, update) must reject it.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Cross-trip link isolation', () => {
|
|
it('SEC-FILE-LINK-001 — linking a file to a reservation from another trip is rejected (no title leak)', async () => {
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb);
|
|
const attackerTrip = createTrip(testDb, attacker.id, { title: 'Attacker Trip' });
|
|
const victimTrip = createTrip(testDb, victim.id, { title: 'Victim Trip' });
|
|
const victimReservation = createReservation(testDb, victimTrip.id, {
|
|
title: 'Victim Secret Flight',
|
|
type: 'flight',
|
|
});
|
|
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const link = await request(app)
|
|
.post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`)
|
|
.set('Cookie', authCookie(attacker.id))
|
|
.send({ reservation_id: victimReservation.id });
|
|
expect(link.status).toBe(400);
|
|
|
|
// Nothing was stored, so the title cannot leak back through the links list.
|
|
const links = await request(app)
|
|
.get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`)
|
|
.set('Cookie', authCookie(attacker.id));
|
|
expect(links.status).toBe(200);
|
|
expect(JSON.stringify(links.body)).not.toContain('Victim Secret Flight');
|
|
expect((links.body.links as any[]).some((l) => l.reservation_id === victimReservation.id)).toBe(false);
|
|
});
|
|
|
|
it('SEC-FILE-LINK-002 — uploading a file with a foreign reservation_id is rejected (no title leak)', async () => {
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb);
|
|
const attackerTrip = createTrip(testDb, attacker.id);
|
|
const victimTrip = createTrip(testDb, victim.id);
|
|
const victimReservation = createReservation(testDb, victimTrip.id, {
|
|
title: 'Victim Secret Flight',
|
|
type: 'flight',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/trips/${attackerTrip.id}/files`)
|
|
.set('Cookie', authCookie(attacker.id))
|
|
.field('reservation_id', String(victimReservation.id))
|
|
.attach('file', FIXTURE_PDF);
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight');
|
|
});
|
|
|
|
it('SEC-FILE-LINK-003 — updating a file with a foreign reservation_id is rejected (no title leak)', async () => {
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb);
|
|
const attackerTrip = createTrip(testDb, attacker.id);
|
|
const victimTrip = createTrip(testDb, victim.id);
|
|
const victimReservation = createReservation(testDb, victimTrip.id, {
|
|
title: 'Victim Secret Flight',
|
|
type: 'flight',
|
|
});
|
|
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const res = await request(app)
|
|
.put(`/api/trips/${attackerTrip.id}/files/${fileId}`)
|
|
.set('Cookie', authCookie(attacker.id))
|
|
.send({ reservation_id: victimReservation.id });
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight');
|
|
});
|
|
|
|
it('SEC-FILE-LINK-004 — linking a file to a place from another trip is rejected', async () => {
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb);
|
|
const attackerTrip = createTrip(testDb, attacker.id);
|
|
const victimTrip = createTrip(testDb, victim.id);
|
|
const victimPlace = createPlace(testDb, victimTrip.id, { name: 'Victim Secret Place' });
|
|
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const link = await request(app)
|
|
.post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`)
|
|
.set('Cookie', authCookie(attacker.id))
|
|
.send({ place_id: victimPlace.id });
|
|
expect(link.status).toBe(400);
|
|
|
|
const links = await request(app)
|
|
.get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`)
|
|
.set('Cookie', authCookie(attacker.id));
|
|
expect((links.body.links as any[]).some((l) => l.place_id === victimPlace.id)).toBe(false);
|
|
});
|
|
|
|
it('SEC-FILE-LINK-005 — same-trip reservation links and uploads still succeed', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const resv = createReservation(testDb, trip.id, { title: 'My Own Flight', type: 'flight' });
|
|
|
|
// Upload carrying the trip's own reservation id is accepted.
|
|
const upload = await request(app)
|
|
.post(`/api/trips/${trip.id}/files`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.field('reservation_id', String(resv.id))
|
|
.attach('file', FIXTURE_PDF);
|
|
expect(upload.status).toBe(201);
|
|
const fileId = upload.body.file.id;
|
|
|
|
// And linking it to the same reservation works.
|
|
const link = await request(app)
|
|
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ reservation_id: resv.id });
|
|
expect(link.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Download
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('File download', () => {
|
|
it('FILE-010 — GET /:id/download without auth returns 401', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const res = await request(app).get(`/api/trips/${trip.id}/files/${fileId}/download`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('FILE-008 — GET /:id/download with Bearer JWT downloads file', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const token = generateToken(user.id);
|
|
|
|
const dl = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
// multer stores the file to disk during uploadFile — physical file exists
|
|
expect(dl.status).toBe(200);
|
|
});
|
|
|
|
it('FILE-011 — GET /:id/download with trek_session cookie downloads file', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
|
|
const token = generateToken(user.id);
|
|
|
|
const dl = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
|
.set('Cookie', `trek_session=${token}`);
|
|
expect(dl.status).toBe(200);
|
|
});
|
|
|
|
it('FILE-P04 — download serves the exact bytes with Content-Type, ETag, and Range support', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
const fixtureBytes = fs.readFileSync(FIXTURE_PDF);
|
|
|
|
const dl = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(dl.status).toBe(200);
|
|
expect(dl.headers['content-type']).toBe('application/pdf');
|
|
expect(dl.headers.etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/);
|
|
expect(Buffer.from(dl.body).equals(fixtureBytes)).toBe(true);
|
|
|
|
const ranged = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.set('Range', 'bytes=0-3');
|
|
expect(ranged.status).toBe(206);
|
|
expect(ranged.headers['content-range']).toBe(`bytes 0-3/${fixtureBytes.length}`);
|
|
});
|
|
|
|
it('FILE-P05 — Apple Wallet passes are served inline with the canonical MIME types', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
for (const [ext, mime] of [
|
|
['pkpass', 'application/vnd.apple.pkpass'],
|
|
['pkpasses', 'application/vnd.apple.pkpasses'],
|
|
] as const) {
|
|
const upload = await request(app)
|
|
.post(`/api/trips/${trip.id}/files`)
|
|
.set('Cookie', authCookie(user.id))
|
|
.attach('file', Buffer.from('PK-wallet-bytes'), `pass.${ext}`);
|
|
expect(upload.status).toBe(201);
|
|
|
|
const dl = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${upload.body.file.id}/download`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(dl.status).toBe(200);
|
|
expect(dl.headers['content-type']).toBe(mime);
|
|
expect(dl.headers['content-disposition']).toBe(`inline; filename="pass.${ext}"`);
|
|
}
|
|
});
|
|
|
|
it('FILE-P06 — a DB row whose bytes are gone answers 404 File not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
|
const fileId = upload.body.file.id;
|
|
fs.rmSync(path.join(uploadsDir, upload.body.file.filename), { force: true });
|
|
|
|
const dl = await request(app)
|
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(dl.status).toBe(404);
|
|
expect(dl.body).toEqual({ error: 'File not found' });
|
|
});
|
|
});
|