Terminplaner: Einbau-Skript, Demo und Einbauanleitung
public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog, Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url, Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb45d35ec7
commit
5fef7e47f0
6 files changed
+2825
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 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', 'ENTWURF',
|
||||
'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')
|
||||
})
|
||||
Reference in new issue
Block a user