Files
tessera-ctl/apps/api/src/favorites/favorites.controller.spec.ts
T
schalli 7704372c3c feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000)
- favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne
  Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung
- FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen
  Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller
  Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle
- FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private
- T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan
  hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die
  Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf
  (best effort, nie blockierend)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:04:29 +02:00

145 lines
5.9 KiB
TypeScript

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