130 lines
7.5 KiB
JavaScript
130 lines
7.5 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', '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('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>/)
|
|
assert.match(HTML, /<a href="\/impressum\.html"[^>]*>Impressum WEBklar<\/a>/)
|
|
assert.doesNotMatch(HTML, /https?:\/\//, 'keine Fremdadressen 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')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- 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`)
|
|
}
|
|
})
|