Files
Webklar-Kundenbereich/test/termineSeiteStatic.test.js
ClaudeandClaude Sonnet 5.5 794a63c0d7 Terminplaner: Abschluss – Client (Formular-Token, Widerruf prominent, Telefonlink, Einbau-Code, G02)
- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
  ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
  nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
  Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
  Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
  tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
  zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
  Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

185 lines
11 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung der Gaesteseite public/termin.html + termin.js + termin.css (Aufgabe 20, Rulings R3 und "termin.html /
// termin.js: kein Browser-Speicher, keine Cookies, noindex"): Datums- und Uhrzeittexte, Kalenderdaten und der Eingang eines
// Widerrufs kommen vom Server (Berliner Zeit), die Seite rechnet nie mit Datum oder Zeitzone des Browsers. Kundentext nur
// ueber textContent.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/termin.js')
const HTML = lies('public/termin.html')
const CSS = lies('public/termin.css')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'termin.js' }))
})
test('kein Browser-Speicher, keine Cookies, kein Referrer, keine Zeitzone', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions', 'getTimezoneOffset']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('keine Datums- und Zeitzonenrechnung im Browser (Date, getDay, toLocale, Intl.)', () => {
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
for (const verboten of ['getDay', 'toLocale', 'Intl.', 'getFullYear', 'getMonth', 'getHours', 'getTime(', 'Date.now']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('Kundentext nur ueber textContent: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval', () => {
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('keine festen Fremdadressen im Code, keine nachgeladenen Skripte', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren nicht in den Code')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('genau ein fetch, ohne Cookies, nur POST an die Token-Seiten der Schnittstelle', () => {
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)')
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes("method: 'POST'"), 'Zustandsaenderungen und Abfragen nur per POST')
assert.ok(OHNE_KOMMENTARE.includes("'/api/public/termine/v1/termin/'"))
for (const aktion of ['vorschau', 'bestaetigen', 'absagen', 'verschieben', 'widerruf', 'tage', 'zeiten']) {
assert.ok(OHNE_KOMMENTARE.includes(`'${aktion}'`), `Aktion ${aktion} fehlt`)
}
})
test('der Token wird aus der Adresszeile entfernt und nur im Speicher gehalten', () => {
assert.ok(OHNE_KOMMENTARE.includes('history.replaceState'), 'Hash muss per history.replaceState entfernt werden')
assert.ok(OHNE_KOMMENTARE.includes('location.hash'))
assert.ok(!OHNE_KOMMENTARE.includes('location.href'), 'keine Weitergabe der Adresse')
assert.ok(!/location\.(?:assign|replace)\(|window\.open\(/.test(OHNE_KOMMENTARE), 'keine Weiterleitung mit dem Token')
})
test('Pflichtbausteine der Aufgabe (Ansichten, Knopftexte, Fuss, Farbe)', () => {
for (const noetig of [
'Widerruf bestätigen', 'Vertrag widerrufen', 'Termin absagen', 'Termin verschieben', 'Ja, Termin absagen', 'Nein, Termin behalten',
'bereits bestätigt', 'Eingang: ', 'eingang.datum', 'eingang.uhrzeit', 'websiteUrl', 'telefon', 'impressumUrl', 'datenschutzUrl',
'noopener noreferrer', 'httpsUrl', 'v.knopf', 'bereits abgesendet', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt in termin.js`)
}
assert.ok(!/\bconfirm\(|\balert\(|\bprompt\(/.test(OHNE_KOMMENTARE), 'keine Browser-Dialoge: die Rueckfrage steht in der Seite')
})
test('Token: replaceState steht in try/catch (wirft der Browser, laeuft die Seite weiter) und vor dem ersten Aufruf', () => {
assert.match(OHNE_KOMMENTARE, /try\s*\{\s*window\.history\.replaceState\([^)]*\)\s*\}\s*catch/)
assert.ok(OHNE_KOMMENTARE.indexOf('history.replaceState') < OHNE_KOMMENTARE.indexOf('lade()', OHNE_KOMMENTARE.indexOf('function start')), 'replaceState vor lade()')
})
test('Widerruf ist ein gleichrangiger Knopf (§ 356a BGB), kein Textlink; kein totes absaetze()', () => {
assert.ok(OHNE_KOMMENTARE.includes("knopf('t-btn t-btn-sek', 'Vertrag widerrufen'"), 'Widerruf als sekundaerer Knopf')
assert.doesNotMatch(OHNE_KOMMENTARE, /['"\s]t-link['"\s]/, 'kein Textlink-Knopf')
assert.doesNotMatch(CSS, /\.t-link\b(?!s)/, 'kein Textlink-Knopf in termin.css')
assert.ok(!OHNE_KOMMENTARE.includes('absaetze'), 'totes absaetze() entfernt')
})
test('termin.css: Reihenfolge im DOM = Anzeige (kein *-reverse), Ueberschrift ohne Rahmen, Bedienelemente mit Rahmen', () => {
assert.doesNotMatch(CSS, /-reverse/, 'kein row-reverse/column-reverse: Tab-Reihenfolge muss der Anzeige entsprechen')
// die Regel fuer die Ueberschrift muss spezifischer sein als `.t-karte :focus-visible`
assert.match(CSS, /\.t-karte \.t-titel:focus-visible\s*\{[^}]*outline:\s*none/)
assert.match(CSS, /\.t-karte :focus-visible[^{]*\{[^}]*outline:\s*3px solid/)
})
test('termin.html: noindex, kein Referrer, Stile und Skript von der eigenen Herkunft, Fuss mit Impressum WEBklar', () => {
assert.match(HTML, /<meta name="robots" content="[^"]*noindex/, 'noindex fehlt')
assert.match(HTML, /<meta name="referrer" content="no-referrer"/)
assert.match(HTML, /<html lang="de">/)
assert.match(HTML, /<meta name="viewport" content="width=device-width, initial-scale=1/)
assert.match(HTML, /aria-live="polite"/)
assert.match(HTML, /<link rel="stylesheet" href="\/termin\.css">/)
assert.match(HTML, /<script src="\/termin\.js" defer><\/script>/)
// Ruling R46: Impressum WEBklar wie auf den anderen Portalseiten (das Portal hat kein /impressum.html)
assert.match(HTML, /<a href="https:\/\/webklar\.com\/impressum" target="_blank" rel="noopener noreferrer">Impressum WEBklar<\/a>/)
assert.deepEqual(HTML.match(/https?:\/\/[^\s"'<>]+/g), ['https://webklar.com/impressum'], 'genau diese eine Fremdadresse in termin.html')
assert.doesNotMatch(HTML, /<script(?![^>]*\bsrc=)[^>]*>/, 'kein Inline-Skript')
assert.doesNotMatch(HTML, /\sstyle=/, 'keine Inline-Stile')
})
test('termin.css: keine Fremdressourcen (keine Schrift- oder Bildadressen von aussen)', () => {
assert.doesNotMatch(CSS, /https?:\/\/|@import|url\(/, 'termin.css darf nichts nachladen')
})
// ---------------------------------------------------------------- Telefonnummer (Abschlussrunde)
/** Die reine Funktion telefonLink des Skripts (zwischen den Marken @telefon-anfang und @telefon-ende) einzeln ausfuehren. */
function telefonFunktion() {
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
return vm.runInNewContext(`${m[1]}; telefonLink`, {})
}
test('telefonLink: Nummern werden gewaehlt, freier Text nie (Buchstaben, Listen), "(0)" hinter der Landesvorwahl entfaellt (dieselbe Regel wie im Widget)', () => {
const telefonLink = telefonFunktion()
const faelle = [
['06301 1234', 'tel:063011234'],
['+49 6301 1234', 'tel:+4963011234'],
['+49 (0) 6301-1234', 'tel:+4963011234'],
['+49(0)6301 1234', 'tel:+4963011234'],
['+49 (0)6301/12 34 5', 'tel:+49630112345'],
['0049 (0) 6301 1234', 'tel:004963011234'],
['(06301) 1234', 'tel:063011234'],
['06301/1234-0', 'tel:0630112340'],
['06301.1234', 'tel:063011234'],
[' +49 160 1234567 ', 'tel:+491601234567'],
['(0)6301 1234', 'tel:063011234'],
['06301 1234 (ab 9 Uhr)', ''],
['Tel. 06301 1234', ''],
['06301 1234 oder 0171 555', ''],
['0171 1, 0171 2', ''],
['06301 1234; 0171 2', ''],
['ruf an', ''],
['+49 (0) 6301 1234 ext. 5', ''],
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
function farbFunktionen() {
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
}
// Unabhaengige Gegenrechnung (WCAG 2.1: relative Leuchtdichte und Kontrastverhaeltnis)
function wcagKontrast(hexA, hexB) {
const l = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [x, y] = [l(hexA), l(hexB)]
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
test('Kopf: Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (Mitteltoene und ein Raster ueber den Farbraum)', () => {
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
for (const farbe of ['#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899']) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
}
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#fde68a'), '#111827')
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
const proben = []
for (let g = 0; g < 256; g += 5) proben.push(hex(g, g, g))
for (let r = 0; r < 256; r += 15) for (let g = 0; g < 256; g += 15) for (let b = 0; b < 256; b += 15) proben.push(hex(r, g, b))
for (const farbe of proben) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(Math.abs(kontrast(luminanz(farbe), luminanz(text)) - wcagKontrast(farbe, text)) < 1e-9, `${farbe}: Skript und Gegenrechnung weichen ab`)
}
})