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) })