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- 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-') // 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