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:
@@ -1,5 +1,14 @@
|
||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
@@ -39,6 +48,9 @@ interface FakeFavoriteRow {
|
||||
position: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
|
||||
uploadedIconMime?: string | null;
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
interface FakeWidgetRow {
|
||||
@@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never {
|
||||
* deshalb strukturell nie.
|
||||
*/
|
||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
||||
const favorites = new Map(
|
||||
favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]),
|
||||
);
|
||||
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
let autoId = favoriteRows.length;
|
||||
@@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||
const id = data.id ?? `fav-${++autoId}`;
|
||||
const now = new Date();
|
||||
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
||||
const record = {
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
id,
|
||||
};
|
||||
favorites.set(id, record);
|
||||
return record;
|
||||
},
|
||||
@@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||
const updated = { ...row, ...data, updatedAt: new Date() };
|
||||
const updated: any = { ...row, ...data, updatedAt: new Date() };
|
||||
// 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic
|
||||
// increment form, angewendet auf den bisherigen Zaehlerstand.
|
||||
if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) {
|
||||
updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment;
|
||||
}
|
||||
favorites.set(where.id, updated);
|
||||
return updated;
|
||||
},
|
||||
@@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe ---
|
||||
|
||||
describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
function fileFor(userId: string, id: string, ext: string): string {
|
||||
return path.join(iconsDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
describe('uploadIcon', () => {
|
||||
it('PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
expect(updated.uploadedIconMime).toBe('image/png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png'));
|
||||
expect(written.equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne Datei -> BadRequestException', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]);
|
||||
const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
PayloadTooLargeException,
|
||||
);
|
||||
});
|
||||
|
||||
it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => {
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const serviceForeignUser = new FavoritesService(
|
||||
prismaForeignUser as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaForeignTenant = makeFakePrisma([baseRow]);
|
||||
const serviceForeignTenant = new FavoritesService(
|
||||
prismaForeignTenant as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaUnknown = makeFakePrisma([]);
|
||||
const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any);
|
||||
await expect(
|
||||
serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false);
|
||||
});
|
||||
|
||||
it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length };
|
||||
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', pngFile);
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile);
|
||||
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true);
|
||||
expect(updated.uploadedIconMime).toBe('image/svg+xml');
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => {
|
||||
it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(result.contentType).toBe('image/png');
|
||||
expect((result.body as Buffer).equals(PNG)).toBe(true);
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||
});
|
||||
|
||||
it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{ ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' },
|
||||
]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
'FavoriteLink not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUploadedIcon', () => {
|
||||
it('Datei weg, uploadedIconMime null, iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('fremder Benutzer -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove() mit hochgeladenem Symbol', () => {
|
||||
it('Zeile und Datei weg', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
await service.remove('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
// Datei vorab entfernen, damit fs.unlink() im Dienst scheitert.
|
||||
fs.unlinkSync(fileFor('user-a1', 'f1', 'png'));
|
||||
|
||||
await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined();
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
} as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Alt',
|
||||
url: 'https://alt.invalid',
|
||||
iconUrl: 'https://alt.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||
});
|
||||
|
||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update nur Titel: keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any);
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user