7704372c3c
- 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>
145 lines
5.9 KiB
TypeScript
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
|
|
});
|
|
});
|