Files
tessera-ctl/apps/api/src/favorites/icon-discovery.service.spec.ts
T
schalli 071082983b fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00

471 lines
16 KiB
TypeScript

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 = `<html><head>
<link rel="icon" href="https://ctl.de/fav-32.jpg" sizes="32x32" />
<link rel="apple-touch-icon" href="https://ctl.de/apple-180.jpg" />
</head></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 <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
const html =
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></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 <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></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 <origin>/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 = '<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);
});
});
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<Uint8Array>({
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<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// 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('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
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('<p>Grüße</p>');
});
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 = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
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);
});
});