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);
});
});