diff --git a/server/routes/auftrag.js b/server/routes/auftrag.js index 68bcadc..5d3e2f5 100644 --- a/server/routes/auftrag.js +++ b/server/routes/auftrag.js @@ -1,10 +1,104 @@ /** - * Gerüst von Strang L0: öffentliche Bestätigung einer Auftragsbestätigung per Token (ohne Anmeldung, das Token steht - * in der Adresse). Mount: /api/auftrag. Kein Sitzungs- und kein Wächter-Eintrag nötig. L5 ergänzt die Ratenbegrenzung - * im Arbeitsspeicher (keine IP-Adressen speichern) und GET /:token, GET /:token/pdf, POST /:token/bestaetigen. + * Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne + * Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag. + * Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder + * protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404). */ import { Router } from 'express' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js' +import { lesePdf } from '../services/auftraege/ablage.js' +import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js' +import { GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js' +import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js' +import { auftraegeAktiv } from '../services/auftraege/schalter.js' +import { hashToken } from '../services/auftraege/token.js' + +const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.' +const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.' + +const echteUhr = () => new Date() +let uhr = echteUhr +const jetztMs = () => uhr().getTime() +const getLimiter = createWriteLimiter({ max: 120, now: jetztMs }) +const postLimiter = createWriteLimiter({ max: 10, now: jetztMs }) +const nachIp = (req) => String(req.ip) + +/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */ +export function setzeUhrFuerTests(fn) { + uhr = typeof fn === 'function' ? fn : echteUhr +} + +/** Nur für Tests: Zähler leeren und echte Uhr. */ +export function resetAuftragRoutesForTests() { + getLimiter.reset() + postLimiter.reset() + uhr = echteUhr +} const router = Router() +router.use((_req, res, next) => { + res.set({ + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + }) + next() +}) + +/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */ +async function ladeNachToken(req, res) { + const nichtGueltig = () => { + res.status(404).json({ error: UNGUELTIG }) + return null + } + const hash = hashToken(req.params.token) + if (hash === null) return nichtGueltig() + const a = await findAuftragByTokenHash(hash) + if (!a) return nichtGueltig() + if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig() + if (a.status === 'entwurf') return nichtGueltig() + if (GESPERRT_MELDUNG[a.status]) { + res.status(410).json({ error: GESPERRT_MELDUNG[a.status] }) + return null + } + const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN + if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig() + return a +} + +/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */ +export async function sendePdf(res, auftrag) { + let buffer + try { + buffer = await lesePdf(auftrag.pdfDatei) + } catch (err) { + if (err instanceof AuftragError) throw err + console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`) + throw new AuftragError(404, 'PDF nicht gefunden.') + } + const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung' + res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` }) + res.end(buffer) +} + +router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (a) res.json({ auftrag: oeffentlicheAnsicht(a) }) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) + +router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (a) await sendePdf(res, a) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) export default router diff --git a/test/auftraegeTokenRoutes.test.js b/test/auftraegeTokenRoutes.test.js new file mode 100644 index 0000000..bda073c --- /dev/null +++ b/test/auftraegeTokenRoutes.test.js @@ -0,0 +1,114 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { hashToken } from '../server/services/auftraege/token.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-')) +process.env.AUFTRAEGE_ENABLED = 'on' +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js') +const app = await startTestApp({ '/api/auftrag': tokenRoutes }) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close()]) +}) + +const PDF = '%PDF-1.4 Test-AB' +await speicherePdf('c1', 'ab1', Buffer.from(PDF)) +const JETZT = new Date('2026-10-10T09:00:00.000Z') +const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' } +const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' } + +let sink +const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) +beforeEach(() => { + resetAuftragRoutesForTests() + setzeUhrFuerTests(() => JETZT) + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + sink = mailSink() + setMailTransportForTests(sink) + seed() +}) +const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts) +const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body }) +const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } }) + +test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => { + const res = await get() + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['auftrag']) + const a = res.body.auftrag + assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat']) + assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }]) + assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/) + for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.headers.get('x-robots-tag'), 'noindex') +}) + +test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => { + for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) { + for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token) + } + seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() }) + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) + seed({ tokenGueltigBis: JETZT.toISOString() }) + assert.equal((await get()).status, 200) +}) + +test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => { + seed({ tokenHash: hashToken(TOKEN_NEU) }) + assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200]) +}) + +test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => { + config.auftraege.mode = 'off' + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['andere@example.invalid'] + assert.equal((await get()).status, 404) + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal((await get()).status, 200) +}) + +test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => { + for (const [status, meldung] of Object.entries(GRUND)) { + seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' }) + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status) + } + seed({ status: 'entwurf' }) + assert.equal((await get()).status, 404) +}) + +test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => { + const res = await get(TOKEN, '/pdf') + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'application/pdf') + assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"') + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.body, PDF) + seed({ pdfDatei: 'c1/fehlt.pdf' }) + const fehlt = await get(TOKEN, '/pdf') + assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }]) +}) + +test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => { + for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`) + const zuViele = await get(TOKEN, '', ip('203.0.113.9')) + assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE]) + assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200) +})