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
@@ -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 = '<html><head><link rel="icon" href="https://ctl.de/fav.png" /></head></html>';
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
});
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await service.discoverFavoriteIconUrl('http://8.8.8.8');
const init = fetchSpy.mock.calls[0][1];
expect(init.dispatcher).toBeInstanceOf(Agent);
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
expect(init.redirect).toBe('manual');
});
it('fetchIconBytes uebergibt denselben tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
const init = fetchSpy.mock.calls[0][1];
expect(init.dispatcher).toBeInstanceOf(Agent);
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
expect(init.redirect).toBe('manual');
});
it('Discovery und fetchIconBytes teilen DENSELBEN Agent (Modul-Singleton)', async () => {
const html = '<html><head></head></html>';
const fetchSpy = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
})
.mockResolvedValueOnce(mockResponse({ contentType: 'image/png' }));
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await service.discoverFavoriteIconUrl('http://8.8.8.8');
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
const calls = fetchSpy.mock.calls;
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
});
});