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:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
@@ -1,4 +1,7 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte
@@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service';
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
* quick-260923-ad9 (Task 1). */
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
* (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die
* betroffenen Favoriten mit hochgeladenem Symbol). */
const BOUND_MODEL_NAMES = [
'dashboard',
'dashboardLayout',
'widgetInstance',
'searchProvider',
'favoriteLink',
];
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
@@ -125,6 +137,27 @@ function makeSearchProvider(
};
}
/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die
* Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`-
* Aufrufe in `removeWidget`/`deleteDashboard` lesen. */
function makeFavorite(
overrides: Partial<{
id: string;
userId: string;
tenantId: string;
widgetId: string;
uploadedIconMime: string | null;
}> = {},
) {
return {
id: overrides.id ?? 'fav-1',
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
widgetId: overrides.widgetId ?? 'w1',
uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime,
};
}
function makeFakePrisma(
opts: {
widgets?: ReturnType<typeof makeWidget>[];
@@ -132,12 +165,14 @@ function makeFakePrisma(
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[];
dashboards?: ReturnType<typeof makeDashboard>[];
favorites?: ReturnType<typeof makeFavorite>[];
} = {},
) {
const widgets = opts.widgets ?? [];
const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null;
const searchProviders = opts.searchProviders ?? [];
const favorites = opts.favorites ?? [];
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
@@ -256,6 +291,24 @@ function makeFakePrisma(
return removed;
}),
},
// T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/
// `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade,
// sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert
// ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier).
favoriteLink: {
findMany: vi.fn(async ({ where }: any) => {
let rows = favorites.filter((f) => f.userId === where.userId);
if (where.widgetId?.in) {
rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId));
} else if (where.widgetId) {
rows = rows.filter((f) => f.widgetId === where.widgetId);
}
if (where.uploadedIconMime?.not === null) {
rows = rows.filter((f) => f.uploadedIconMime !== null);
}
return rows;
}),
},
module: {
findMany: vi.fn(async ({ where }: any) => {
const slugs: string[] = where.slug.in;
@@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
);
});
describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => {
let iconsDir: string;
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
beforeEach(() => {
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-'));
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;
}
});
it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const favorite = makeFavorite({
id: 'fav-1',
userId: 'user-1',
widgetId: 'w1',
uploadedIconMime: 'image/png',
});
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, Buffer.from([1, 2, 3]));
await service.removeWidget('w1', 'user-1', 'tenant-1');
expect(fs.existsSync(absolute)).toBe(false);
});
it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const favorite = makeFavorite({
id: 'fav-1',
userId: 'user-1',
widgetId: 'w1',
uploadedIconMime: 'image/png',
});
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
});
it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null });
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
});
});
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
NotFoundException,
);
});
describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => {
let iconsDir: string;
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
beforeEach(() => {
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-'));
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;
}
});
it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
widgets: [
makeWidget({ id: 'w1', dashboardId: 'd1' }),
makeWidget({ id: 'w2', dashboardId: 'd1' }),
],
favorites: [
makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }),
makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }),
makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string;
for (const absolute of [abs1, abs2]) {
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, Buffer.from([1]));
}
await service.deleteDashboard('d1', 'user-1', 'tenant-1');
expect(fs.existsSync(abs1)).toBe(false);
expect(fs.existsSync(abs2)).toBe(false);
});
it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' });
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
});
});
});
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
+69 -2
View File
@@ -2,9 +2,11 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma, Role } from '@prisma/client';
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
import { ModuleAccessService } from '../module-registry/module-access.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
*/
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly prisma: PrismaService,
private readonly moduleAccessService: ModuleAccessService,
) {}
/**
* T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan
* geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die
* Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`),
* OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in
* `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die
* Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort
* (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt,
* er darf das Loeschen des Widgets/Reiters nie verhindern oder
* zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der
* erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion.
*/
private async cleanUpFavoriteIconFiles(
userId: string,
rows: Array<{ id: string; uploadedIconMime: string | null }>,
): Promise<void> {
for (const row of rows) {
if (row.uploadedIconMime === null) continue;
const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime);
if (!removed) {
this.logger.warn(
`Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`,
);
}
}
}
/**
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
@@ -243,7 +274,25 @@ export class DashboardService {
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
// T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein
// eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`.
// Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion
// unten, weil die Dateiraeumung selbst NICHT transaktional sein muss
// (und best effort niemals einen Rollback ausloesen darf).
const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({
where: { dashboardId: id, userId },
select: { id: true },
});
const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id);
const iconRows =
widgetIds.length === 0
? []
: await tenantPrisma.favoriteLink.findMany({
where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } },
select: { id: true, uploadedIconMime: true },
});
const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
await tx.dashboard.deleteMany({ where: { id, userId } });
@@ -262,6 +311,10 @@ export class DashboardService {
return { id };
});
await this.cleanUpFavoriteIconFiles(userId, iconRows);
return result;
}
/**
@@ -505,6 +558,11 @@ export class DashboardService {
* Verifies ownership by userId before deleting (T-05-01) — same real
* ownership check as `updateWidgetConfig` above, same reasoning: both
* queries run over the SAME bound client and tenant id.
*
* T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard`
* trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen
* Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien
* best effort entfernt (siehe `cleanUpFavoriteIconFiles`).
*/
async removeWidget(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -518,9 +576,18 @@ export class DashboardService {
);
}
return tenantPrisma.widgetInstance.delete({
const iconRows = await tenantPrisma.favoriteLink.findMany({
where: { widgetId: id, userId, uploadedIconMime: { not: null } },
select: { id: true, uploadedIconMime: true },
});
const result = await tenantPrisma.widgetInstance.delete({
where: { id },
});
await this.cleanUpFavoriteIconFiles(userId, iconRows);
return result;
}
// --- Search Providers (05-02, D-15) ---