feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern

- icon-discovery.service.ts: undicis eigenes fetch mit Modul-Singleton
  LENIENT_TLS_AGENT (Agent({ connect: { rejectUnauthorized: false } }))
  als dispatcher in fetchWithRedirectGuard, der einzigen Ausgangsstelle
  fuer HTML-Ermittlung und Icon-Byte-Holen; SSRF-Schutz unveraendert
- undici 7.28.0 (bereits im Lockfile aufgeloest) als direkte Abhaengigkeit
  von @tessera/api via pnpm add --offline
- PUT /favorites/order (ReorderFavoritesDto) vor den :id-Routen;
  FavoritesService.reorder() setzt position=index fuer die Favoriten
  eines Widgets in EINER withTenantTransaction, userId+widgetId in jeder
  Bedingung (zweites Netz), eine BadRequestException fuer alle
  Abweichungen (T-JDD-06)
- getIcon: X-Content-Type-Options nosniff + restriktive CSP (T-JDD-02)
- 10 neue Tests (3 Dispatcher, 7 reorder); volle API-Suite 68 Dateien/
  1101 Tests und type-check gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:36:03 +02:00
parent e7633e15de
commit 2a562d0b14
8 changed files with 373 additions and 11 deletions
+72 -1
View File
@@ -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).