737974b653
- 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>
123 lines
5.1 KiB
TypeScript
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');
|
|
});
|
|
});
|