diff --git a/scripts/umschalten-nachpruefung.mjs b/scripts/umschalten-nachpruefung.mjs
new file mode 100644
index 0000000..cf7619e
--- /dev/null
+++ b/scripts/umschalten-nachpruefung.mjs
@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+/**
+ * Kundenansicht neu (P10 Umschalten): prueft per HTTP, ob die oeffentlichen Seiten, das Manifest und die Abo-Bedingungen
+ * so ausgeliefert werden, wie der Schalter KUNDENANSICHT_NEU es verlangt: "alt" (off und pilot) oder "neu" (on).
+ * Nur lesend: je Pruefung ein GET ohne Cookies und ohne Umleitungen, kein Appwrite, kein Schreiben, keine Namen
+ * oder Kundendaten in der Ausgabe (nur Adressen der Seiten und Gruende).
+ * Usage:
+ * node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=alt
+ * node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=neu
+ * Exit 0 = alle Zeilen gruen, 1 = mindestens eine Zeile rot, 2 = falscher Aufruf.
+ */
+import { pathToFileURL } from 'node:url'
+
+const ZEITLIMIT_MS = 15000
+
+const html = (pfad, enthaelt, enthaeltNicht = []) => Object.freeze({ pfad, typ: 'html', enthaelt, enthaeltNicht })
+// Manifest: "schluessel=wert" prueft ein Feld, "shortcuts.name=X" einen Shortcut mit diesem Namen
+const manifest = (enthaelt) => Object.freeze({ pfad: '/manifest.webmanifest', typ: 'json', enthaelt, enthaeltNicht: [] })
+const pdf = Object.freeze({ pfad: '/WEBklar-Abo-Vertragsunterlagen.pdf', typ: 'pdf', enthaelt: ['application/pdf', '%PDF-'], enthaeltNicht: [] })
+
+const NEU_FORMULAR = ['/neu/oeffentlich/oeffentlich.css', '/legal-form.js']
+
+/** Marker je Erwartung; jede Zeile verlangt Status 200. */
+export const PRUEFUNGEN = Object.freeze({
+ neu: Object.freeze([
+ html('/dashboard.html', [''], ['kb-page']),
+ html('/login.html', ['content="#ffffff"', '/neu/tokens.css'], ['#9d3dfe', '/login.css']),
+ html('/kuendigen.html', NEU_FORMULAR, ['/legal-form.css']),
+ html('/widerrufen.html', NEU_FORMULAR, ['/legal-form.css']),
+ html('/offline.html', ['content="#ffffff"'], ['#9d3dfe', ' { basis, erwartet }; sonst Error "Aufruf: ...". */
+export function parseArgs(argv) {
+ const args = Array.isArray(argv) ? argv : []
+ const optionen = args.filter((a) => typeof a === 'string' && a.startsWith('--'))
+ const rest = args.filter((a) => !optionen.includes(a))
+ const erwartet = optionen.length === 1 ? /^--erwartet=(neu|alt)$/.exec(optionen[0])?.[1] : undefined
+ const basis = rest.length === 1 ? rest[0] : undefined
+ // nur Schema, Host und optional Port: kein Pfad, keine Zugangsdaten, keine Parameter
+ if (!erwartet || typeof basis !== 'string' || !/^https?:\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(:\d{1,5})?$/.test(basis)) throw new Error(AUFRUF)
+ return { basis, erwartet }
+}
+
+/** Ein Feld des Manifests nach "schluessel=wert" / "shortcuts.name=X" pruefen. */
+function manifestHat(daten, marker) {
+ const gleich = marker.indexOf('=')
+ const schluessel = marker.slice(0, gleich)
+ const wert = marker.slice(gleich + 1)
+ if (schluessel === 'shortcuts.name') return Array.isArray(daten?.shortcuts) && daten.shortcuts.some((s) => s?.name === wert)
+ return daten?.[schluessel] === wert
+}
+
+/** Eine Pruefung auswerten -> { pfad, ok, grund }. */
+async function pruefe(basis, pruefung, fetchFn) {
+ const zeile = (ok, grund = '') => ({ pfad: pruefung.pfad, ok, grund })
+ let antwort
+ let text
+ try {
+ antwort = await fetchFn(basis + pruefung.pfad, {
+ redirect: 'manual',
+ headers: { 'cache-control': 'no-cache' },
+ signal: AbortSignal.timeout(ZEITLIMIT_MS),
+ })
+ text = await antwort.text()
+ } catch {
+ return zeile(false, 'Netzfehler')
+ }
+ if (antwort.status !== 200) return zeile(false, `Status ${antwort.status} statt 200`)
+
+ if (pruefung.typ === 'pdf') {
+ const art = antwort.headers.get('content-type') || ''
+ if (!art.includes('application/pdf')) return zeile(false, `Inhaltstyp ${art || 'fehlt'} statt application/pdf`)
+ return text.startsWith('%PDF-') ? zeile(true) : zeile(false, 'Beginn %PDF- fehlt')
+ }
+
+ if (pruefung.typ === 'json') {
+ let daten
+ try {
+ daten = JSON.parse(text)
+ } catch {
+ return zeile(false, 'kein JSON')
+ }
+ const fehlt = pruefung.enthaelt.find((m) => !manifestHat(daten, m))
+ return fehlt ? zeile(false, `fehlt: ${fehlt}`) : zeile(true)
+ }
+
+ const fehlt = pruefung.enthaelt.find((m) => !text.includes(m))
+ if (fehlt) return zeile(false, `fehlt: ${fehlt}`)
+ const verboten = pruefung.enthaeltNicht.find((m) => text.includes(m))
+ return verboten ? zeile(false, `verboten: ${verboten}`) : zeile(true)
+}
+
+/**
+ * Alle Pruefungen der Erwartung ("neu" | "alt") gegen basis ausfuehren; ohne: Adressen, die ausgelassen werden
+ * (z. B. die Fassung-6-Zeilen, solange sie nicht ausgeliefert werden). -> { ok, zeilen: [{ pfad, ok, grund }] }.
+ */
+export async function nachpruefen(basis, erwartet, { fetchFn = fetch, ohne = [] } = {}) {
+ const liste = PRUEFUNGEN[erwartet]
+ if (!liste) throw new Error(AUFRUF)
+ const zeilen = []
+ for (const pruefung of liste) {
+ if (ohne.includes(pruefung.pfad)) continue
+ zeilen.push(await pruefe(basis, pruefung, fetchFn))
+ }
+ return { ok: zeilen.every((z) => z.ok), zeilen }
+}
+
+const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
+if (invokedDirectly) {
+ let args
+ try {
+ args = parseArgs(process.argv.slice(2))
+ } catch (err) {
+ console.error(err.message)
+ process.exit(2)
+ }
+ nachpruefen(args.basis, args.erwartet)
+ .then(({ ok, zeilen }) => {
+ for (const z of zeilen) console.log(z.ok ? `ok ${z.pfad}` : `FEHLER ${z.pfad} ${z.grund}`)
+ process.exit(ok ? 0 : 1)
+ })
+ .catch((err) => {
+ console.error(`FEHLER ${err.message}`)
+ process.exit(1)
+ })
+}
diff --git a/test/knUmschaltNachpruefung.test.js b/test/knUmschaltNachpruefung.test.js
new file mode 100644
index 0000000..041b09d
--- /dev/null
+++ b/test/knUmschaltNachpruefung.test.js
@@ -0,0 +1,123 @@
+import { test, after } from 'node:test'
+import assert from 'node:assert/strict'
+import express from 'express'
+import { fileURLToPath } from 'node:url'
+import { startAppwriteMock } from './support/appwriteMock.js'
+import { startFakeStripe } from './support/fakeStripe.js'
+import { useTestEnv } from './support/testEnv.js'
+import { schemaKeys } from './support/schema.js'
+
+// P10 Task 4: Nachprüf-Skript – prüft die ausgelieferten Seiten per HTTP gegen „alt“ oder „neu“ (nur lesend, kein Appwrite)
+const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } })
+const st = await startFakeStripe()
+useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
+const { startTestApp } = await import('./support/testApp.js')
+const { umschaltSeitenRouter } = await import('../server/routes/umschaltSeiten.js')
+const { PRUEFUNGEN, parseArgs, nachpruefen } = await import('../scripts/umschalten-nachpruefung.mjs')
+after(() => Promise.all([aw.close(), st.close()]))
+
+const publicDir = fileURLToPath(new URL('../public/', import.meta.url))
+
+// Stellvertreter-Antworten werden aus den Markern der Tabelle gebaut (Zeichenketten aus `enthaelt`, Manifest als JSON)
+const MANIFEST = { id: '/dashboard.html', start_url: '/dashboard.html', scope: '/', background_color: '#ffffff', theme_color: '#ffffff', shortcuts: [{ name: 'Chat' }, { name: 'Rechnungen' }, { name: 'Verträge' }] }
+const antwort = (p, erwartet) =>
+ p.typ === 'json'
+ ? { status: 200, type: 'application/manifest+json', body: JSON.stringify(erwartet === 'neu' ? MANIFEST : { ...MANIFEST, background_color: '#000000', theme_color: '#000000' }) }
+ : p.typ === 'pdf'
+ ? { status: 200, type: 'application/pdf', body: '%PDF-1.7 …' }
+ : { status: 200, type: 'text/html', body: p.enthaelt.join(' ') }
+const stub = (erwartet, aendern = () => null) => async (url) => {
+ const pfad = new URL(url).pathname
+ const a = aendern(pfad) ?? antwort(PRUEFUNGEN[erwartet].find((x) => x.pfad === pfad), erwartet)
+ if (a instanceof Error) throw a
+ return new Response(a.body, { status: a.status, headers: { 'content-type': a.type } })
+}
+
+test('neu und alt: alle Zeilen grün, eine Zeile je Prüfung', async () => {
+ for (const e of ['neu', 'alt']) {
+ const r = await nachpruefen('https://stub.invalid', e, { fetchFn: stub(e) })
+ assert.deepEqual([r.ok, r.zeilen.length], [true, PRUEFUNGEN[e].length], e)
+ }
+})
+
+test('neu gegen alte Seiten ist rot und nennt die Seiten', async () => {
+ const r = await nachpruefen('https://stub.invalid', 'neu', { fetchFn: async (u) => stub('alt')(u).catch(() => new Response('', { status: 404 })) })
+ assert.equal(r.ok, false)
+ assert.ok(r.zeilen.filter((z) => !z.ok).map((z) => z.pfad).includes('/login.html'))
+})
+
+test('Status 500, Netzfehler und verbotener Marker färben genau ihre Zeile rot', async () => {
+ const r = await nachpruefen('https://stub.invalid', 'neu', {
+ fetchFn: stub('neu', (p) =>
+ p === '/offline.html'
+ ? { status: 500, type: 'text/html', body: '' }
+ : p === '/kuendigen.html'
+ ? new Error('ECONNRESET')
+ : p === '/login.html'
+ ? { status: 200, type: 'text/html', body: 'content="#ffffff" /neu/tokens.css #9d3dfe' }
+ : null
+ ),
+ })
+ assert.deepEqual(r.zeilen.filter((z) => !z.ok).map((z) => z.pfad).sort(), ['/kuendigen.html', '/login.html', '/offline.html'])
+ assert.match(r.zeilen.find((z) => z.pfad === '/offline.html').grund, /500/)
+})
+
+test('gegen die echten Dateien der Weiche: alt bei off, neu bei on (ohne die Fassung-6-Zeilen)', async () => {
+ let modus = 'off'
+ const router = express.Router()
+ router.use(umschaltSeitenRouter({ publicDir, modus: () => modus, fassung6: () => null }))
+ router.use(express.static(publicDir))
+ const app = await startTestApp({ '/': router })
+ try {
+ const OHNE = ['/abo-bedingungen.html', '/WEBklar-Abo-Vertragsunterlagen.pdf']
+ assert.deepEqual((await nachpruefen(app.url, 'alt', { ohne: OHNE })).zeilen.filter((z) => !z.ok), [])
+ modus = 'on'
+ const r = await nachpruefen(app.url, 'neu', { ohne: OHNE })
+ assert.deepEqual(r.zeilen.filter((z) => !z.ok), [])
+ assert.equal(r.zeilen.length, PRUEFUNGEN.neu.length - 2)
+ } finally {
+ await app.close()
+ }
+})
+
+test('mit der echten Fassung 6 der Weiche: alle Zeilen, alt bei pilot, neu bei on', async () => {
+ let modus = 'pilot'
+ const router = express.Router()
+ router.use(umschaltSeitenRouter({ publicDir, modus: () => modus }))
+ router.use(express.static(publicDir))
+ const app = await startTestApp({ '/': router })
+ try {
+ const alt = await nachpruefen(app.url, 'alt')
+ assert.deepEqual(alt.zeilen.filter((z) => !z.ok), [])
+ assert.equal(alt.zeilen.length, PRUEFUNGEN.alt.length)
+ modus = 'on'
+ const neu = await nachpruefen(app.url, 'neu')
+ assert.deepEqual(neu.zeilen.filter((z) => !z.ok), [])
+ assert.equal(neu.zeilen.length, PRUEFUNGEN.neu.length)
+ // und neu gegen den Stand von pilot ist rot: die Prüfung unterscheidet wirklich
+ modus = 'pilot'
+ const falsch = await nachpruefen(app.url, 'neu')
+ assert.equal(falsch.ok, false)
+ assert.ok(falsch.zeilen.filter((z) => !z.ok).length >= PRUEFUNGEN.neu.length - 1)
+ } finally {
+ await app.close()
+ }
+})
+
+test('Umleitungen werden nicht verfolgt (302 ist rot) und es werden keine Cookies gesendet', async () => {
+ const gesehen = []
+ const fetchFn = async (url, opts = {}) => {
+ gesehen.push(opts)
+ return new Response('', { status: 302, headers: { location: '/login.html' } })
+ }
+ const r = await nachpruefen('https://stub.invalid', 'neu', { fetchFn })
+ assert.equal(r.ok, false)
+ assert.ok(r.zeilen.every((z) => !z.ok && /302/.test(z.grund)))
+ assert.ok(gesehen.every((o) => o.redirect === 'manual' && !o.headers?.cookie && !o.headers?.Cookie))
+})
+
+test('parseArgs', () => {
+ assert.deepEqual(parseArgs(['https://project.webklar.com', '--erwartet=neu']), { basis: 'https://project.webklar.com', erwartet: 'neu' })
+ assert.deepEqual(parseArgs(['http://127.0.0.1:3000', '--erwartet=alt']), { basis: 'http://127.0.0.1:3000', erwartet: 'alt' })
+ for (const schlecht of [[], ['https://x.invalid'], ['ftp://x.invalid', '--erwartet=neu'], ['https://x.invalid/pfad', '--erwartet=neu'], ['https://x.invalid', '--erwartet=beide']]) assert.throws(() => parseArgs(schlecht), /Aufruf/)
+})