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>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user