fix(favorites): normalize scheme-less URLs so favicons resolve
A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").
- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
scheme-less fallback stays absolute)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IconDiscoveryService, isPublicHttpUrl } from './icon-discovery.service';
|
||||
import {
|
||||
IconDiscoveryService,
|
||||
isPublicHttpUrl,
|
||||
normalizeUrl,
|
||||
} from './icon-discovery.service';
|
||||
|
||||
function mockResponse(options: {
|
||||
contentType?: string;
|
||||
@@ -46,6 +50,71 @@ describe('isPublicHttpUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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.fetchIconBytes', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
Reference in New Issue
Block a user