feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall

- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens,
  Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt),
  XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot
- xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy),
  Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber
  key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus
  damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot
- xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird
  mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter
  Hinweis auf verweigertes Einbetten; 8 Tests
- Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol),
  Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist
  "neuem"; der Server ruft die Adresse nie ab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:18:48 +02:00
parent 8bf3601a18
commit d63d9f5563
18 changed files with 904 additions and 7 deletions
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import {
XFRAME_RELOAD_MAX,
XFRAME_RELOAD_MIN,
XFRAME_RELOAD_OPTIONS,
XFRAME_SANDBOX,
XFRAME_TITLE_MAX,
isHttpsUrl,
resolveXframeConfig,
} from './xframe-config';
/**
* xframe-config.test — NEU (quick-260921-qd3, XFrame).
*
* Reiner Resolver ohne React: die API prueft Config-Felder nicht — allein
* diese Funktion entscheidet, was zum `<iframe src>` wird (T-QD3-03). Dazu
* die Sandbox-Konstante (T-QD3-01: keine Top-Navigation, keine Modals) und
* die Klemmung des Neulade-Intervalls (T-QD3-08).
*/
describe('resolveXframeConfig (quick-260921-qd3)', () => {
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
expect(resolveXframeConfig({})).toEqual({ url: null, title: '', reloadSeconds: 0 });
});
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
expect(resolveXframeConfig({ url: 'https://intern.example/board' }).url).toBe('https://intern.example/board');
expect(resolveXframeConfig({ url: ' https://a.de/x ' }).url).toBe('https://a.de/x');
});
it('Test 3: http, javascript, data, ftp, Unparsbares, Zahl und leer -> null', () => {
for (const url of ['http://a.de', 'javascript:alert(1)', 'data:text/html,x', 'ftp://a.de', 'kein-url', 42, '']) {
expect(resolveXframeConfig({ url }).url, `url=${String(url)}`).toBeNull();
}
});
it('Test 4: Titel getrimmt, auf 100 Zeichen gekuerzt, Nicht-String -> leer', () => {
expect(resolveXframeConfig({ title: ' Board ' }).title).toBe('Board');
expect(resolveXframeConfig({ title: 'x'.repeat(150) }).title).toHaveLength(XFRAME_TITLE_MAX);
expect(XFRAME_TITLE_MAX).toBe(100);
expect(resolveXframeConfig({ title: 7 }).title).toBe('');
});
it('Test 5: reloadSeconds fehlt / "abc" / -5 / 30 -> 0 (unter der kleinsten Stufe heisst nie)', () => {
expect(resolveXframeConfig({}).reloadSeconds).toBe(0);
expect(resolveXframeConfig({ reloadSeconds: 'abc' }).reloadSeconds).toBe(0);
expect(resolveXframeConfig({ reloadSeconds: -5 }).reloadSeconds).toBe(0);
expect(resolveXframeConfig({ reloadSeconds: 30 }).reloadSeconds).toBe(0);
expect(resolveXframeConfig({ reloadSeconds: Number.NaN }).reloadSeconds).toBe(0);
});
it('Test 6: reloadSeconds 60 -> 60, 90 -> 60, 599 -> 300, 600 -> 600 (groesste Stufe <= n)', () => {
expect(resolveXframeConfig({ reloadSeconds: 60 }).reloadSeconds).toBe(60);
expect(resolveXframeConfig({ reloadSeconds: 90 }).reloadSeconds).toBe(60);
expect(resolveXframeConfig({ reloadSeconds: 599 }).reloadSeconds).toBe(300);
expect(resolveXframeConfig({ reloadSeconds: 600 }).reloadSeconds).toBe(600);
});
it('Test 7: reloadSeconds 99999 -> 3600 (Obergrenze)', () => {
expect(resolveXframeConfig({ reloadSeconds: 99999 }).reloadSeconds).toBe(XFRAME_RELOAD_MAX);
expect(XFRAME_RELOAD_MAX).toBe(3600);
expect(XFRAME_RELOAD_MIN).toBe(60);
});
it('Test 8: alle drei Felder zusammen', () => {
expect(
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
).toEqual({ url: 'https://a.de/', title: 'Board', reloadSeconds: 1800 });
});
});
describe('XFRAME_SANDBOX / XFRAME_RELOAD_OPTIONS / isHttpsUrl (quick-260921-qd3)', () => {
it('Test 9: Sandbox enthaelt scripts, same-origin, forms, popups, popups-to-escape-sandbox', () => {
const tokens = XFRAME_SANDBOX.split(' ');
for (const token of [
'allow-scripts',
'allow-same-origin',
'allow-forms',
'allow-popups',
'allow-popups-to-escape-sandbox',
]) {
expect(tokens).toContain(token);
}
});
it('Test 10: Sandbox enthaelt KEIN top-navigation und KEIN modals (T-QD3-01)', () => {
expect(XFRAME_SANDBOX).not.toMatch(/top-navigation/);
expect(XFRAME_SANDBOX).not.toMatch(/modals/);
});
it('Test 11: Auswahlstufen des Neuladens sind 0, 60, 300, 600, 1800, 3600', () => {
expect(XFRAME_RELOAD_OPTIONS).toEqual([0, 60, 300, 600, 1800, 3600]);
});
it('Test 12: isHttpsUrl ist re-exportiert — HTTPS://A.DE true, http://a.de false', () => {
expect(isHttpsUrl('HTTPS://A.DE')).toBe(true);
expect(isHttpsUrl('http://a.de')).toBe(false);
});
});
@@ -0,0 +1,84 @@
/**
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
* (Muster picture-frame-config.ts).
*
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
* nicht einmal als `<iframe>` gerendert.
*
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
*/
import { isHttpsUrl } from './picture-frame-config';
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
export const XFRAME_RELOAD_MIN = 60;
export const XFRAME_RELOAD_MAX = 3600;
export const XFRAME_TITLE_MAX = 100;
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
export const XFRAME_SANDBOX =
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
export { isHttpsUrl };
export interface XframeConfig {
/** null = keine gueltige https-Adresse -> Leerzustand. */
url: string | null;
title: string;
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
reloadSeconds: number;
}
function resolveUrl(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isHttpsUrl(trimmed) ? trimmed : null;
}
function resolveTitle(raw: unknown): string {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, XFRAME_TITLE_MAX);
}
/**
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
*/
function resolveReload(raw: unknown): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
if (raw < XFRAME_RELOAD_MIN) return 0;
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
let best = 0;
for (const option of XFRAME_RELOAD_OPTIONS) {
if (option <= raw && option > best) best = option;
}
return best;
}
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
return {
url: resolveUrl(config.url),
title: resolveTitle(config.title),
reloadSeconds: resolveReload(config.reloadSeconds),
};
}
@@ -0,0 +1,153 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// next-intl — Durchreiche t(key) => key (Muster picture-frame-widget.test.tsx)
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import { XFRAME_SANDBOX } from './xframe-config';
import { XframeWidget } from './xframe-widget';
/**
* xframe-widget.test — NEU (quick-260921-qd3).
*
* Kachel: Leerzustand (auch bei http), genau ein <iframe> mit exakter
* Sandbox (T-QD3-01), no-referrer (T-QD3-05), lazy, leerem allow
* (T-QD3-06); Link „In neuem Tab öffnen“ als Ecksymbol oder in der
* Kopfleiste; Titel wird escaped (T-QD3-09); Neuladen per Timer haengt den
* Rahmen neu ein und raeumt beim Aushaengen (T-QD3-08); Bearbeitungs-Overlay
* nach dem Rahmen im DOM (T-QD3-07).
*
* jsdom laedt keine Unterressourcen — ein <iframe src="https://…"> erzeugt
* hier keinen Netzabruf.
*/
const URL_A = 'https://intern.example/board';
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
return render(<XframeWidget instanceId="xf-1" config={config} isEditMode={isEditMode} />);
}
function frame(): HTMLIFrameElement {
return screen.getByTestId('xframe-frame') as HTMLIFrameElement;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('XframeWidget (quick-260921-qd3)', () => {
it('Test 1: leere Konfiguration -> Leerhinweis, kein <iframe>, kein Link', () => {
renderWidget({});
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('Test 2: http-Adresse -> ebenfalls Leerzustand (Resolver weist ab)', () => {
renderWidget({ url: 'http://a.de' });
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
});
it('Test 3: https-Adresse -> genau ein <iframe> mit src, title = Adresse, exakter Sandbox, no-referrer, lazy, allow leer, Nonce 0', () => {
const { container } = renderWidget({ url: URL_A });
expect(container.querySelectorAll('iframe')).toHaveLength(1);
const f = frame();
expect(f.getAttribute('src')).toBe(URL_A);
expect(f.getAttribute('title')).toBe(URL_A);
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(f.getAttribute('sandbox')).not.toMatch(/top-navigation/);
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(f.getAttribute('loading')).toBe('lazy');
expect(f.getAttribute('allow')).toBe('');
expect(f.getAttribute('data-reload-nonce')).toBe('0');
});
it('Test 4: ohne Titel -> kein <h2>, Link als Ecksymbol (absolute) mit href, _blank, noopener noreferrer', () => {
const { container } = renderWidget({ url: URL_A });
expect(container.querySelector('h2')).toBeNull();
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.getAttribute('href')).toBe(URL_A);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
expect(link.getAttribute('rel')).toContain('noreferrer');
expect(link.className).toContain('absolute');
});
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
const { container } = renderWidget({ url: URL_A, title: 'Board' });
expect(container.querySelector('h2')).toHaveTextContent('Board');
expect(frame().getAttribute('title')).toBe('Board');
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.closest('.border-b')).not.toBeNull();
expect(link.className).not.toContain('absolute');
});
it('Test 6: Titel <b>x</b> erscheint woertlich, kein <b>-Element (React-Escaping, T-QD3-09)', () => {
const { container } = renderWidget({ url: URL_A, title: '<b>x</b>' });
expect(screen.getByText('<b>x</b>')).toBeInTheDocument();
expect(container.querySelector('b')).toBeNull();
});
it('Test 7: reloadSeconds 60 -> ein Timer; nach 60 s neues <iframe>-Objekt mit Nonce 1, nach 120 s Nonce 2; unmount raeumt', () => {
const { unmount } = renderWidget({ url: URL_A, reloadSeconds: 60 });
expect(vi.getTimerCount()).toBe(1);
const before = frame();
act(() => {
vi.advanceTimersByTime(60_000);
});
const after = frame();
expect(after).not.toBe(before);
expect(after.getAttribute('data-reload-nonce')).toBe('1');
expect(after.getAttribute('src')).toBe(URL_A);
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(frame().getAttribute('data-reload-nonce')).toBe('2');
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it('Test 8: reloadSeconds 0 -> kein Timer, Nonce bleibt 0', () => {
renderWidget({ url: URL_A, reloadSeconds: 0 });
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(3_600_000);
});
expect(frame().getAttribute('data-reload-nonce')).toBe('0');
});
it('Test 9: Bearbeitungsmodus + reloadSeconds 60 -> kein Timer (kein Neuladen beim Bearbeiten)', () => {
renderWidget({ url: URL_A, reloadSeconds: 60 }, true);
expect(vi.getTimerCount()).toBe(0);
});
it('Test 10: Bearbeitungsmodus -> Overlay aria-hidden, absolute inset-0, im DOM NACH dem <iframe>', () => {
renderWidget({ url: URL_A }, true);
const overlay = screen.getByTestId('xframe-edit-overlay');
expect(overlay.getAttribute('aria-hidden')).toBe('true');
expect(overlay.className).toContain('absolute');
expect(overlay.className).toContain('inset-0');
// FOLLOWING = 4: das Overlay folgt dem Rahmen in Dokumentreihenfolge
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('Test 11: Ansichtsmodus -> kein Overlay', () => {
renderWidget({ url: URL_A }, false);
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
});
it('Test 12: Ecksymbol sitzt im Bearbeitungsmodus tiefer (top-6) als im Ansichtsmodus (top-1)', () => {
renderWidget({ url: URL_A }, false);
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-1');
cleanup();
renderWidget({ url: URL_A }, true);
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
});
});
@@ -0,0 +1,140 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
/**
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
* in der Kachel (quick-260921-qd3).
*
* Sandbox: `XFRAME_SANDBOX` (Begruendung in xframe-config.ts, T-QD3-01) —
* keine Freigabe der Navigation des obersten Fensters, keine Modaldialoge;
* `allow=""` delegiert keine Berechtigungen (Kamera, Mikrofon, Standort,
* T-QD3-06); `referrerPolicy="no-referrer"`, damit der Fremdhost die
* Tessera-Adresse nicht erfaehrt (T-QD3-05).
*
* Woher die Seite kommt: AUSSCHLIESSLICH der Browser des Benutzers laedt
* die Adresse — der Server ruft sie nie ab (T-QD3-04: keine SSRF-Flaeche,
* kein Proxy-Endpunkt; interne Hosts sieht damit nur, wer sie ohnehin
* erreicht). Was gerendert wird, entscheidet `resolveXframeConfig`
* (T-QD3-03): nur eine https-Adresse, sonst der Leerzustand.
*
* Bearbeitungsmodus (T-QD3-07): ein <iframe> schluckt Mausereignisse und
* bricht das Ziehen. Deshalb liegt bei `isEditMode` eine transparente
* Flaeche NACH dem Rahmen im DOM ueber ihm, damit `mousedown` zur Karte
* `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte,
* eine div startet das Ziehen). Im Ansichtsmodus gibt es die Flaeche nicht,
* die Seite bleibt bedienbar.
*
* Neuladen (T-QD3-08): bei `reloadSeconds > 0` bumpt ein Intervall einen
* Zaehler, der Teil des `key` des <iframe> ist — der Rahmen wird neu
* eingehaengt. Aufraeumfunktion raeumt den Timer; im Bearbeitungsmodus
* laeuft kein Timer.
*
* Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der
* Fremdseite) ist cross-origin nicht zuverlaessig erkennbar — wird nicht
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
*/
export function XframeWidget({ config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
const [reloadNonce, setReloadNonce] = useState(0);
useEffect(() => {
if (url === null || reloadSeconds === 0 || isEditMode) return;
const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000);
return () => clearInterval(timer);
}, [url, reloadSeconds, isEditMode]);
if (url === null) {
return (
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
{t('xframe.empty')}
</div>
);
}
const openLabel = t('xframe.openInNewTab');
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{title !== '' && (
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
<NewTabLink
url={url}
label={openLabel}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
/>
</div>
)}
<div className="relative min-h-0 flex-1">
<iframe
key={`${url}#${reloadNonce}`}
src={url}
title={title !== '' ? title : url}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
loading="lazy"
data-testid="xframe-frame"
data-reload-nonce={reloadNonce}
className="h-full w-full border-0 bg-background"
/>
{isEditMode && (
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
)}
{title === '' && (
<NewTabLink
url={url}
label={openLabel}
className={`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground ${
isEditMode ? 'top-6' : 'top-1'
}`}
/>
)}
</div>
</div>
);
}
/** „In neuem Tab öffnen“ — echter Link (Tastatur), ohne opener und ohne Referrer (T-QD3-05). */
function NewTabLink({ url, label, className }: { url: string; label: string; className: string }) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
title={label}
className={className}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{/* Biome useAnchorContent zaehlt aria-label nicht als Inhalt — der
sr-only-Text traegt denselben Namen fuer Vorleser. */}
<span className="sr-only">{label}</span>
</a>
);
}