import 'reflect-metadata'; import { ForbiddenException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; /** * `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen der * Upload-Route (T-LRR-04) am AUFRUF pruefbar sind — Muster * `dashboard-images.controller.spec.ts` (quick-260921-pi9). */ const { fileInterceptorMock } = vi.hoisted(() => ({ fileInterceptorMock: vi.fn(() => class FakeInterceptor {}), })); vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock })); import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files'; import { FavoritesController } from './favorites.controller'; /** * favorites.controller.spec — NEU (quick-260923-lrr). * * Fuenf Bereiche: Interceptor-Grenzen der Upload-Route (Feld `icon`, 512 KB, * genau eine Datei), die Header der Symbol-Antwort (jetzt `private` statt * `public`, 260923-lrr), Weitergabe von Mandant/Benutzer ausschliesslich aus * dem Sitzungsnachweis (`req.tenantId` VOR `req.user.tenantId`, Muster * `extractContext`), Abweisung ohne Mandantenkontext, und die * Routen-Metadaten der zwei neuen Wege. */ function makeService() { return { list: vi.fn(async () => []), create: vi.fn(async () => ({ id: 'new' })), reorder: vi.fn(async () => []), getIconBytes: vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from([0x89, 0x50, 0x4e, 0x47]), })), uploadIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({ id, uploadedIconMime: 'image/png', iconVersion: 1, })), removeUploadedIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({ id, uploadedIconMime: null, iconVersion: 2, })), update: vi.fn(async () => ({})), remove: vi.fn(async () => undefined), }; } function makeRes() { const headers: Record = {}; return { headers, setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), send: vi.fn(), }; } function makeReq(overrides: Partial<{ tenantId: string | null; user: any }> = {}) { return { tenantId: overrides.tenantId, user: overrides.user ?? { id: 'user-1', tenantId: 'tenant-from-user' }, } as any; } describe('FavoritesController (quick-260923-lrr)', () => { it('Test 1: FileInterceptor wird mit dem Feld icon und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen', () => { expect(fileInterceptorMock).toHaveBeenCalledWith('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 }, }); expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024); }); it('Test 2: getIcon setzt Content-Type aus dem Dienst, Cache-Control private, nosniff, CSP sandbox', async () => { const service = makeService(); const controller = new FavoritesController(service as never); const res = makeRes(); await controller.getIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), res as never); expect(service.getIconBytes).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1'); expect(res.headers['Content-Type']).toBe('image/png'); expect(res.headers['Cache-Control']).toBe('private, max-age=86400'); expect(res.headers['X-Content-Type-Options']).toBe('nosniff'); expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox"); }); it('Test 3: uploadIcon reicht tenantId aus req.tenantId (VOR req.user.tenantId) und userId aus req.user.id an den Dienst', async () => { const service = makeService(); const controller = new FavoritesController(service as never); const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; await controller.uploadIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), file); expect(service.uploadIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1', file); }); it('Test 4: uploadIcon faellt auf req.user.tenantId zurueck, wenn req.tenantId fehlt', async () => { const service = makeService(); const controller = new FavoritesController(service as never); const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; await controller.uploadIcon('fav-1', makeReq({ tenantId: undefined }), file); expect(service.uploadIcon).toHaveBeenCalledWith('tenant-from-user', 'fav-1', 'user-1', file); }); it('Test 5: removeUploadedIcon reicht tenantId/userId ebenso weiter', async () => { const service = makeService(); const controller = new FavoritesController(service as never); await controller.removeUploadedIcon('fav-1', makeReq({ tenantId: 'tenant-1' })); expect(service.removeUploadedIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1'); }); it('Test 6: ohne Mandantenkontext -> ForbiddenException, Dienst wird NICHT aufgerufen', async () => { const service = makeService(); const controller = new FavoritesController(service as never); const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 }; await expect( controller.uploadIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } }), file), ).rejects.toThrow(ForbiddenException); expect(service.uploadIcon).not.toHaveBeenCalled(); await expect( controller.removeUploadedIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } })), ).rejects.toThrow(ForbiddenException); expect(service.removeUploadedIcon).not.toHaveBeenCalled(); }); it('Test 7: POST :id/icon und DELETE :id/icon sind als Routen-Metadaten vorhanden', () => { const proto = FavoritesController.prototype; expect(Reflect.getMetadata('path', proto.uploadIcon)).toBe(':id/icon'); expect(Reflect.getMetadata('method', proto.uploadIcon)).toBe(1); // RequestMethod.POST expect(Reflect.getMetadata('path', proto.removeUploadedIcon)).toBe(':id/icon'); expect(Reflect.getMetadata('method', proto.removeUploadedIcon)).toBe(3); // RequestMethod.DELETE }); });