fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann

- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:34:58 +02:00
parent 7188c5b958
commit b15c74632b
12 changed files with 307 additions and 112 deletions
@@ -490,6 +490,75 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
});
it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-9',
widgetId: 'fav-1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Docuvita')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
});
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct1.src).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
);
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
act(() => {
fireEvent.error(direct1);
});
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
act(() => {
fireEvent.error(direct2);
});
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
});
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-8',
widgetId: 'fav-1',
title: 'Boese',
url: 'https://boese.example',
iconUrl: 'javascript:alert(1)',
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Boese')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
});
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
expect(direct.src).toBe('https://boese.example/favicon.ico');
});
it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
@@ -730,8 +799,8 @@ describe('FavoritesWidget', () => {
});
});
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -753,7 +822,7 @@ describe('FavoritesWidget', () => {
});
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
});
expect(mockUploadIcon).not.toHaveBeenCalled();
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();