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, //)
assert.match(HTML, //)
assert.match(HTML, /