diff --git a/apps/api/package.json b/apps/api/package.json
index dc1e93d..646850c 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -48,6 +48,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.0.0",
"tsdav": "2.2.2",
+ "undici": "7.28.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
diff --git a/apps/api/src/favorites/dto/reorder-favorites.dto.ts b/apps/api/src/favorites/dto/reorder-favorites.dto.ts
new file mode 100644
index 0000000..b1157d0
--- /dev/null
+++ b/apps/api/src/favorites/dto/reorder-favorites.dto.ts
@@ -0,0 +1,28 @@
+import {
+ ArrayMaxSize,
+ ArrayMinSize,
+ ArrayUnique,
+ IsArray,
+ IsUUID,
+} from 'class-validator';
+
+/**
+ * DTO for `PUT /favorites/order` (260917-jdd).
+ *
+ * `ids` is the FULL id list of a widget's favorites, in the desired display
+ * order — the service requires an exact match against the caller's existing
+ * favorites for this widget (no partial reorder, no foreign/unknown ids).
+ * `ArrayMaxSize(500)` is a DoS cap for the `updateMany` loop inside the
+ * transaction (T-JDD-05); a widget has a handful of links in practice.
+ */
+export class ReorderFavoritesDto {
+ @IsUUID()
+ widgetId!: string;
+
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayMaxSize(500)
+ @ArrayUnique()
+ @IsUUID('all', { each: true })
+ ids!: string[];
+}
diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts
index a8b2cb5..a49cb5f 100644
--- a/apps/api/src/favorites/favorites.controller.ts
+++ b/apps/api/src/favorites/favorites.controller.ts
@@ -8,12 +8,14 @@ import {
ParseUUIDPipe,
Patch,
Post,
+ Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
+import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import { FavoritesService } from './favorites.service';
@@ -35,6 +37,8 @@ import { FavoritesService } from './favorites.service';
* Routes:
* - GET /favorites?widgetId= — list favorites for a widget instance
* - POST /favorites — create a favorite (triggers server-side icon discovery)
+ * - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
+ * - GET /favorites/:id/icon — stream a favorite's stored icon bytes
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
*/
@@ -77,6 +81,23 @@ export class FavoritesController {
return this.favoritesService.create(tenantId, userId, dto);
}
+ /**
+ * PUT /favorites/order — persists the display order for a widget's
+ * favorites (260917-jdd). Declared BEFORE the `:id` routes below on
+ * purpose (NestJS route order — a later `:id` route would otherwise
+ * shadow the literal segment "order"; precedent tenders.controller.ts
+ * Z. 636-648).
+ */
+ @Put('order')
+ async reorder(
+ @Body() dto: ReorderFavoritesDto,
+ @Req() req: Request,
+ ) {
+ const { userId, tenantId } = this.extractContext(req);
+
+ return this.favoritesService.reorder(tenantId, userId, dto);
+ }
+
/**
* GET /favorites/:id/icon — streams the stored icon bytes for a favorite
* owned by the caller, from Tessera's own origin. This avoids the browser
@@ -101,6 +122,12 @@ export class FavoritesController {
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400');
+ // 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
+ // Zertifikat. Als -Unterressource ignoriert der Browser diese
+ // Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
+ // Skript und ohne Tessera-Origin (T-JDD-02).
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(body);
}
diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts
index 8feaf6d..ffd0877 100644
--- a/apps/api/src/favorites/favorites.service.spec.ts
+++ b/apps/api/src/favorites/favorites.service.spec.ts
@@ -1,7 +1,7 @@
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FavoritesService } from './favorites.service';
-import { forTenant } from '../prisma/prisma-tenant.extension';
+import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
/**
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
@@ -14,9 +14,18 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
+ *
+ * 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster
+ * groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction`
+ * reicht den gebundenen Klienten als `tx` durch und protokolliert den
+ * Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer
+ * `reorder()`.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
+ withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) =>
+ unboundClient.__withTenantTransaction(tenantId, fn),
+ ),
}));
interface FakeFavoriteRow {
@@ -40,7 +49,7 @@ interface FakeWidgetRow {
interface BoundCall {
tenantId: string;
- model: 'favoriteLink' | 'widgetInstance';
+ model: 'favoriteLink' | 'widgetInstance' | '$transaction';
method: string;
}
@@ -117,6 +126,20 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
favorites.delete(where.id);
return row;
},
+ // 260917-jdd: reorder() — filtert nach tenantId sowie, falls in
+ // `where` vorhanden, id/userId/widgetId; wendet `data` auf jede
+ // Treffer-Zeile an; liefert { count }.
+ updateMany: async ({ where, data }: any) => {
+ boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' });
+ let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
+ if (where?.id) rows = rows.filter((f) => f.id === where.id);
+ if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
+ if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
+ for (const row of rows) {
+ favorites.set(row.id, { ...row, ...data, updatedAt: new Date() });
+ }
+ return { count: rows.length };
+ },
};
}
@@ -146,6 +169,10 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
widgetInstance: makeScopedWidgetInstance(tenantId),
};
},
+ __withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
+ boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
+ return fn(fake.__makeBoundClient(tenantId));
+ },
};
return fake;
}
@@ -505,4 +532,97 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
}
});
});
+
+ describe('reorder (260917-jdd)', () => {
+ const makeAltbestand = () =>
+ makeFakePrisma([
+ { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
+ { id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
+ { id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
+ { id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
+ ]);
+
+ it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ const result = await service.reorder('t1', 'user-a1', {
+ widgetId: 'widget-a1',
+ ids: ['f3', 'f1', 'f2'],
+ } as any);
+
+ expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']);
+ expect(prisma.__favorites.get('f3').position).toBe(0);
+ expect(prisma.__favorites.get('f1').position).toBe(1);
+ expect(prisma.__favorites.get('f2').position).toBe(2);
+ expect(prisma.__favorites.get('f9').position).toBe(0);
+ expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function));
+ expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany');
+ });
+
+ it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ await expect(
+ service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any),
+ ).rejects.toThrow(BadRequestException);
+ expect(prisma.__favorites.get('f1').position).toBe(0);
+ expect(prisma.__favorites.get('f2').position).toBe(0);
+ expect(prisma.__favorites.get('f3').position).toBe(0);
+ });
+
+ it('unbekannte id -> BadRequestException', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ await expect(
+ service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('Teilmenge (2 von 3) -> BadRequestException', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ await expect(
+ service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ vi.mocked(withTenantTransaction).mockClear();
+ await expect(
+ service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any),
+ ).rejects.toThrow(BadRequestException);
+ expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0);
+ });
+
+ it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ await expect(
+ service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any),
+ ).rejects.toThrow(BadRequestException);
+ expect(prisma.__favorites.get('f1').position).toBe(0);
+ expect(prisma.__favorites.get('f2').position).toBe(0);
+ expect(prisma.__favorites.get('f3').position).toBe(0);
+ });
+
+ it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => {
+ const prisma = makeAltbestand();
+ const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
+
+ vi.mocked(forTenant).mockClear();
+ vi.mocked(withTenantTransaction).mockClear();
+ await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any);
+
+ expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
+ expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
+ });
+ });
});
diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts
index 413b9fd..3185102 100644
--- a/apps/api/src/favorites/favorites.service.ts
+++ b/apps/api/src/favorites/favorites.service.ts
@@ -6,8 +6,9 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
-import { forTenant } from '../prisma/prisma-tenant.extension';
+import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
+import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
@@ -37,6 +38,8 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* - Every query is scoped by userId (prevents cross-user access).
* - list() additionally scopes by widgetId so each widget instance has its own set.
* - update() and remove() verify userId ownership before mutating.
+ * - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
+ * every updateMany by userId AND widgetId (see reorder() doc below).
*
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
@@ -171,6 +174,74 @@ export class FavoritesService {
await tenantPrisma.favoriteLink.delete({ where: { id } });
}
+ /**
+ * Persists the display order of a user's favorites for one widget
+ * instance (260917-jdd, PUT /favorites/order).
+ *
+ * Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
+ * einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
+ * (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
+ * `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
+ * gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
+ * faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
+ * setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
+ * — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
+ * und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
+ * `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
+ * dieser Klasse es fuer alle Methoden vorsieht).
+ *
+ * `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
+ * `id` filtern koennte — der Ownership-Check muesste dann als separater
+ * Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
+ * ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
+ * der Schreiboperation und liefert `count`, das sofort geprueft wird.
+ *
+ * Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
+ * DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
+ * fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
+ * Grund zutraf (Muster T-GWH-05).
+ *
+ * Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
+ * keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
+ * kein Migrations- oder Sonderpfad noetig.
+ */
+ async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
+ if (new Set(dto.ids).size !== dto.ids.length) {
+ throw new BadRequestException('ids must match the favorites of this widget exactly');
+ }
+
+ return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
+ const existing = await tx.favoriteLink.findMany({
+ where: { userId, widgetId: dto.widgetId },
+ select: { id: true },
+ });
+ const existingIds = new Set(existing.map((r: { id: string }) => r.id));
+
+ if (
+ existing.length !== dto.ids.length ||
+ dto.ids.some((id) => !existingIds.has(id))
+ ) {
+ throw new BadRequestException('ids must match the favorites of this widget exactly');
+ }
+
+ for (const [index, id] of dto.ids.entries()) {
+ const { count } = await tx.favoriteLink.updateMany({
+ where: { id, userId, widgetId: dto.widgetId },
+ data: { position: index },
+ });
+
+ if (count !== 1) {
+ throw new BadRequestException('ids must match the favorites of this widget exactly');
+ }
+ }
+
+ return tx.favoriteLink.findMany({
+ where: { userId, widgetId: dto.widgetId },
+ orderBy: [{ position: 'asc' }, { title: 'asc' }],
+ });
+ });
+ }
+
/**
* Fetches the raw bytes of a favorite's stored icon, scoped to the
* requesting user (T-08-06 — same ownership check as update/remove).
diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts
index 094c99a..0693da0 100644
--- a/apps/api/src/favorites/icon-discovery.service.spec.ts
+++ b/apps/api/src/favorites/icon-discovery.service.spec.ts
@@ -1,4 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * 260917-jdd — `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz
+ * geht: die produktive Datei ruft ab jetzt `undiciFetch` statt des globalen
+ * `fetch` auf, mit einem Modul-Singleton-`Agent` als `dispatcher`. Die
+ * Mock-Klasse zeichnet nur die uebergebenen `options` auf; `fetch` delegiert
+ * ZUR LAUFZEIT (Pfeilfunktion, nicht beim Laden aufgeloest) an
+ * `globalThis.fetch`, damit alle bestehenden `vi.stubGlobal('fetch', …)`-
+ * Tests wortgleich gruen bleiben.
+ */
+vi.mock('undici', () => ({
+ Agent: class Agent {
+ constructor(public readonly options: unknown) {}
+ },
+ fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
+}));
+
+import { Agent } from 'undici';
import {
IconDiscoveryService,
isPublicHttpUrl,
@@ -203,3 +221,69 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
expect(typeof result).toBe('string');
});
});
+
+describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('discoverFavoriteIconUrl uebergibt den tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
+ const html = '