Files
tessera-ctl/apps/api/src/dashboard/dashboard-images.controller.spec.ts
T
schalli 737974b653 feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck
- Prisma-Modell DashboardImage (bytea) mit Migration 20260921120000: Tabelle,
  Indizes, RLS ENABLE/FORCE und tenant_isolation_policy mit Benutzerdimension
- dashboard-image-rules.ts: detectImageMime ueber Magic Bytes (PNG/JPEG/GIF/
  WebP), Grenzen 5 MiB je Datei und 30 je Benutzer
- DashboardImagesService: list/upload/getBytes/remove, je Methode
  forTenant(prisma, tenantId, userId); Besitz = Mandant UND Benutzer, sonst 404
- DashboardImagesController unter dashboard/images: GET, POST (FileInterceptor
  image, 5 MiB, eine Datei), GET :id mit Content-Type aus dem erkannten Typ,
  Cache-Control private, nosniff, Content-Disposition inline ohne Dateinamen,
  CSP sandbox; DELETE :id
- CreateWidgetDto kennt 'picture-frame'
- Klassifikationsdokument: neues Paar dashboard-images.service.ts/
  dashboardImage; Bereichs- und Summenzeilen nachgemessen (dashboard 12->18,
  settings 3->4 und bug-reports waren in der Summe nie mitgezaehlt)
- Befund: Prisma-Bytes verlangt Uint8Array<ArrayBuffer>, multers Buffer wird
  ohne Zusicherung abgelehnt - Kopie per new Uint8Array(buffer) statt Cast

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:47:07 +02:00

123 lines
5.1 KiB
TypeScript

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<string, string> = {};
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');
});
});