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 { discardBody, IconDiscoveryService, isPublicHttpUrl, normalizeUrl, readTextCapped, } from './icon-discovery.service'; function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response { const body = options.body ?? new ArrayBuffer(10); return { ok: true, status: 200, headers: { get: (name: string) => name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null, }, arrayBuffer: async () => body, } as unknown as Response; } describe('isPublicHttpUrl', () => { afterEach(() => { vi.restoreAllMocks(); }); it('rejects private IPv4 addresses', async () => { await expect(isPublicHttpUrl(new URL('http://127.0.0.1/x'))).resolves.toBe(false); await expect(isPublicHttpUrl(new URL('http://10.0.0.5/x'))).resolves.toBe(false); await expect(isPublicHttpUrl(new URL('http://192.168.1.1/x'))).resolves.toBe(false); await expect(isPublicHttpUrl(new URL('http://169.254.1.1/x'))).resolves.toBe(false); }); it('rejects blocked hostnames', async () => { await expect(isPublicHttpUrl(new URL('http://localhost/x'))).resolves.toBe(false); await expect(isPublicHttpUrl(new URL('http://foo.local/x'))).resolves.toBe(false); await expect(isPublicHttpUrl(new URL('http://0.0.0.0/x'))).resolves.toBe(false); }); it('rejects non-http(s) protocols', async () => { await expect(isPublicHttpUrl(new URL('ftp://example.com/x'))).resolves.toBe(false); }); it('accepts a public IPv4 address without DNS lookup', async () => { await expect(isPublicHttpUrl(new URL('http://8.8.8.8/x'))).resolves.toBe(true); }); }); describe('normalizeUrl', () => { it('prepends https:// to a scheme-less host', () => { expect(normalizeUrl('ctl.de')).toBe('https://ctl.de'); expect(normalizeUrl('www.ctl.de/path')).toBe('https://www.ctl.de/path'); }); it('leaves an existing scheme untouched', () => { expect(normalizeUrl('http://ctl.de')).toBe('http://ctl.de'); expect(normalizeUrl('https://ctl.de')).toBe('https://ctl.de'); }); it('trims surrounding whitespace', () => { expect(normalizeUrl(' ctl.de ')).toBe('https://ctl.de'); }); it('returns empty string unchanged', () => { expect(normalizeUrl(' ')).toBe(''); }); }); describe('IconDiscoveryService.discoverFavoriteIconUrl', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('extracts the apple-touch-icon from page HTML', async () => { const html = ` `; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: (n: string) => n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, }, text: async () => html, }), ); const service = new IconDiscoveryService(); // Public IP avoids a real DNS lookup in the SSRF guard. const icon = await service.discoverFavoriteIconUrl('http://8.8.8.8'); expect(icon).toBe('https://ctl.de/apple-180.jpg'); }); it('normalizes a scheme-less URL so the fallback is absolute, not "/favicon.ico"', async () => { // fetch fails → discovery falls back. The fallback must be an absolute // https origin URL, not the broken relative path that produced the bug. vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); const service = new IconDiscoveryService(); const icon = await service.discoverFavoriteIconUrl('ctl.de'); expect(icon).toBe('https://ctl.de/favicon.ico'); }); }); describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function htmlResponse(status: number, html: string) { return { ok: status >= 200 && status < 300, status, headers: { get: (n: string) => n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, }, text: async () => html, }; } it('Seite antwortet 400, traegt aber (docuvita) -> dieser Verweis wird genutzt', async () => { const html = ''; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html))); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl( 'http://8.8.8.8/server/services/web/', ); expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico'); }); it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall /favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => { const html = ''; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html))); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); expect(icon).toBe('http://8.8.8.8/favicon.ico'); }); it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }), ); await expect( new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'), ).rejects.toThrow(); }); }); describe('IconDiscoveryService.fetchIconBytes', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('returns contentType and body for a valid image response', async () => { const body = new ArrayBuffer(100); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png', body })), ); const service = new IconDiscoveryService(); const result = await service.fetchIconBytes('http://8.8.8.8/favicon.ico'); expect(result.contentType).toBe('image/png'); expect(result.body).toBeInstanceOf(Buffer); expect(result.body.length).toBe(100); }); it('rejects when Content-Type is not an image', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' }))); const service = new IconDiscoveryService(); await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow( /not an image/, ); }); it('rejects when the SSRF guard blocks the target', async () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); const service = new IconDiscoveryService(); await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow( /blocked or failed/, ); expect(fetchSpy).not.toHaveBeenCalled(); }); it('rejects when the body exceeds the size cap', async () => { const oversized = new ArrayBuffer(1_000_001); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png', body: oversized })), ); const service = new IconDiscoveryService(); await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow( /size limit/, ); }); }); describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('falls back to /favicon.ico when the page cannot be fetched', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); const service = new IconDiscoveryService(); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); expect(result).toBe('http://8.8.8.8/favicon.ico'); }); it('still returns a URL string', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' }))); const service = new IconDiscoveryService(); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); 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 = ''; 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 = ''; 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); }); }); describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); /** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */ function countingStream(chunks: number, chunkChars: number) { const state = { pulled: 0, cancelled: false }; const encoder = new TextEncoder(); const body = new ReadableStream({ pull(controller) { if (state.pulled >= chunks) { controller.close(); return; } state.pulled += 1; controller.enqueue(encoder.encode('a'.repeat(chunkChars))); }, cancel() { state.cancelled = true; }, }); return { body, state }; } it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => { const { body, state } = countingStream(1000, 1000); const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500); expect(text).toHaveLength(2500); expect(state.pulled).toBeLessThan(10); expect(state.cancelled).toBe(true); }); it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => { let cancelled = false; const body = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('')); // danach kommt nichts mehr, der Stream bleibt offen }, cancel() { cancelled = true; }, }); const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50); expect(text).toBe(''); expect(cancelled).toBe(true); }); it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => { const bytes = new TextEncoder().encode('

Grüße

'); const body = new ReadableStream({ start(controller) { // Das "ü" (2 Bytes) wird absichtlich zerteilt. controller.enqueue(bytes.slice(0, 5)); controller.enqueue(bytes.slice(5)); controller.close(); }, }); const text = await readTextCapped({ body, text: async () => '' } as never, 200000); expect(text).toBe('

Grüße

'); }); it('ohne Stream: Rueckfall auf text() mit Deckel', async () => { const text = await readTextCapped( { body: null, text: async () => 'x'.repeat(50) } as never, 10, ); expect(text).toBe('x'.repeat(10)); }); it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => { const { body, state } = countingStream(5, 10); discardBody({ body } as never); expect(state.cancelled).toBe(true); expect(() => discardBody({ body: null } as never)).not.toThrow(); }); it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => { const { body, state } = countingStream(5, 10); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null), }, body, }), ); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); expect(icon).toBe('http://8.8.8.8/favicon.ico'); expect(state.cancelled).toBe(true); }); it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => { const head = ''; const encoder = new TextEncoder(); const state = { pulled: 0, cancelled: false }; const body = new ReadableStream({ pull(controller) { state.pulled += 1; controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024))); }, cancel() { state.cancelled = true; }, }); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) }, body, text: async () => { throw new Error('text() darf bei vorhandenem Stream nicht laufen'); }, }), ); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/'); expect(icon).toBe('http://8.8.8.8/klein.png'); expect(state.cancelled).toBe(true); // 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen. expect(state.pulled).toBeLessThan(10); }); });