280 lines
17 KiB
JavaScript
280 lines
17 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import vm from 'node:vm'
|
||
import { luminanz as serverLuminanz, textFarbeAuf as serverTextFarbeAuf } from '../server/services/termine/einbauanleitung.js'
|
||
|
||
// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4):
|
||
// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen
|
||
// vom Server: monat, tage, zeiten, datumLang). Darum kein Date, getDay, toLocale*, Intl.
|
||
// - Kein Browser-Speicher, keine Cookies, kein Auslesen von Referrer oder Zeitzone, keine Fremdadressen im Code.
|
||
// - Kundentext nur ueber textContent/Attribute: innerHTML nur mit festen Zeichenketten.
|
||
// Die Pruefung der Seiten drumherum (Demo, Einbauanleitung) steht am Ende.
|
||
|
||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||
const QUELLE = lies('public/embed/termine.js')
|
||
|
||
/** 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: 'termine.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 im Widget 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 im Widget nicht vorkommen`)
|
||
}
|
||
})
|
||
|
||
test('innerHTML nur mit festen Zeichenketten; kein outerHTML, insertAdjacentHTML, document.write, eval', () => {
|
||
const treffer = [...OHNE_KOMMENTARE.matchAll(/innerHTML/g)]
|
||
for (const t of treffer) {
|
||
const rest = OHNE_KOMMENTARE.slice(t.index + 'innerHTML'.length)
|
||
assert.match(rest, /^\s*=\s*(?:'[^'\\+]*'|"[^"\\+]*")\s*(?:;|\n|\))/, 'innerHTML darf nur eine feste Zeichenkette bekommen')
|
||
}
|
||
for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
|
||
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
|
||
}
|
||
})
|
||
|
||
test('keine festen Fremdadressen im Code (nur die Adresse aus data-api oder dem Skript)', () => {
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code')
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
|
||
})
|
||
|
||
test('Anfragen ohne Cookies und nur an die Termine-Schnittstelle', () => {
|
||
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
|
||
assert.ok(OHNE_KOMMENTARE.includes('/api/public/termine/v1/'))
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)')
|
||
})
|
||
|
||
test('Pflichtbausteine der Spezifikation (Einbau, Stile, Honeypot, Fuss, Texte)', () => {
|
||
for (const noetig of [
|
||
'data-webklar-termine', 'data-api', 'data-modus', 'data-knopf', 'data-leistung',
|
||
"'wkt-style'", '@layer wkt', 'wkt-',
|
||
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
|
||
'kontakt_url', 'autocomplete', 'tabindex', 'aria-hidden', 'aria-live', 'polite', 'aria-selected', 'aria-disabled', 'role', 'dialog',
|
||
'performance.now', '100 * 60 * 1000', 'neu_laden', '/widerruf',
|
||
'Vertrag widerrufen', 'Datenschutz', 'Terminbuchung über WEBklar', 'Widerruf bestätigen',
|
||
'Gerade nicht erreichbar. Bitte rufen Sie an: ',
|
||
'Fast geschafft', 'widerrufsbelehrung', 'details',
|
||
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown',
|
||
]) {
|
||
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
|
||
}
|
||
})
|
||
|
||
test('Stile stehen einmal in <style id="wkt-style">, alle Klassen beginnen mit wkt-', () => {
|
||
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
|
||
assert.ok(css, 'CSS-Block fehlt')
|
||
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]).filter((k) => !/^\d/.test(k))
|
||
assert.ok(klassen.length > 20)
|
||
for (const k of klassen) assert.ok(k.startsWith('wkt-'), `Klasse ${k} ohne Praefix wkt-`)
|
||
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
|
||
})
|
||
|
||
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link auf die AGB nur als https-Link', () => {
|
||
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
|
||
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
|
||
assert.ok(QUELLE.includes('agbUrl') && QUELLE.includes('AGB des Betriebs'))
|
||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(c\.betrieb\.agbUrl\)/)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Telefonnummer, Formular-Token, Widerrufsknoepfe (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', () => {
|
||
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'],
|
||
// Text mit Buchstaben oder Mehrfachangaben: kein Link
|
||
['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', ''],
|
||
// keine Nummer
|
||
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
|
||
]
|
||
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
|
||
})
|
||
|
||
test('R53: das Widget wartet vor dem Absenden, bis das Formular-Token formMinSekunden plus Puffer alt ist (Einen Moment …), Messung mit performance.now', () => {
|
||
for (const noetig of ['formMinSekunden', 'FORM_PUFFER_MS', 'FORM_MIN_VORGABE_S', 'warteAufFormToken', 'Einen Moment …']) {
|
||
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
|
||
}
|
||
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/, 'Puffer 0,5 s')
|
||
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/, 'Vorgabe 3 s wie der Server')
|
||
// der Abruf der Buchung steht erst nach dem Warten
|
||
const sende = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function sende ('))
|
||
assert.ok(sende.indexOf('warteAufFormToken(senden)') > 0 && sende.indexOf("request('POST', '/buchungen'") > sende.indexOf('warteAufFormToken(senden)'), 'POST /buchungen nur nach warteAufFormToken')
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/buchungen'/g)].length, 1, 'genau eine Stelle sendet Buchungen')
|
||
})
|
||
|
||
test('R54: Widerruf im Fuss ist ein zweiter Knopf (wkt-btn wkt-btn-sek, 44 px), kein Textlink; im Knopfmodus ein Abruf beim Laden und ein dauerhafter Knopf', () => {
|
||
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen'"), 'Fuss: sekundaerer Knopf')
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /wkt-link/, 'kein Textlink-Knopf')
|
||
assert.match(QUELLE, /\.wkt-btn,\.wkt-knopf\{[^}]*min-height:44px/, 'Knoepfe mindestens 44 px hoch')
|
||
// Knopfmodus: neben dem Buchungsknopf, oeffnet den Dialog gleich beim Widerruf
|
||
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-knopf wkt-btn-sek', 'Vertrag widerrufen'"), 'dauerhafter Knopf im Knopfmodus')
|
||
assert.ok(OHNE_KOMMENTARE.includes('oeffne(true)'), 'der Knopf oeffnet den Dialog beim Widerrufsformular')
|
||
const start = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function startKnopf'))
|
||
assert.ok(start.includes('lade()') && start.indexOf('lade()') < start.indexOf('widerrufKnopf = knopf'), 'startKnopf holt die Konfiguration beim Laden')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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('Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (auch fuer mittlere Toene)', () => {
|
||
const { textFarbeAuf } = farbFunktionen()
|
||
// Mitteltoene, bei denen Weiss allein nur 2,5 bis 3,6 : 1 haette
|
||
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.ok(wcagKontrast(farbe, text) >= wcagKontrast(farbe, '#ffffff') - 1e-9, `${farbe}: Weiss waere besser gewesen`)
|
||
}
|
||
// dunkle und helle Farben behalten Weiss bzw. das dunkle Grau
|
||
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
|
||
assert.equal(textFarbeAuf('#1d4ed8'), '#ffffff')
|
||
assert.equal(textFarbeAuf('#fde68a'), '#111827')
|
||
assert.equal(textFarbeAuf('#ffffff'), '#111827')
|
||
assert.equal(textFarbeAuf('#000000'), '#ffffff')
|
||
})
|
||
|
||
test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raster ueber den Farbraum)', () => {
|
||
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
|
||
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 += 1) 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(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: unerwartete Textfarbe ${text}`)
|
||
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`)
|
||
}
|
||
})
|
||
|
||
test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) waehlen fuer alle 4096 Farben dieselbe Schrift', () => {
|
||
const { textFarbeAuf, luminanz } = farbFunktionen()
|
||
const stufen = Array.from({ length: 16 }, (_, i) => i * 17) // 0, 17, ... 255
|
||
const hex = (c) => c.toString(16).padStart(2, '0')
|
||
let anzahl = 0
|
||
for (const r of stufen) {
|
||
for (const g of stufen) {
|
||
for (const b of stufen) {
|
||
const farbe = `#${hex(r)}${hex(g)}${hex(b)}`
|
||
assert.equal(serverTextFarbeAuf(farbe), textFarbeAuf(farbe), `${farbe}: Schriftfarbe weicht ab`)
|
||
assert.equal(serverLuminanz(farbe), luminanz(farbe), `${farbe}: Leuchtdichte weicht ab`)
|
||
anzahl += 1
|
||
}
|
||
}
|
||
}
|
||
assert.equal(anzahl, 4096)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Stornobedingungen nur bei Leistungen mit Preis (TP2 F11)
|
||
|
||
/** Die reine Funktion stornoGilt des Skripts (zwischen den Marken @storno-anfang und @storno-ende) einzeln ausfuehren. */
|
||
function stornoFunktion() {
|
||
const m = /\/\/ @storno-anfang[^\n]*\n([\s\S]*?)\/\/ @storno-ende/.exec(QUELLE)
|
||
assert.ok(m, 'Marken @storno-anfang/@storno-ende fehlen')
|
||
return vm.runInNewContext(`${m[1]}; stornoGilt`, {})
|
||
}
|
||
|
||
test('widget_storno_je_leistung: Box, Haken und Pruefung der Stornobedingungen haengen am Preis der gewaehlten Leistung', () => {
|
||
const stornoGilt = stornoFunktion()
|
||
const storno = { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' }
|
||
// nur mit Stornotext des Betriebs UND einer Leistung mit Preis (l.preis ist der Preistext des Servers, '' ohne Preis)
|
||
assert.equal(stornoGilt(storno, { id: 's1', preis: '45,00 €' }), true)
|
||
assert.equal(stornoGilt(storno, { id: 's1', preis: 'ab 45,00 €' }), true)
|
||
assert.equal(stornoGilt(storno, { id: 's2', preis: '' }), false, 'kostenlos: weder Box noch Haken noch Pruefung')
|
||
assert.equal(stornoGilt(storno, { id: 's2' }), false)
|
||
assert.equal(stornoGilt(storno, { id: 's2', preis: null }), false)
|
||
assert.equal(stornoGilt(storno, null), false)
|
||
assert.equal(stornoGilt(storno, undefined), false)
|
||
assert.equal(stornoGilt(null, { id: 's1', preis: '45,00 €' }), false, 'kein Stornotext beim Betrieb')
|
||
assert.equal(stornoGilt(undefined, { id: 's1', preis: '45,00 €' }), false)
|
||
// im Zusammenfassungs-Schritt: Box, Haken und Pruefung fragen alle stornoGilt(…, l) – nie mehr nur c.storno
|
||
assert.match(OHNE_KOMMENTARE, /var stornoAn = stornoGilt\(c\.storno, l\)/)
|
||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && istText\(c\.storno\.text\)\) \{\s*sb\.appendChild\(el\('h3', '', 'Stornobedingungen'\)\)/, 'Box nur mit Stornotext und Preis')
|
||
assert.match(OHNE_KOMMENTARE, /if \(\(stornoAn && istText\(c\.storno\.text\)\) \|\| agbUrl\) \{/, 'der AGB-Link bleibt unabhaengig vom Preis')
|
||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn\) form\.appendChild\(haken\(refs, 'storno', c\.texte\.storno, dd\.storno, true,/)
|
||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && !dd\.storno\) f\.storno = /)
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /if \(c\.storno\)|if \(c\.storno &&|c\.storno && !dd\.storno/, 'kein Haken und keine Pruefung allein nach c.storno')
|
||
// gesendet wird der Haken nur, wenn er gezeigt wurde
|
||
assert.match(OHNE_KOMMENTARE, /zustimmungen: \{ storno: stornoGilt\(S\.cfg\.storno, aktuelleLeistung\(\)\) && !!dd\.storno, vorzeitig: !!dd\.vorzeitig \}/)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Demo und Einbauanleitung
|
||
|
||
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
|
||
const demo = lies('public/embed/termine-demo.html')
|
||
assert.match(demo, /<meta name="robots" content="noindex"/)
|
||
assert.match(demo, /<script src="\/embed\/termine\.js" defer><\/script>/)
|
||
assert.match(demo, /data-webklar-termine="abcdefghijklmnop"/)
|
||
assert.match(demo, /data-modus="inline"/)
|
||
assert.match(demo, /data-modus="knopf"/)
|
||
assert.match(demo, /data-knopf="/)
|
||
assert.match(demo, /Termine telefonisch/, 'Ausweichtext fehlt')
|
||
})
|
||
|
||
test('Einbauanleitung: Snippet, Variablen, drei Stile, Datenschutz-Absatz, Abnahme-Checkliste, API-Kurzreferenz', () => {
|
||
const doc = lies('docs/funktionen/termine-einbau.md')
|
||
for (const noetig of [
|
||
'<div data-webklar-termine="SITEKEY"', '<script src="https://project.webklar.com/embed/termine.js" defer></script>',
|
||
'data-modus', 'data-api', 'data-knopf', 'data-leistung',
|
||
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
|
||
'@layer wkt', 'wkt-style',
|
||
'docs/rechtliches/termine-datenschutz-absatz.md', 'Fassung',
|
||
'Abnahme', '/api/public/termine/v1', '/config', '/tage', '/zeiten', '/buchungen', '/widerruf', '/kalender/',
|
||
'termine-demo.html', 'termine-widget.ui.mjs',
|
||
]) {
|
||
assert.ok(doc.includes(noetig), `${noetig} fehlt in der Einbauanleitung`)
|
||
}
|
||
assert.equal([...doc.matchAll(/^### Stil \d/gm)].length, 3, 'drei Stilbeispiele')
|
||
assert.ok([...doc.matchAll(/^- \[ \] /gm)].length >= 8, 'Abnahme-Checkliste mit Punkten')
|
||
})
|