diff --git a/server/index.js b/server/index.js index 3196ab5..b441ca2 100644 --- a/server/index.js +++ b/server/index.js @@ -66,6 +66,7 @@ import legacyAdminRedirect from './routes/legacyAdminRedirect.js' import { corsMiddleware } from './middleware/cors.js' import { kundenansichtGuard } from './middleware/kundenansichtGuard.js' import { dashboardWeiche, neuDirektWeiche, oeffentlicheSeiteWeiche } from './services/kundenansicht/dashboardWeiche.js' +import { umschaltSeitenRouter } from './routes/umschaltSeiten.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -168,6 +169,8 @@ app.get('/dashboard.html', dashboardWeiche({ publicDir })) app.get('/neu/dashboard.html', neuDirektWeiche()) // Kuendigen und Widerrufen: im Pilot die neuen Seiten aus public/neu/oeffentlich/, sobald die Dateien dort liegen app.get(['/kuendigen.html', '/widerrufen.html'], oeffentlicheSeiteWeiche({ publicDir })) +// Umschalten (P10): erst bei KUNDENANSICHT_NEU=on die neuen oeffentlichen Seiten, das Manifest und die Fassung-6-Adresse, auch ohne Sitzung +app.use(umschaltSeitenRouter({ publicDir })) app.use(express.static(publicDir)) app.get('/dashboard.html', (req, res, next) => { diff --git a/server/routes/umschaltSeiten.js b/server/routes/umschaltSeiten.js new file mode 100644 index 0000000..d6f5302 --- /dev/null +++ b/server/routes/umschaltSeiten.js @@ -0,0 +1,88 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' +import express from 'express' +import { config } from '../config.js' +import { termsDocFor } from '../services/products.js' +import { neueAnsichtModus } from '../services/kundenansicht/schalter.js' + +/** + * Umschalt-Weiche der neuen Kundenansicht (P10): erst bei KUNDENANSICHT_NEU=on liefert sie die neuen oeffentlichen Seiten, + * das Manifest und die Fassung-6-Adresse der Abo-Bedingungen, auch Besuchern ohne Sitzung. In off und pilot (auch mit '*') + * ruft jede Zeile next(); die alten Dateien kommen dann unveraendert von express.static. server/index.js haengt den Router + * hinter die Weichen von P0 (dashboardWeiche, oeffentlicheSeiteWeiche) und vor express.static ein. + */ + +/** Adresse -> Datei unter public/, die bei on an ihre Stelle tritt. */ +export const UMSCHALT_SEITEN = Object.freeze({ + '/dashboard.html': 'neu/dashboard.html', // greift nur, wo P0s Weiche nicht gegriffen hat, also ohne Sitzung + '/login.html': 'neu/oeffentlich/login.html', + '/kuendigen.html': 'neu/oeffentlich/kuendigen.html', + '/widerrufen.html': 'neu/oeffentlich/widerrufen.html', + '/offline.html': 'neu/oeffentlich/offline.html', + '/manifest.webmanifest': 'neu/manifest.webmanifest', +}) + +const ABO_SEITE = '/abo-bedingungen.html' +const ABO_PDF = '/WEBklar-Abo-Vertragsunterlagen.pdf' + +/** { url, pdfFile } der Fassung 6 aus config.abo.termsVersionNeu; ohne gesetzte Neu-Fassung null. */ +export function fassung6Aus() { + return config.abo.termsVersionNeu ? termsDocFor(config.abo.termsVersionNeu) : null +} + +/** + * Absoluter Pfad zu rel (Adresse oder Dateiname, mit oder ohne fuehrenden Schraegstrich) unter publicDir. + * Null, wenn rel kein Text ist, aus public/ herausfuehren wuerde oder der aufgeloeste Pfad nicht darunter liegt. + */ +function unterPublic(publicDir, rel) { + if (typeof rel !== 'string' || rel === '' || rel.includes('\0')) return null + const relativ = path.posix.normalize(rel.replace(/^\/+/, '')) + if (relativ === '..' || relativ.startsWith('../')) return null + const wurzel = path.resolve(publicDir) + const ziel = path.resolve(wurzel, '.' + path.posix.normalize('/' + rel)) + return ziel.startsWith(wurzel + path.sep) ? ziel : null +} + +/** + * Router der Weiche. modus() und fassung6() werden je Anfrage gelesen (Schalter wirkt ohne Neustart der Weiche); + * fassung6() liefert { url, pdfFile } oder null (dann bleibt die Datei von heute). + */ +export function umschaltSeitenRouter({ publicDir, modus = neueAnsichtModus, fassung6 = fassung6Aus }) { + // Wie express.static: Gross-/Kleinschreibung und Schraegstrich am Ende zaehlen + const router = express.Router({ caseSensitive: true, strict: true }) + + /** Zieldatei senden (no-cache); fehlt sie, laeuft die Anfrage unveraendert zu express.static weiter. */ + const sende = (ziel, res, next) => + stat(ziel).then( + (info) => { + if (!info.isFile()) return next() + res.set('Cache-Control', 'no-cache') + res.sendFile(ziel, { dotfiles: 'deny' }, (err) => { + if (!err) return + if (res.headersSent) return + res.removeHeader('Cache-Control') + next(err.code === 'ENOENT' ? undefined : err) + }) + }, + () => next() + ) + + for (const [adresse, rel] of Object.entries(UMSCHALT_SEITEN)) { + router.get(adresse, (_req, res, next) => { + if (modus() !== 'on') return next() + const ziel = unterPublic(publicDir, rel) + return ziel ? sende(ziel, res, next) : next() + }) + } + + // Stripe-Checkout und die neuen Seiten verlinken diese zwei Adressen; im Fassung-6-Weg muessen sie Fassung 6 zeigen + for (const [adresse, feld] of [[ABO_SEITE, 'url'], [ABO_PDF, 'pdfFile']]) { + router.get(adresse, (_req, res, next) => { + if (modus() !== 'on') return next() + const ziel = unterPublic(publicDir, fassung6()?.[feld]) + return ziel ? sende(ziel, res, next) : next() + }) + } + + return router +} diff --git a/test/knUmschaltSeiten.test.js b/test/knUmschaltSeiten.test.js new file mode 100644 index 0000000..c7c0619 --- /dev/null +++ b/test/knUmschaltSeiten.test.js @@ -0,0 +1,129 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +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 3: Umschalt-Weiche – erst bei KUNDENANSICHT_NEU=on liefert sie die neuen öffentlichen Seiten, das Manifest und die Fassung-6-Adresse +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, UMSCHALT_SEITEN, fassung6Aus } = await import('../server/routes/umschaltSeiten.js') + +const publicDir = fileURLToPath(new URL('../public/', import.meta.url)) +let modus = 'off' +let fassung6 = null +const router = express.Router() +router.use(umschaltSeitenRouter({ publicDir, modus: () => modus, fassung6: () => fassung6 })) +router.use(express.static(publicDir)) +const app = await startTestApp({ '/': router }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +const holen = async (pfad, basis = app.url, methode = 'GET') => { + const r = await fetch(basis + pfad, { method: methode, redirect: 'manual' }) + return { status: r.status, bytes: Buffer.from(await r.arrayBuffer()), headers: r.headers } +} +const datei = (rel) => readFileSync(path.join(publicDir, rel)) + +for (const [pfad, neu] of Object.entries(UMSCHALT_SEITEN)) { + test(`${pfad}: off und pilot liefern die Datei von heute, on die neue (no-cache)`, async () => { + for (const m of ['off', 'pilot']) { + modus = m + const r = await holen(pfad) + assert.ok(r.status === 200 && r.bytes.equals(datei(pfad.slice(1))), `${m} ${pfad}`) + } + modus = 'on' + const r = await holen(pfad) + assert.ok(r.status === 200 && r.bytes.equals(datei(neu)), pfad) + assert.equal(r.headers.get('cache-control'), 'no-cache') + }) +} + +test('Manifest bei on ist JSON', async () => { + modus = 'on' + assert.match((await holen('/manifest.webmanifest')).headers.get('content-type'), /json/) +}) + +test('Fassung 6: Seite und PDF kommen bei on aus der Neu-Fassung, sonst bleibt die Datei von heute', async () => { + fassung6 = { url: '/neu/oeffentlich/offline.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' } // Stellvertreter-Dateien + modus = 'pilot' + assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('abo-bedingungen.html'))) + modus = 'on' + assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('neu/oeffentlich/offline.html'))) + assert.ok((await holen('/WEBklar-Abo-Vertragsunterlagen.pdf')).bytes.equals(datei('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'))) + fassung6 = null + assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('abo-bedingungen.html'))) +}) + +test('Standard-Fassung 6 (fassung6Aus): bei on kommen Seite und PDF der Fassung 6 aus public/neu/rechtliches/', async () => { + const f6 = fassung6Aus() + assert.deepEqual([f6.url, f6.pdfFile], ['/neu/rechtliches/abo-bedingungen.html', 'neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf']) + const echt = express.Router() + echt.use(umschaltSeitenRouter({ publicDir, modus: () => modus })) + echt.use(express.static(publicDir)) + const a3 = await startTestApp({ '/': echt }) + try { + modus = 'pilot' + assert.ok((await holen('/abo-bedingungen.html', a3.url)).bytes.equals(datei('abo-bedingungen.html'))) + modus = 'on' + assert.ok((await holen('/abo-bedingungen.html', a3.url)).bytes.equals(datei('neu/rechtliches/abo-bedingungen.html'))) + assert.ok((await holen('/WEBklar-Abo-Vertragsunterlagen.pdf', a3.url)).bytes.equals(datei('neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf'))) + } finally { + await a3.close() + } +}) + +test('Pfadwächter: eine Adresse außerhalb von public/ wird nie geliefert', async () => { + modus = 'on' + fassung6 = { url: '/../../package.json', pdfFile: '../../server/config.js' } + for (const p of ['/abo-bedingungen.html', '/WEBklar-Abo-Vertragsunterlagen.pdf']) assert.ok((await holen(p)).bytes.equals(datei(p.slice(1))), p) +}) + +test('fehlende neue Datei: Rückfall auf die alte statt 500', async () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'kn-weiche-')) + writeFileSync(path.join(tmp, 'login.html'), 'ALT') + const a2 = express() + a2.use(umschaltSeitenRouter({ publicDir: tmp, modus: () => 'on', fassung6: () => null })) + a2.use(express.static(tmp)) + const srv = await new Promise((res) => { + const s = a2.listen(0, '127.0.0.1', () => res(s)) + }) + try { + const r = await holen('/login.html', `http://127.0.0.1:${srv.address().port}`) + assert.deepEqual([r.status, r.bytes.toString()], [200, 'ALT']) + // der Rückfall erbt kein no-cache der Weiche (die Datei von heute behält ihren eigenen Cache-Kopf) + assert.notEqual(r.headers.get('cache-control'), 'no-cache') + } finally { + srv.close() + unlinkSync(path.join(tmp, 'login.html')) + rmdirSync(tmp) + } +}) + +test('nur GET und HEAD: HEAD liefert die neue Seite ohne Inhalt, POST bleibt unberührt', async () => { + modus = 'on' + fassung6 = null + const kopf = await holen('/login.html', app.url, 'HEAD') + assert.deepEqual([kopf.status, kopf.bytes.length, kopf.headers.get('cache-control')], [200, 0, 'no-cache']) + assert.equal(Number(kopf.headers.get('content-length')), datei('neu/oeffentlich/login.html').length) + const post = await holen('/login.html', app.url, 'POST') + assert.equal(post.status, 404) +}) + +test('Groß- und Kleinschreibung: /Login.html wird nicht umgeleitet (wie express.static)', async () => { + modus = 'on' + assert.equal((await holen('/Login.html')).status, 404) +}) + +test('server/index.js hängt die Weiche hinter P0s Weichen und vor express.static ein', () => { + const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + const reihe = ["app.get('/dashboard.html', dashboardWeiche(", 'app.use(umschaltSeitenRouter(', 'app.use(express.static(publicDir))'].map((s) => index.indexOf(s)) + assert.ok(reihe[0] > -1 && reihe.every((n, i) => i === 0 || n > reihe[i - 1]), reihe.join(',')) +})