"zzgl. Versand" steht jetzt unter dem Preis jeder Artikelkarte und im Detail, nur wenn der Shop versendet. Unter dem Raster bleibt der Steuerhinweis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
16 KiB
JavaScript
239 lines
16 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('M-3: "zzgl. Versand" steht bei jedem Preis (Karte und Detail) und nur, wenn der Shop versendet - Abholshops zeigen es nicht', () => {
|
||
assert.equal([...OHNE_KOMMENTARE.matchAll(/'zzgl\. Versand'/g)].length, 1, 'der Text steht an genau einer Stelle (versandLink)')
|
||
const helfer = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function versandAktiv'), OHNE_KOMMENTARE.indexOf('function gewaehrKasten'))
|
||
assert.match(helfer, /function versandAktiv \(\) \{ return liste\(shop\(\)\.lieferlaender\)\.length > 0 \}/, 'Versand = es gibt Lieferländer')
|
||
assert.match(helfer, /return versandAktiv\(\) \? knopf\('wks-link', 'zzgl\. Versand'/)
|
||
const karte = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function karte (p)'), OHNE_KOMMENTARE.indexOf('// ------------------------------------------------------------ Detail'))
|
||
assert.ok(karte.indexOf("el('p', 'wks-preis'") < karte.indexOf('versandLink()'), 'Hinweis nach dem Preis in der Karte')
|
||
const raster = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function zeichneRaster'), OHNE_KOMMENTARE.indexOf('function karte (p)'))
|
||
assert.ok(!raster.includes('Versand'), 'unter dem Raster steht nur der Steuerhinweis')
|
||
const detail = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function zeichneDetail'), OHNE_KOMMENTARE.indexOf('function aktualisierePreis'))
|
||
assert.ok(detail.includes('versandLink()') && detail.includes('if (hinweis.childNodes.length) rechts.appendChild(hinweis)'))
|
||
})
|
||
|
||
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')
|
||
})
|