Files
Webklar-Kundenbereich/test/shopEmbedStatic.test.js
T

187 lines
12 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('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 = 'lazy'", '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('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')
})