Files
Webklar-Kundenbereich/test/termineLimits.test.js
T
ClaudeandClaude Sonnet 5.5 2b0c05b8e9 Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz
Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit
eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64),
siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten.
Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00

170 lines
9.2 KiB
JavaScript

import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
zaehle,
zaehleAlle,
darfG08,
darfK21NichtZugeordnet,
darfAnfrage,
darfAnfrageSite,
darfBuchungsversuch,
darfHaltung,
darfWiderrufsversuch,
darfG01,
ipSchluessel,
resetTermineLimitsForTests,
} from '../server/services/termine/limits.js'
// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel.
const T0 = new Date('2026-10-05T08:00:00.000Z')
const STUNDE = 60 * 60 * 1000
const TAG = 24 * STUNDE
const MINUTE = 60 * 1000
const plus = (ms) => new Date(T0.getTime() + ms)
beforeEach(() => resetTermineLimitsForTests())
test('zaehle: erlaubt bis max, danach false; das Fenster beginnt mit dem ersten Ereignis und endet nach fensterMs', () => {
assert.deepEqual([1, 2, 3].map(() => zaehle('a', 3, STUNDE, T0)), [true, true, true])
assert.equal(zaehle('a', 3, STUNDE, plus(10 * 60000)), false)
assert.equal(zaehle('a', 3, STUNDE, plus(STUNDE - 1)), false, 'bis zur letzten Millisekunde des Fensters')
assert.equal(zaehle('b', 3, STUNDE, T0), true, 'andere Schluessel zaehlen getrennt')
// die abgelehnten Anfragen haben nichts gezaehlt: das neue Fenster (ab plus(STUNDE)) erlaubt wieder drei
assert.deepEqual([1, 2, 3, 4].map((i) => zaehle('a', 3, STUNDE, plus(STUNDE + i))), [true, true, true, false])
})
test('zaehle: max 0 erlaubt nie, ungueltige Angaben werfen TypeError', () => {
assert.equal(zaehle('a', 0, STUNDE, T0), false)
assert.throws(() => zaehle('a', 1.5, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', -1, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', 1, 0, T0), TypeError)
assert.throws(() => zaehle('', 1, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', 1, STUNDE, undefined), TypeError)
assert.throws(() => zaehle('a', 1, STUNDE, Date.now()), TypeError)
assert.throws(() => zaehleAlle([], T0), TypeError)
})
test('zaehleAlle: alle oder keine - scheitert eine Regel, zaehlt keine', () => {
const regeln = [
{ schluessel: 'adresse', max: 3, fensterMs: TAG },
{ schluessel: 'funktion', max: 1, fensterMs: STUNDE },
]
assert.equal(zaehleAlle(regeln, T0), true)
assert.equal(zaehleAlle(regeln, plus(1000)), false, 'die Funktionsgrenze ist erreicht')
// der Adress-Zaehler wurde dabei nicht verbraucht: nach der Stunde sind noch zwei Ereignisse an diese Adresse frei
const nurAdresse = [{ schluessel: 'adresse', max: 3, fensterMs: TAG }]
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true)
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true)
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), false)
})
test('darfG08: 3 je Adresse und Tag (auch ueber Funktionen), 20 je Funktion und Stunde', () => {
assert.deepEqual([1, 2, 3, 4].map(() => darfG08('f1', 'max@example.com', T0)), [true, true, true, false])
assert.equal(darfG08('f2', 'max@example.com', plus(TAG - 1)), false, 'die Adresse zaehlt fuer alle Funktionen zusammen')
assert.equal(darfG08('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
resetTermineLimitsForTests()
const erlaubt = Array.from({ length: 22 }, (_, i) => darfG08('f1', `gast${i}@example.com`, T0))
assert.equal(erlaubt.filter(Boolean).length, 20)
assert.equal(darfG08('f2', 'neu@example.com', T0), true, 'andere Funktion hat ihr eigenes Kontingent')
assert.equal(darfG08('f1', 'spaet@example.com', plus(STUNDE)), true, 'nach einer Stunde wieder')
})
test('darfK21NichtZugeordnet: 30 je Funktion und Stunde', () => {
assert.equal(Array.from({ length: 32 }, () => darfK21NichtZugeordnet('f1', T0)).filter(Boolean).length, 30)
assert.equal(darfK21NichtZugeordnet('f2', T0), true)
assert.equal(darfK21NichtZugeordnet('f1', plus(STUNDE)), true)
})
test('Obergrenze: ueber 10 000 Schluessel faellt der aelteste Zaehler weg, die neueren bleiben', () => {
assert.equal(zaehle('aeltester', 1, TAG, T0), true)
assert.equal(zaehle('aeltester', 1, TAG, T0), false)
for (let i = 0; i < 9998; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000))
assert.equal(zaehle('aeltester', 1, TAG, T0), false, 'bei 9 999 Schluesseln noch da')
assert.equal(zaehle('k9997', 1, TAG, plus(2000)), false)
zaehle('neu1', 1, TAG, plus(3000)) // 10 000
zaehle('neu2', 1, TAG, plus(3000)) // 10 001: der aelteste faellt weg
assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true, 'der aelteste Zaehler wurde verdraengt')
assert.equal(zaehle('neu2', 1, TAG, plus(4000)), false, 'der neueste bleibt')
assert.equal(zaehle('k9997', 1, TAG, plus(4000)), false, 'die juengeren bleiben')
})
test('resetTermineLimitsForTests leert alles', () => {
zaehle('a', 1, TAG, T0)
assert.equal(zaehle('a', 1, TAG, T0), false)
resetTermineLimitsForTests()
assert.equal(zaehle('a', 1, TAG, T0), true)
})
// ---------------------------------------------------------------- Aufgabe 16: IP, siteKey, G01
test('ipSchluessel: IPv4 bleibt, IPv4-in-IPv6 wird IPv4, IPv6 zaehlt je /64 (auch in anderer Schreibweise)', () => {
assert.equal(ipSchluessel('203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::ffff:203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::FFFF:203.0.113.7'), '203.0.113.7')
const a = ipSchluessel('2001:db8:1:2::1')
assert.equal(ipSchluessel('2001:0db8:0001:0002:ffff:ffff:ffff:ffff'), a, 'dieselbe /64, ausgeschrieben')
assert.equal(ipSchluessel('2001:DB8:1:2:abcd::9'), a, 'dieselbe /64, Grossbuchstaben und Kurzform')
assert.equal(ipSchluessel('2001:db8:1:2:0:0:0:0'), a)
assert.notEqual(ipSchluessel('2001:db8:1:3::1'), a, 'andere /64')
assert.notEqual(ipSchluessel('2001:db8:2:2::1'), a)
assert.equal(ipSchluessel('fe80::1%eth0'), ipSchluessel('fe80::2'), 'Zonenkennung faellt weg')
assert.equal(ipSchluessel('::1'), ipSchluessel('0:0:0:0:ffff:ffff:ffff:ffff'), 'Loopback ist die /64 ::')
assert.notEqual(ipSchluessel('203.0.113.7'), ipSchluessel('203.0.113.8'))
assert.notEqual(ipSchluessel('203.0.113.7'), a, 'IPv4 und IPv6 stossen nie zusammen')
})
test('ipSchluessel: fehlende oder unlesbare Adresse ergibt einen festen Ersatzschluessel (nie einen Fehler)', () => {
for (const wert of [undefined, null, '', ' ', 'kaputt', '1.2.3', '999.1.1.1', 42, {}]) {
assert.equal(ipSchluessel(wert), 'unbekannt', JSON.stringify(wert))
}
})
test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je siteKey und Minute', () => {
assert.equal(Array.from({ length: 125 }, () => darfAnfrage('203.0.113.7', T0)).filter(Boolean).length, 120)
assert.equal(darfAnfrage('203.0.113.8', T0), true, 'andere IP')
assert.equal(darfAnfrage('203.0.113.7', plus(MINUTE)), true, 'nach einer Minute wieder')
resetTermineLimitsForTests()
const erlaubt = Array.from({ length: 125 }, (_, i) => darfAnfrage(`2001:db8:1:2::${i + 1}`, T0))
assert.equal(erlaubt.filter(Boolean).length, 120, 'alle Adressen der /64 teilen sich 120')
assert.equal(darfAnfrage('2001:db8:1:3::1', T0), true)
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 605 }, () => darfAnfrageSite('abcdefghijklmnop', T0)).filter(Boolean).length, 600)
assert.equal(darfAnfrageSite('bcdefghijklmnopq', T0), true)
assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true)
})
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
assert.equal(darfBuchungsversuch('2001:db8::1', T0) && darfBuchungsversuch('2001:db8:0:0:1::1', T0), true, 'getrennt von der IPv4')
resetTermineLimitsForTests()
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9::${i + 1}`, T0), true)
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9:1::${i + 1}`, T0), true)
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60)
assert.equal(darfHaltung('bcdefghijklmnopq', T0), true)
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true)
})
test('darfWiderrufsversuch: 10 je IP und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfWiderrufsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfWiderrufsversuch('203.0.113.8', T0), true)
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true)
})
test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => {
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false])
assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion')
assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse')
assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false)
assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01')
})