Files
Webklar-Kundenbereich/test/auftraegeTokenRoutes.test.js

214 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }
test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => {
for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) {
const res = await post(body)
assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body))
}
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null])
assert.equal(sink.sent.length, 0)
// die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400
const unbekannt = await post({}, TOKEN_NEU)
assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }])
})
test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => {
const pruefe = async (erwartet, token = TOKEN) => {
const res = await post({ bestaetigt: true }, token)
assert.deepEqual([res.status, res.body], erwartet)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
}
await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU)
await pruefe([404, { error: UNGUELTIG }], 'kurz')
seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt
await pruefe([404, { error: UNGUELTIG }], TOKEN)
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
await pruefe([404, { error: UNGUELTIG }])
seed({ status: 'ersetzt' })
const ersetzt = await post({ bestaetigt: true })
assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }])
assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0])
seed()
config.auftraege.mode = 'off'
await pruefe([404, { error: UNGUELTIG }])
})
test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => {
const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77'))
assert.equal(res.status, 200)
assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()])
assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag))
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true])
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'])
assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument')
})
test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => {
for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) {
seed({ kunde: kunde(kundenart) })
const res = await post({ bestaetigt: true, fruehbeginn: eingabe })
assert.equal(res.status, 200, `${kundenart} ${eingabe}`)
assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`)
}
})
test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => {
const erste = await post({ bestaetigt: true, fruehbeginn: true })
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000))
const zweite = await post({ bestaetigt: true, fruehbeginn: false })
assert.equal(zweite.status, 200)
assert.deepEqual(zweite.body, erste.body)
assert.equal(sink.sent.length, 2)
seed()
sink.sent.length = 0
const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm)
assert.equal(sink.sent.length, 2)
})
test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => {
seed({ status: 'abgerechnet' })
const res = await post({ bestaetigt: true })
assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet'])
assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => {
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
const log = mock.method(console, 'error', () => {})
const res = await post({ bestaetigt: true })
assert.equal(res.status, 200)
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
assert.ok(log.mock.callCount() > 0)
mock.restoreAll()
})
test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => {
const kopf = ip('203.0.113.20')
for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`)
const zuViele = await post({ bestaetigt: true }, TOKEN, kopf)
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
assert.equal((await get(TOKEN, '', kopf)).status, 200)
assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200)
})