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,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)', () => {
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user