Die amtliche Gewaehrleistungsgrafik steht in hervorgehobenem Kasten mit Ueberschrift vor der Zusammenfassung der Kasse (hoechstens 420 px breit, sofort geladen, Vergroessern oeffnet sie in einem mit Esc und Knopf schliessbaren Dialog). Direkt ueber "Zahlungspflichtig bestellen" steht "Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der Widerrufsbelehrung." mit Links auf beide Texte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
15 KiB
JavaScript
226 lines
15 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync, statSync } from 'node:fs'
|
||
import vm from 'node:vm'
|
||
|
||
// Statische Pruefung des Einbau-Skripts public/embed/shop.js (Plan 2026-10-05-webshop.md, Task H1; Muster test/termineEmbedStatic.test.js):
|
||
// - Kundentext nur ueber textContent/Attribute: kein innerHTML, kein eval.
|
||
// - Das Widget rechnet nie: keine Datums-, Zahlen- oder Zeitzonenformatierung im Browser (alles kommt als fertiger Text vom Server).
|
||
// - Einziger Browser-Speicher ist der Warenkorb in der sessionStorage, immer in try/catch; keine Cookies, keine Fremdadressen im Code.
|
||
// - Pflichttexte der Kasse und der Widerrufsfunktion (§ 312j Abs. 3 BGB, § 356a BGB) stehen im Skript.
|
||
|
||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||
const QUELLE = lies('public/embed/shop.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) und unter 120 KB', () => {
|
||
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'shop.js' }))
|
||
assert.ok(statSync(new URL('../public/embed/shop.js', import.meta.url)).size < 120 * 1024, 'Skript ist zu gross')
|
||
})
|
||
|
||
test('ES5-Stil wie termine.js: IIFE mit use strict, keine Pfeilfunktionen, let/const, Schablonenzeichenketten', () => {
|
||
assert.match(OHNE_KOMMENTARE, /^\s*\(function \(\) \{\s*'use strict'/)
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /=>/, 'keine Pfeilfunktionen')
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /\b(?:let|const)\s/, 'var statt let/const')
|
||
assert.ok(!OHNE_KOMMENTARE.includes('`'), 'keine Schablonenzeichenketten')
|
||
})
|
||
|
||
test('innerHTML gar nicht (Fremddaten nur ueber textContent); kein outerHTML, insertAdjacentHTML, document.write, eval, new Function', () => {
|
||
assert.ok(!/innerHTML/.test(OHNE_KOMMENTARE), 'innerHTML darf im Widget nicht vorkommen')
|
||
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 Zahlen-, Datums- oder Zeitzonenrechnung im Browser (Date, Intl, toLocale, toFixed)', () => {
|
||
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
|
||
for (const verboten of ['toLocale', 'Intl.', 'toFixed', 'getTimezoneOffset', 'resolvedOptions', 'getFullYear', 'getHours', 'Date.now']) {
|
||
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
|
||
}
|
||
})
|
||
|
||
test('keine Cookies, kein localStorage, kein Referrer, kein Beacon; keine festen Fremdadressen im Code', () => {
|
||
for (const verboten of ['localStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent']) {
|
||
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
|
||
}
|
||
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('sessionStorage nur fuer den Warenkorb unter wks-korb-<siteKey> und jeder Zugriff steht in try/catch', () => {
|
||
const treffer = [...OHNE_KOMMENTARE.matchAll(/sessionStorage/g)]
|
||
assert.ok(treffer.length >= 3, 'lesen, schreiben, entfernen')
|
||
for (const t of treffer) {
|
||
const davor = OHNE_KOMMENTARE.slice(0, t.index)
|
||
const tryPos = davor.lastIndexOf('try {')
|
||
assert.ok(tryPos >= 0, 'sessionStorage ohne try davor')
|
||
const zwischen = davor.slice(tryPos)
|
||
assert.ok(!/\bcatch\b/.test(zwischen), 'sessionStorage steht ausserhalb des try-Blocks')
|
||
assert.ok(zwischen.length < 600, 'try und sessionStorage liegen weit auseinander')
|
||
}
|
||
assert.ok(OHNE_KOMMENTARE.includes("'wks-korb-'"), 'Schluessel wks-korb-<siteKey>')
|
||
// gespeichert werden nur IDs und Mengen: das Array [id, variante, menge]
|
||
assert.match(OHNE_KOMMENTARE, /korb\.map\(function \(z\) \{ return \[z\.id, z\.v, z\.n\] \}\)/)
|
||
})
|
||
|
||
test('Anfragen ohne Cookies und nur an die Shop-Schnittstelle, eine Stelle fuer alle Anfragen, Zeitgrenze 20 s', () => {
|
||
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
|
||
assert.ok(OHNE_KOMMENTARE.includes('/api/public/shop/v1/'))
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch')
|
||
assert.match(OHNE_KOMMENTARE, /TIMEOUT_MS\s*=\s*20000\b/)
|
||
for (const pfad of ["'/pruefen'", "'/bestellung'", "'/widerruf'", "'/bestellung/'"]) assert.ok(OHNE_KOMMENTARE.includes(pfad), `Pfad ${pfad} fehlt`)
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/bestellung'/g)].length, 1, 'genau eine Stelle legt Bestellungen an')
|
||
})
|
||
|
||
test('Widerruf: Bestellnummer freiwillig mit Hinweis auf Bestelldatum und Artikel (§ 356a BGB)', () => {
|
||
assert.ok(QUELLE.includes("label: 'Bestellnummer (falls zur Hand)'"))
|
||
assert.ok(QUELLE.includes('Ohne Bestellnummer: Nennen Sie bitte Bestelldatum und Artikel im Feld „Erklärung“, damit wir Ihren Vertrag zuordnen können.'))
|
||
assert.ok(QUELLE.includes('Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.'), 'Pruefung im Formular ohne Nummer')
|
||
assert.ok(!/label: 'Bestellnummer'/.test(QUELLE))
|
||
})
|
||
|
||
test('Pflichttexte: Knopf der Kasse, Widerrufsfunktion, Gewaehrleistungsbild, Stripe-Hinweis, Fuss, Kopfzeile, geschlossen', () => {
|
||
for (const noetig of [
|
||
"'Zahlungspflichtig bestellen'", "'Vertrag widerrufen'", "'Widerruf bestätigen'", "'Weiter'",
|
||
'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.',
|
||
'Der Shop ist gerade geschlossen.', 'Shop-Technik: WEBklar', 'Lieferung nach: ', 'Abholung möglich', 'Zahlung: ',
|
||
"'AGB'", "'Widerrufsbelehrung'", "'Versand & Zahlung'", "'Impressum'", "'Datenschutz'", 'zzgl. Versand',
|
||
'Vielen Dank für Ihre Bestellung', 'bei uns eingegangen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.',
|
||
'rel = \'noopener noreferrer\'', 'img.alt = g.text', "img.loading = 'eager'", 'gewaehrleistung',
|
||
'wk_bestellung', 'wk_t', 'wk_abbruch', 'replaceState', 'kontakt_url', 'formToken', 'rueckUrl: location.href', 'nurWiderruf',
|
||
'data-webklar-shop', 'data-api', 'data-ansicht', "'ansicht'", 'aria-live', 'aria-pressed', 'tabIndex = -1', 'showModal',
|
||
]) {
|
||
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
|
||
}
|
||
// der Knopf heisst genau so (kein Zusatz) und steht in der Kasse
|
||
assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Zahlungspflichtig bestellen')"))
|
||
assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Widerruf bestätigen')"))
|
||
assert.ok(OHNE_KOMMENTARE.includes("knopf('wks-btn wks-btn-sek', 'Vertrag widerrufen'"), 'Widerruf im Fuss ist ein Knopf')
|
||
})
|
||
|
||
test('I-2: Gewährleistungsgrafik steht hervorgehoben vor der Zusammenfassung, höchstens 420 px, sofort geladen, mit Vergrößern-Dialog', () => {
|
||
assert.ok(OHNE_KOMMENTARE.includes("'Ihre gesetzlichen Gewährleistungsrechte'"), 'Überschrift')
|
||
assert.match(OHNE_KOMMENTARE, /\.wks-gewaehr img\{[^']*width:100%;max-width:420px/)
|
||
assert.match(OHNE_KOMMENTARE, /\.wks-gewaehr\{[^']*border:2px solid/)
|
||
const kastenCode = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function gewaehrKasten'), OHNE_KOMMENTARE.indexOf('function honigfeld'))
|
||
assert.ok(kastenCode.includes("img.alt = g.text") && !kastenCode.includes('lazy'), 'Pflichtbild nicht verzögert laden, Alt-Text = voller Text')
|
||
assert.ok(OHNE_KOMMENTARE.includes("img.loading = 'eager'"))
|
||
assert.ok(OHNE_KOMMENTARE.includes("'Vergrößern'"), 'Knopf')
|
||
assert.match(OHNE_KOMMENTARE, /dialogOeffnen\('Ihre gesetzlichen Gewährleistungsrechte', \[\{ bild: bildUrl, text: g\.text \}\]/)
|
||
assert.match(OHNE_KOMMENTARE, /e\.key === 'Escape'/, 'Esc schließt')
|
||
assert.ok(OHNE_KOMMENTARE.includes('knopf(\'wks-schliessen\', \'×\', dialogSchliessen)'), 'Schließen-Knopf')
|
||
// Reihenfolge in der Kasse: Kasten -> Zusammenfassung -> AGB-Hinweis -> Knopf
|
||
const i = (t) => OHNE_KOMMENTARE.indexOf(t)
|
||
const kasten = i('form.appendChild(gkasten)')
|
||
const pflicht = i('form.appendChild(pflicht)')
|
||
const hinweis = i('form.appendChild(agbHinweis)')
|
||
const knopf = i("var senden = el('button', 'wks-btn', 'Zahlungspflichtig bestellen')")
|
||
assert.ok(kasten > 0 && kasten < pflicht && pflicht < hinweis && hinweis < knopf, 'Reihenfolge')
|
||
// nicht mehr in der Zusammenfassung (die wird bei jeder Änderung neu aufgebaut)
|
||
const fuelle = OHNE_KOMMENTARE.slice(i('function fuellePflicht'), i('function landName'))
|
||
assert.ok(!fuelle.includes('gewaehr'), 'Gewährleistung nicht in fuellePflicht')
|
||
})
|
||
|
||
test('I-3: ausdrücklicher AGB-Hinweis direkt über dem Bestellknopf, AGB und Widerrufsbelehrung öffnen die Texte', () => {
|
||
const stelle = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf("var agbHinweis = el('p'"), OHNE_KOMMENTARE.indexOf("var senden = el('button'"))
|
||
assert.ok(stelle.includes("createTextNode('Es gelten unsere ')"))
|
||
assert.ok(stelle.includes("knopf('wks-link', 'AGB', function (e) { zeigeAgb(e.currentTarget) })"))
|
||
assert.ok(stelle.includes("createTextNode('. Informationen zu Ihrem Widerrufsrecht finden Sie in der ')"))
|
||
assert.ok(stelle.includes("knopf('wks-link', 'Widerrufsbelehrung', function (e) { zeigeBelehrung(e.currentTarget) })"))
|
||
assert.ok(stelle.includes("createTextNode('.')"))
|
||
})
|
||
|
||
test('Weiterleitung zu Stripe nur zu einer https-Adresse (httpsUrl), Links der Rechtstexte nur als https', () => {
|
||
assert.match(OHNE_KOMMENTARE, /var ziel = d \? httpsUrl\(d\.url\) : ''/)
|
||
assert.match(OHNE_KOMMENTARE, /location\.href = ziel/)
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/location\.href\s*=/g)].length, 1, 'nur eine Weiterleitung')
|
||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.impressumUrl\)/)
|
||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.datenschutzUrl\)/)
|
||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(g\.bild\)/)
|
||
// Bildpfade des Servers (ohne Host) bekommen nur die API-Adresse vorangestellt
|
||
assert.match(OHNE_KOMMENTARE, /function absUrl \(api, pfad\)/)
|
||
})
|
||
|
||
test('Die Zusammenfassung der Kasse kommt vom Server (/pruefen) und wird bei jeder Aenderung neu geholt; veraltete Antworten verworfen', () => {
|
||
assert.match(OHNE_KOMMENTARE, /request\('POST', '\/pruefen'/)
|
||
assert.ok(OHNE_KOMMENTARE.includes('pruefSeq'), 'Zaehler gegen veraltete Antworten')
|
||
assert.match(OHNE_KOMMENTARE, /senden\.disabled = true/, 'Knopf bleibt gesperrt, bis die Zusammenfassung da ist')
|
||
// Preise werden nie im Widget gerechnet: keine Multiplikation von Preisen
|
||
assert.doesNotMatch(OHNE_KOMMENTARE, /preisCent|summeCent|einzelCent|parseFloat/)
|
||
})
|
||
|
||
test('Warten auf das Formular-Token (3 s) vor Bestellung und Widerruf; Konfiguration nach 100 min erneuert', () => {
|
||
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/)
|
||
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/)
|
||
assert.match(OHNE_KOMMENTARE, /CONFIG_ALTER_MAX_MS\s*=\s*100 \* 60 \* 1000/)
|
||
for (const stelle of ["request('POST', '/bestellung'", "request('POST', '/widerruf'"]) {
|
||
const vor = OHNE_KOMMENTARE.slice(0, OHNE_KOMMENTARE.indexOf(stelle))
|
||
const warte = vor.lastIndexOf('warteAufFormToken(senden)')
|
||
assert.ok(warte >= 0 && vor.length - warte < 400, `${stelle} nur nach warteAufFormToken`)
|
||
}
|
||
})
|
||
|
||
test('Warenkorb-Rueckkehr: bis zu fuenf Abrufe im Abstand von 2 s solange die Bestellung offen ist', () => {
|
||
assert.match(OHNE_KOMMENTARE, /STATUS_VERSUCHE\s*=\s*5\b/)
|
||
assert.match(OHNE_KOMMENTARE, /STATUS_ABSTAND_MS\s*=\s*2000\b/)
|
||
assert.ok(OHNE_KOMMENTARE.includes("d.status === 'offen' && st.versuch < STATUS_VERSUCHE"))
|
||
})
|
||
|
||
test('Stile stehen einmal in <style id="wks-style"> im @layer wks, alle Klassen beginnen mit wks-', () => {
|
||
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
|
||
assert.ok(css, 'CSS-Block fehlt')
|
||
assert.ok(css[1].includes('@layer wks{'))
|
||
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]).filter((k) => !/^\d/.test(k))
|
||
assert.ok(klassen.length > 40)
|
||
for (const k of klassen) assert.ok(k.startsWith('wks-'), `Klasse ${k} ohne Praefix wks-`)
|
||
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
|
||
assert.ok(QUELLE.includes("'wks-style'"))
|
||
for (const variable of ['--wks-akzent', '--wks-akzent-text', '--wks-hintergrund', '--wks-text', '--wks-rand', '--wks-radius', '--wks-schrift', '--wks-schrift-titel']) {
|
||
assert.ok(QUELLE.includes(variable), `${variable} fehlt`)
|
||
}
|
||
})
|
||
|
||
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite)', () => {
|
||
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
|
||
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
|
||
})
|
||
|
||
/** 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 })`, {})
|
||
}
|
||
|
||
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 Akzentfarbe erreicht mindestens 4,5 : 1 (Grautoene und ein Raster ueber den Farbraum)', () => {
|
||
const { textFarbeAuf } = farbFunktionen()
|
||
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
|
||
const proben = ['#aa3300', '#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899', '#fde68a', '#ffffff', '#000000']
|
||
for (let g = 0; g < 256; g += 5) proben.push(hex(g, g, g))
|
||
for (let r = 0; r < 256; r += 51) for (let g = 0; g < 256; g += 51) for (let b = 0; b < 256; b += 51) 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`)
|
||
}
|
||
})
|
||
|
||
test('Demo-Seite: noindex, Platzhalter-siteKey, Ausweichtext, Skript per defer', () => {
|
||
const demo = lies('public/embed/shop-demo.html')
|
||
assert.match(demo, /<meta name="robots" content="noindex"/)
|
||
assert.match(demo, /<script src="\/embed\/shop\.js" defer><\/script>/)
|
||
assert.match(demo, /data-webklar-shop="abcdefghijklmnop"/)
|
||
assert.match(demo, /Bestellungen telefonisch/, 'Ausweichtext fehlt')
|
||
})
|