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('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