diff --git a/server/routes/auftrag.js b/server/routes/auftrag.js index 5d3e2f5..033fa39 100644 --- a/server/routes/auftrag.js +++ b/server/routes/auftrag.js @@ -9,12 +9,13 @@ 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 { bestaetigeAuftrag, 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 HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.' const echteUhr = () => new Date() @@ -101,4 +102,17 @@ router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, r } }) +router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (!a) return + const body = req.body && typeof req.body === 'object' ? req.body : {} + if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN }) + const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() }) + res.json({ auftrag: oeffentlicheAnsicht(neu) }) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) + export default router diff --git a/test/auftraegeTokenRoutes.test.js b/test/auftraegeTokenRoutes.test.js index bda073c..2ea5c8c 100644 --- a/test/auftraegeTokenRoutes.test.js +++ b/test/auftraegeTokenRoutes.test.js @@ -112,3 +112,102 @@ test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine ande 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) +})