Files
Webklar-Kundenbereich/test/shopEmbedStatic.test.js
T
ClaudeandClaude Opus 5.5 7104f97feb Shop Review B I-2, I-3: Gewaehrleistungskasten vor der Zusammenfassung, AGB-Hinweis ueber dem Bestellknopf
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>
2026-10-05 09:47:28 +00:00

226 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})