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') }) // ---------------------------------------------------------------- Speicher: IP-Zaehler getrennt, Abgelaufene zuerst /** Eindeutige IPv4-Adresse Nr. i (10.a.b.c). */ const ipNr = (i) => `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}` test('Speicher: 10 000 und mehr verschiedene IPs verdraengen keinen laufenden Zaehler je Adresse, Funktion oder siteKey', () => { // G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true) assert.equal(darfG01('f1', 'opfer@example.com', T0), false) for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true) assert.equal(darfG08('f1', 'opfer@example.com', T0), true) // ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten for (let i = 0; i < 10500; i += 1) darfAnfrage(ipNr(i), T0) for (let i = 0; i < 10500; i += 1) darfBuchungsversuch(ipNr(i), plus(1000)) for (let i = 0; i < 10500; i += 1) darfWiderrufsversuch(ipNr(i), plus(2000)) assert.equal(darfG01('f1', 'opfer@example.com', plus(3000)), false, 'die G01-Grenze der Adresse gilt weiter') assert.equal(darfHaltung('abcdefghijklmnop', plus(3000)), false, 'die Haltungsgrenze gilt weiter') assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true, 'G08 zaehlt weiter ab 1 (2 von 3 frei)') assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true) assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), false, 'und die dritte war die letzte') }) test('Speicher: eine volle Map raeumt zuerst Abgelaufene ab - ein laufender Zaehler bleibt, auch der aelteste', () => { // der aelteste Zaehler laeuft eine Stunde; danach fuellen Minuten-Zaehler die IP-Map for (let i = 0; i < 10; i += 1) assert.equal(darfWiderrufsversuch('203.0.113.7', T0), true) assert.equal(darfWiderrufsversuch('203.0.113.7', T0), false) for (let i = 0; i < 9999; i += 1) darfAnfrage(ipNr(i), T0) // Map voll (10 000 Schluessel) // zwei Minuten spaeter sind alle Minuten-Zaehler abgelaufen: neue Schluessel schaffen Platz, ohne den laufenden zu verdraengen for (let i = 0; i < 300; i += 1) darfAnfrage(ipNr(20000 + i), plus(2 * MINUTE)) assert.equal(darfWiderrufsversuch('203.0.113.7', plus(2 * MINUTE)), false, 'der Stunden-Zaehler ueberlebt') assert.equal(darfAnfrage(ipNr(5), plus(2 * MINUTE)), true, 'abgelaufene Zaehler zaehlen neu') // dasselbe in der Map fuer alle anderen Zaehler resetTermineLimitsForTests() assert.equal(zaehle('alt', 1, TAG, T0), true) for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, MINUTE, T0) for (let i = 0; i < 300; i += 1) zaehle(`neu${i}`, 1, MINUTE, plus(2 * MINUTE)) assert.equal(zaehle('alt', 1, TAG, plus(2 * MINUTE)), false, 'der Tages-Zaehler ueberlebt') }) test('Speicher: sind alle Zaehler noch am Laufen, faellt wie bisher der aelteste weg (Obergrenze gilt je Map)', () => { assert.equal(zaehle('aeltester', 1, TAG, T0), true) for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000)) for (let i = 0; i < 10000; i += 1) darfAnfrage(ipNr(i), plus(1000)) // die IP-Map ist eine andere assert.equal(zaehle('aeltester', 1, TAG, plus(2000)), false, 'durch die IP-Zaehler nicht verdraengt') zaehle('neu', 1, TAG, plus(3000)) // 10 001. Schluessel der Map: der aelteste faellt weg assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true) assert.equal(zaehle('neu', 1, TAG, plus(4000)), false) })