Files
TREK/server/tests/unit/nest/files.controller.test.ts
T
Maurice 49c3c57c31 Give the ephemeral tokens a provider, and clear the next wave of type errors
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup
timer only because it happened to be the domain that mints tokens. Issuing
them and sweeping them are different jobs, and FilesService and TokenService
mint or consume them too.

EphemeralTokenService owns the lifecycle and hands the three operations out.
The store and the interval stay module state next door, and that is
load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on
the upgrade handshake, outside the container, before any injector is in
reach. Per-instance state would hand it an empty Map and close every
connection with 4001, which no test would catch because the ws suites drive
the transport directly. WS-027 through WS-029 pin the session gate that runs
through exactly that path.

The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken
returns null at 10,000 live tokens and TokenService turns that into a 503. A
second store would raise the real ceiling with nobody noticing.

Its own leaf module rather than a provider in AuthModule: TokensModule mints
download and ws tokens as well, AuthModule already imports TokensModule, and
TokensModule is documented as a deliberate leaf that three graphs reach for.
Same shape as MailerModule, which broke AuthModule <-> NotificationsModule.

Twelve hand-wired construction sites in tests needed the new collaborator.
The typecheck named all twelve; before this branch they would have been
undefined at runtime.

Sixteen more test files are cleared here too, each at its root cause. Four of
them were passing for the wrong reason:

files.controller.test.ts drove two handlers with shifted arguments, a string
tripId where the trip row belongs and undefined where tripId and id belong.
It stayed green because FilesService is fully stubbed and the stubs ignore
what they are given, so DELETE /:id/permanent and DELETE /trash/empty never
covered that the trip row reaches the permission check.

conflictUpdate.test.ts was missing real collaborators on PlacesService and
PackingService. None of the exercised paths reach them, so it passed by luck.

assignments.service.test.ts builds its service by hand, and the one consumer
of the collaborator it was missing swallows every throw in a bare catch. A
broken journey reconciliation would still have shown 28 green tests.

scheduler.test.ts mocks two modules with paths that resolve one directory
above the server root, so they mock nothing at all. Harmless only because
src/scheduler.ts imports neither.
2026-08-10 19:54:46 +02:00

298 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; });
/** The trip row TripAccessGuard resolves and hands to every handler via @Trip(). */
const trip = { id: 5, user_id: 42 } as never;
// The 404 "Trip not found" cases moved to trip-access.guard.test.ts with the check.
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
it('GET / lists with the trash flag', () => {
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
expect(new FilesController(fsvc({ listFiles } as Partial<FilesService>), new RuntimeEnvService()).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] });
expect(listFiles).toHaveBeenCalledWith('5', true);
});
describe('POST / (upload)', () => {
it('404 without trip access — upload keeps its own check so the body is read first', () => {
// The guard would answer before multer, and a response sent mid-upload destroys
// the socket: the caller would see ECONNRESET rather than this 404.
const svc = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
expect(thrown(() => new FilesController(svc, new RuntimeEnvService()).upload(user, '5', undefined, {}))).toEqual({
status: 404,
body: { error: 'Trip not found' },
});
});
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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '5')).rejects.toBeInstanceOf(HttpException);
const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial<FilesService>);
expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, trip, '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, trip, '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, trip, '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, trip, '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, trip, '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, trip, '5', '9')).rejects.toMatchObject({ status: 403 });
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
});
});
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' });
});
});