import 'reflect-metadata'; import { describe, expect, it, vi } from 'vitest'; /** * `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je * Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst * ihre Optionen in einer Klasse ohne lesbare Metadaten. */ const { fileInterceptorMock } = vi.hoisted(() => ({ fileInterceptorMock: vi.fn(() => class FakeInterceptor {}), })); vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock })); import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import type { AuthUser } from '../auth/types/auth-user'; import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules'; import { DashboardImagesController } from './dashboard-images.controller'; /** * dashboard-images.controller.spec — NEU (quick-260921-pi9). * * Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge * (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld * `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten * Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein * `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich * aus dem Sitzungsnachweis (T-PI9-11). */ const user: AuthUser = { id: 'user-1', username: 'anna', role: 'USER', tenantId: 'tenant-1', mustChangePassword: false, }; function makeService() { return { list: vi.fn(async () => []), upload: vi.fn(async () => ({ id: 'new' })), getBytes: vi.fn(async () => ({ mimeType: 'image/webp', data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]), })), remove: vi.fn(async (id: string) => ({ id })), }; } function makeRes() { const headers: Record = {}; return { headers, setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), send: vi.fn(), }; } // Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht // beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf. describe('DashboardImagesController (quick-260921-pi9)', () => { it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => { expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images'); const proto = DashboardImagesController.prototype; const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'); expect(order).toEqual(['list', 'upload', 'getImage', 'remove']); expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id'); expect(Reflect.getMetadata('path', proto.remove)).toBe(':id'); expect(Reflect.getMetadata('path', proto.list)).toBe('/'); expect(Reflect.getMetadata('path', proto.upload)).toBe('/'); }); it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => { expect(fileInterceptorMock).toHaveBeenCalledTimes(1); expect(fileInterceptorMock.mock.calls[0]).toEqual([ 'image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }, ]); expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024); }); it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => { const service = makeService(); const controller = new DashboardImagesController(service as never); const res = makeRes(); await controller.getImage('img-1', user, res as never); expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1'); expect(res.headers['Content-Type']).toBe('image/webp'); expect(res.headers['Cache-Control']).toBe('private, max-age=86400'); expect(res.headers['X-Content-Type-Options']).toBe('nosniff'); expect(res.headers['Content-Disposition']).toBe('inline'); expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox"); const sent = res.send.mock.calls[0][0] as unknown; expect(Buffer.isBuffer(sent)).toBe(true); expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true); }); it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => { const proto = DashboardImagesController.prototype; for (const name of ['list', 'upload', 'getImage', 'remove'] as const) { expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); } }); it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => { const service = makeService(); const controller = new DashboardImagesController(service as never); const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 }; await controller.list(user); expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1'); await controller.upload(user, file); expect(service.upload).toHaveBeenCalledWith(user, file); await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' }); expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1'); }); });