071082983b
- 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>
471 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|