mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
22d178d2f9
Two pieces of copy-paste in the guard layer. Three addon guards — collections, airtrail, journey — did the same thing and differed only in the addon id they read and the word leading the 404. They are one AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through the Reflector. A handler-level decorator overrides the controller's, and a route without the decorator is not gated at all. The demo-mode upload block was copy-pasted into six handlers, each carrying its own copy of the message. All six call isDemoWriteBlocked now, against the same DEMO_WRITE_ERROR body. That one is deliberately NOT a guard, though all six sites are handlers and a guard is the obvious shape — and this PR did build one first. Guards run before the multipart parser, so throwing from one leaves the request body unread and Node resets the connection: the client gets ECONNRESET instead of the 403. PROFILE-015 caught it. The check has to sit inside the handler, after the interceptor has drained the upload, for the response to arrive at all. The function carries that reasoning so the next person does not rediscover it. Two smaller findings on the way there. The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode: AuthModule listed it as a provider, and RuntimeEnvService was not resolvable inside a TestingModule built around one domain module. AppConfigModule is @Global, but @Global only applies to modules that are in the graph. The five modules whose controllers now inject RuntimeEnvService import it explicitly, the same way PermissionsModule is handled. And both new guards get a test asserting the decorators are actually applied. Once a check moves out of a handler, that handler's own tests stop noticing if the decorator is dropped — the assertions on __guards__ and the @RequireAddon metadata are what keeps the four upload endpoints and the four addon route groups gated, and gated on the right addon.
289 lines
19 KiB
TypeScript
289 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
|
|
|
import { FilesController } from '../../../src/nest/files/files.controller';
|
|
import { FilesDownloadController } from '../../../src/nest/files/files-download.controller';
|
|
import { PhotosController } from '../../../src/nest/photos/photos.controller';
|
|
import type { FilesService } from '../../../src/nest/files/files.service';
|
|
import type { PhotosService } from '../../../src/nest/photos/photos.service';
|
|
import { isDemoEmail } from '../../../src/nest/common/demo';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
|
|
|
function fsvc(o: Partial<FilesService> = {}): FilesService {
|
|
return {
|
|
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
|
can: vi.fn().mockReturnValue(true),
|
|
findForeignLinkTarget: vi.fn().mockReturnValue(null),
|
|
broadcast: vi.fn(),
|
|
...o,
|
|
} as unknown as FilesService;
|
|
}
|
|
|
|
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
|
try { fn(); } catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
afterEach(() => { delete process.env.DEMO_MODE; });
|
|
|
|
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
|
|
it('GET / 404 without access, else lists with the trash flag', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }), new RuntimeEnvService()).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(new FilesController(fsvc({ listFiles } as Partial<FilesService>), new RuntimeEnvService()).list(user, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
|
expect(listFiles).toHaveBeenCalledWith('5', true);
|
|
});
|
|
|
|
describe('POST / (upload)', () => {
|
|
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
|
it('403 in demo mode for a demo email', () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
vi.mocked(isDemoEmail).mockReturnValue(true);
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
|
});
|
|
it('403 without file_upload, 400 without a file, else creates + broadcasts', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
|
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const broadcast = vi.fn();
|
|
const s = fsvc({ createFile, broadcast } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).upload(user, '5', file, { description: 'd' }, 'sock')).toEqual({ file: { id: 9 } });
|
|
expect(createFile).toHaveBeenCalledWith('5', file, 1, { place_id: undefined, description: 'd', reservation_id: undefined });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 9 } }, 'sock');
|
|
});
|
|
|
|
it('caps non-video by extension and accepts large video; cleans up on rejection (#823)', () => {
|
|
const big = { filename: 'big.pdf', originalname: 'big.pdf', size: 60 * 1024 * 1024, path: '' } as Express.Multer.File;
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', big, {}))).toEqual({ status: 400, body: { error: 'File is too large' } });
|
|
|
|
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const vid = { filename: 'v.mp4', originalname: 'clip.mp4', size: 200 * 1024 * 1024, path: '' } as Express.Multer.File;
|
|
expect(new FilesController(fsvc({ createFile, broadcast: vi.fn() } as Partial<FilesService>), new RuntimeEnvService()).upload(user, '5', vid, {})).toEqual({ file: { id: 9 } });
|
|
|
|
// A rejected upload with a real path triggers the unlink cleanup branch
|
|
// (the file doesn't exist, so the inner best-effort catch swallows it).
|
|
const withPath = { filename: 'a.pdf', path: '/nonexistent/zzz.pdf' } as Express.Multer.File;
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).upload(user, '5', withPath, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
|
});
|
|
});
|
|
|
|
it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).update(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const updateFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).update(user, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
|
|
});
|
|
|
|
it('PATCH /:id/star 403/404, else toggles', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).star(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).star(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 });
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).star(user, '5', '9')).toEqual({ file: { id: 9, starred: 1 } });
|
|
expect(toggleStarred).toHaveBeenCalledWith('9', 0);
|
|
});
|
|
|
|
it('DELETE /:id soft-delete 403/404, else success', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).remove(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const softDeleteFile = vi.fn();
|
|
const broadcast = vi.fn();
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'file:deleted', { fileId: 9 }, 'sock');
|
|
});
|
|
|
|
it('POST /:id/restore 404 not in trash, else restores', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).restore(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
|
const restoreFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).restore(user, '5', '9')).toEqual({ file: { id: 9 } });
|
|
});
|
|
|
|
it('DELETE /:id/permanent 404 not in trash, else deletes', async () => {
|
|
await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException);
|
|
const permanentDeleteFile = vi.fn().mockResolvedValue(undefined);
|
|
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, '5', '9')).toEqual({ success: true });
|
|
});
|
|
|
|
it('DELETE /trash/empty 403, else returns the count', async () => {
|
|
await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException);
|
|
const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial<FilesService>);
|
|
expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 });
|
|
});
|
|
|
|
it('POST /:id/link 404 unknown file, else links', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).link(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).link(user, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] });
|
|
});
|
|
|
|
it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => {
|
|
const deleteFileLink = vi.fn();
|
|
expect(new FilesController(fsvc({ deleteFileLink } as Partial<FilesService>), new RuntimeEnvService()).unlink(user, '5', '9', '3')).toEqual({ success: true });
|
|
expect(deleteFileLink).toHaveBeenCalledWith('3', '9');
|
|
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).links(user, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
|
});
|
|
|
|
it('the trash + link routes all reject without file_delete / file_edit', async () => {
|
|
const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) });
|
|
await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).restore(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).link(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).unlink(user, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
});
|
|
|
|
it('GET /:id/links 404 without trip access', () => {
|
|
const s = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
|
expect(thrown(() => new FilesController(s, new RuntimeEnvService()).links(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
});
|
|
});
|
|
|
|
describe('FilesDownloadController', () => {
|
|
function dsvc(o: Partial<FilesService> = {}): FilesService {
|
|
return {
|
|
authenticateDownload: vi.fn().mockReturnValue({ userId: 1 }),
|
|
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'x.pdf', original_name: 'x.pdf' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: 'C:/nope/x.pdf', safe: true }),
|
|
...o,
|
|
} as unknown as FilesService;
|
|
}
|
|
const req = { headers: {}, query: {} } as Request;
|
|
const res = { setHeader: vi.fn(), sendFile: vi.fn() } as unknown as Response;
|
|
|
|
it('maps the auth error from authenticateDownload', () => {
|
|
const s = dsvc({ authenticateDownload: vi.fn().mockReturnValue({ error: 'Authentication required', status: 401 }) });
|
|
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 401, body: { error: 'Authentication required' } });
|
|
});
|
|
it('404 without trip access, 404 unknown file, 403 on an unsafe path', () => {
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } });
|
|
});
|
|
|
|
it('404 when the safe path is gone from disk', () => {
|
|
const missing = path.join(os.tmpdir(), `trek-no-such-${Date.now()}.pdf`);
|
|
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: missing, safe: true }) });
|
|
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
});
|
|
|
|
it('streams a regular file via sendFile with an explicit root', () => {
|
|
const real = path.join(os.tmpdir(), `trek-dl-${Date.now()}.pdf`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const sendFile = vi.fn();
|
|
const localRes = { setHeader: vi.fn(), sendFile } as unknown as Response;
|
|
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }) });
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(sendFile).toHaveBeenCalledWith(path.basename(real), { root: path.dirname(real) });
|
|
expect(localRes.setHeader).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('serves a .pkpass inline with the Wallet MIME type and the original name', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: 'BoardingPass.pkpass' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpass');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPass.pkpass"');
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('serves a .pkpasses bundle inline with its own Wallet MIME type', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpasses`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'passes.pkpasses', original_name: 'BoardingPasses.pkpasses' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpasses');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPasses.pkpasses"');
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('falls back to the resolved basename when a .pkpass has no original name', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: null }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', `inline; filename="${path.basename(real)}"`);
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('PhotosController', () => {
|
|
const user2 = { id: 1 } as User;
|
|
function psvc(o: Partial<PhotosService> = {}): PhotosService {
|
|
return { canAccess: vi.fn().mockReturnValue(true), stream: vi.fn().mockResolvedValue(undefined), info: vi.fn(), ...o } as unknown as PhotosService;
|
|
}
|
|
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
|
|
it('400 on a non-finite id, 403 without access', async () => {
|
|
await expect(new PhotosController(psvc()).thumbnail(user2, 'abc', res)).rejects.toMatchObject({ status: 400 });
|
|
await expect(new PhotosController(psvc({ canAccess: vi.fn().mockReturnValue(false) })).original(user2, '5', res)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it('streams thumbnail/original', async () => {
|
|
const stream = vi.fn().mockResolvedValue(undefined);
|
|
const c = new PhotosController(psvc({ stream }));
|
|
await c.thumbnail(user2, '5', res);
|
|
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'thumbnail');
|
|
await c.original(user2, '5', res);
|
|
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'original', undefined);
|
|
});
|
|
|
|
it('info writes the data, maps a service error', async () => {
|
|
const okRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ data: { id: '5' } }) })).info(user2, '5', okRes);
|
|
expect(okRes.json).toHaveBeenCalledWith({ id: '5' });
|
|
const errRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) })).info(user2, '5', errRes);
|
|
expect(errRes.status).toHaveBeenCalledWith(404);
|
|
expect(errRes.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
|
});
|
|
});
|