214 lines
12 KiB
JavaScript
214 lines
12 KiB
JavaScript
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)
|
||
})
|