From 4d9656524048967f21b3834e5eb4c19db5e637a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:03:39 +0000 Subject: [PATCH] Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06) Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/limits.js | 30 ++++++- server/services/termine/mails.js | 28 ++++++- test/publicTermine.test.js | 9 ++- test/termineAblaufBetrieb.test.js | 129 +++++++++++++++++++++++++++++- test/termineLimits.test.js | 79 ++++++++++++++++-- 5 files changed, 257 insertions(+), 18 deletions(-) diff --git a/server/services/termine/limits.js b/server/services/termine/limits.js index a2f088e..bdc7cb8 100644 --- a/server/services/termine/limits.js +++ b/server/services/termine/limits.js @@ -13,8 +13,11 @@ import { pruefeNow, pruefeText } from './util.js' * Buchung, ein Treffer bekommt seine Mails immer. * Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe * je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01 - * (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist + * (Bestaetigungsmails je Adresse). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist * ein ganzes /64, die Adresse darin wechselt beliebig. + * Empfaenger-Kappe: eine fremde Adresse bekommt ueber alle Betriebe (Funktionen) zusammen hoechstens 3 Mails je Stunde und + * 10 je Tag, die ein Gast oder ein Betrieb ausloest (darfG01 beim Gast-Formular, darfEmpfaenger fuer den Freitext des + * Betriebs in G04/G06, mails.js anGast); beide teilen dieselben Zaehler. * * Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben * beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion @@ -46,6 +49,9 @@ const HALTUNGEN_JE_SITE = 25 const WIDERRUFE_JE_IP = 10 /** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */ const G01_JE_ADRESSE = 5 +/** Empfaenger-Kappe ueber alle Funktionen: je (normalisierter) Adresse und Stunde, je Adresse und Tag. */ +const EMPFAENGER_JE_STUNDE = 3 +const EMPFAENGER_JE_TAG = 10 /** * Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. @@ -195,9 +201,27 @@ export function darfWiderrufsversuch(ip, now) { return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now) } -/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */ +/** Die zwei Regeln der Empfaenger-Kappe (Stunde und Tag) fuer eine normalisierte Adresse, ueber alle Funktionen. */ +const empfaengerRegeln = (adresse) => [ + { schluessel: `empfaenger-h:${adresse}`, max: EMPFAENGER_JE_STUNDE, fensterMs: STUNDE_MS }, + { schluessel: `empfaenger-d:${adresse}`, max: EMPFAENGER_JE_TAG, fensterMs: TAG_MS }, +] + +/** + * Darf an diese (normalisierte: klein, getrimmt) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag + * (gehalten wie "zu viele") und die Empfaenger-Kappe ueber alle Funktionen (3 je Stunde, 10 je Tag). Alles oder nichts: + * eine abgelehnte Anfrage erhoeht keinen Zaehler. + */ export function darfG01(featureId, adresse, now) { - return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now) + return zaehleAlle([{ schluessel: `g01:${featureId}:${adresse}`, max: G01_JE_ADRESSE, fensterMs: TAG_MS }, ...empfaengerRegeln(adresse)], now) +} + +/** + * Darf an diese (normalisierte) Adresse eine Mail mit Freitext des Betriebs gehen (G04, G06)? Dieselben Zaehler wie + * darfG01 (3 je Stunde, 10 je Tag ueber alle Funktionen), aber ohne die Regel je Funktion. + */ +export function darfEmpfaenger(adresse, now) { + return zaehleAlle(empfaengerRegeln(adresse), now) } /** Leert alle Zaehler (Tests). */ diff --git a/server/services/termine/mails.js b/server/services/termine/mails.js index b09d939..f14af05 100644 --- a/server/services/termine/mails.js +++ b/server/services/termine/mails.js @@ -3,6 +3,7 @@ import { getDocument } from '../appwriteAdmin.js' import { preisHinweis, preisText, zahlungText } from './einstellungen.js' import { icsTermin } from './ics.js' import { websiteUrl } from './kontext.js' +import { darfEmpfaenger } from './limits.js' import { einreihen } from './outbox.js' import { widerrufsbelehrung } from './rechtstexte.js' import { buchungsNummer, verwaltungsToken } from './token.js' @@ -21,6 +22,8 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js' * - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer. * - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse); * in keinem Betreff steht ein Gastname. + * - Freitext des Betriebs (G04 nachricht, G06 grund und nachricht bei Absage durch den Betrieb) geht nur bis zur + * Empfaenger-Kappe (limits.js darfEmpfaenger) an eine Adresse; danach geht die Mail ohne diesen Text raus (anGast). * - Verwaltungslink `${publicBaseUrl}/termin.html#m=` (G02, G03, G05, G07), Bestaetigungslink `#v=` * (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`. * @@ -52,6 +55,8 @@ const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08'] const BETRIEBS_ARTEN = ['K20', 'K21'] const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' } const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +/** Felder mit Freitext des Betriebs je Gastmail (G06 nur bei von === 'betrieb'). */ +const FREITEXT_FELDER = { G04: ['nachricht'], G06: ['grund', 'nachricht'] } function einfrieren(wert) { if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) { @@ -458,10 +463,26 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) { // ---------------------------------------------------------------- Einreihen +/** + * Zaehlt eine Mail mit Freitext des Betriebs gegen die Empfaenger-Kappe der Adresse (limits.js darfEmpfaenger) und gibt + * das extra zurueck, mit dem die Mail rausgeht: unveraendert, solange die Kappe nicht erreicht ist oder kein Freitext + * dabei ist (dann zaehlt nichts), sonst ohne die Freitextfelder (Log-Warnung ohne Adresse). G05, K20/K21 und der Rest + * sind nicht betroffen, ebenso G06 des Gastes oder des Systems. + */ +function begrenzeFreitext(art, extra, empfaenger, now) { + const felder = FREITEXT_FELDER[art] + if (!felder || (art === 'G06' && extra.von !== 'betrieb') || !felder.some((feld) => hatText(extra[feld]))) return extra + const adresse = hatText(empfaenger) ? empfaenger.trim().toLowerCase() : '' + if (adresse === '' || darfEmpfaenger(adresse, now)) return extra + console.warn(`[termine-mails] ${art}: Empfänger-Kappe erreicht, die Mail geht ohne Freitext raus`) + return { ...extra, ...Object.fromEntries(felder.map((feld) => [feld, undefined])) } +} + /** * Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist * extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils - * ausser mit extra.ohneIcs. + * ausser mit extra.ohneIcs. Freitext des Betriebs in G04/G06 geht nur bis zur Empfaenger-Kappe der Adresse mit (siehe + * begrenzeFreitext). * @param {string} art 'G01' ... 'G08' * @param {object} ctx * @param {object|null} buchung @@ -472,7 +493,8 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) { export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) { if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`) pruefeNow(now, 'anGast') - extra = extra ?? {} + const an = hatText(extra?.an) ? extra.an : buchung?.gastEmail + extra = begrenzeFreitext(art, extra ?? {}, an, now) const { values, blocks } = werteFuer(art, ctx, buchung, extra) const b = ctx.einstellung.betrieb const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now) @@ -481,7 +503,7 @@ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) { featureId: ctx.feature?.$id, bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG, art, - an: hatText(extra.an) ? extra.an : buchung?.gastEmail, + an, fromName: b.name, replyTo: b.email, template: art, diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js index 66970eb..3c930b1 100644 --- a/test/publicTermine.test.js +++ b/test/publicTermine.test.js @@ -566,7 +566,10 @@ test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => { await speichere() - for (const zeit of ['10:00', '11:00', '12:00']) assert.equal((await buchen({ zeit })).status, 201) + // drei bestaetigte Termine belegen die drei offenen Plaetze; die Kappe der Adresse (3 Mails je Stunde) ist damit ausgeschoepft, + // darum kommt die vierte Anfrage erst eine Stunde spaeter bis zu "zu viele" (in der Stunde selbst bliebe sie still, siehe oben) + for (const zeit of ['10:00', '11:00', '12:00']) await gebucht({ zeit }) + setzeUhrFuerTests(() => plus(JETZT, 61 * 60000)) await warteAufVersand() const vorher = sink.sent.length const vierte = await buchen({ zeit: '13:00' }) @@ -627,7 +630,7 @@ test('je siteKey 25 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buc assert.equal(buchungen().length, 1) }) -test('je Adresse und Funktion 5 Bestaetigungsmails pro Tag: die sechste Anfrage bekommt dieselbe Antwort, aber keine Mail', async () => { +test('je Adresse 3 Bestaetigungsmails pro Stunde: die vierte bis sechste Anfrage bekommt dieselbe Antwort, aber keine Haltung und keine Mail', async () => { await speichere() const zeiten = ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00'] const antworten = [] @@ -637,7 +640,7 @@ test('je Adresse und Funktion 5 Bestaetigungsmails pro Tag: die sechste Anfrage assert.ok(antworten.every((a) => JSON.stringify(a.body) === JSON.stringify(antworten[0].body)), 'alle Antworten gleich') assert.deepEqual(antworten[0].body, { ok: true, email: 'v***@example.com', minuten: 30 }) assert.equal(buchungen().length, 3, 'nur drei offene Termine') - assert.equal(mailsAn('viel@example.com').length, 5, '3 Bestaetigungen + 2 "zu viele", die sechste Anfrage bekommt nichts') + assert.equal(mailsAn('viel@example.com').length, 3, 'drei Bestaetigungen; die vierte bis sechste Anfrage bekommt nichts (auch keine "zu viele")') // andere Adressen zaehlen getrennt; Fehler der Zeit kommen vor der Mailgrenze (kein Orakel, ob die Adresse an der Grenze ist) const andere = await buchen({ zeit: '16:00', email: 'anna@example.com' }) diff --git a/test/termineAblaufBetrieb.test.js b/test/termineAblaufBetrieb.test.js index ae93de4..77cb780 100644 --- a/test/termineAblaufBetrieb.test.js +++ b/test/termineAblaufBetrieb.test.js @@ -31,7 +31,8 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js') const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js') -const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { resetTermineLimitsForTests, darfEmpfaenger } = await import('../server/services/termine/limits.js') +const { anGast } = await import('../server/services/termine/mails.js') const { halte, vorschau, bestaetige, kannWiderrufen, verschiebeGast } = await import('../server/services/termine/ablauf.js') const { aktion, manuell, sperre, entsperre } = await import('../server/services/termine/ablaufBetrieb.js') after(() => setMailTransportForTests(null)) @@ -395,6 +396,132 @@ test('absagen: eine Anfrage (nie eine Einladung erhalten) bekommt G06 ohne Kalen assert.equal(err.code, 'nicht_moeglich') }) +// ---------------------------------------------------------------- Empfaenger-Kappe fuer Freitext (G04, G06) + +const GAST = 'max@example.com' +/** Alle Warnungen und Fehler im Log als ein Text (die Adresse des Gastes darf dort nie stehen). */ +const logText = () => JSON.stringify([...log.warn.mock.calls, ...log.error.mock.calls].map((c) => c.arguments.map((a) => String(a?.message ?? a)))) +/** Verbraucht n Platz(e) der Empfaenger-Kappe einer Adresse (wie n Mails mit Freitext oder G01 davor). */ +const fuelleKappe = (adresse, n, now = JETZT) => { + for (let i = 0; i < n; i += 1) assert.equal(darfEmpfaenger(adresse, now), true) +} +/** Reiht eine Gastmail direkt ein und liefert die versandte Mail. */ +async function sende(art, ctx, buchungsDoc, extra, now = JETZT) { + const vorher = sink.sent.length + await anGast(art, ctx, buchungsDoc, extra, now) + await warteAufVersand() + assert.equal(sink.sent.length, vorher + 1) + return sink.sent[vorher] +} + +test('ablehnen: ueber der Kappe (3 je Adresse und Stunde) geht G04 ohne die Nachricht des Betriebs raus, darunter mit; die Warnung im Log nennt keine Adresse', async () => { + const ctx = await speichere() + const a = await gebucht(ctx, { leistung: S_ANFRAGE }) + const b = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00' }) + fuelleKappe(GAST, 2) // zwei Mails mit Freitext sind schon raus: die dritte darf noch + const vorher = sink.sent.length + + await aktion(ctx, a.id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, JETZT) + await warteAufVersand() + assert.match(sink.sent[vorher].text, /Am Montag sind wir im Urlaub\./, 'die dritte Mail der Stunde enthaelt den Text') + assert.doesNotMatch(logText(), /Kappe/) + + await aktion(ctx, b.id, { aktion: 'ablehnen', nachricht: 'Bitte ruft uns einfach an.' }, JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, vorher + 2, 'die Mail geht trotzdem raus') + const mail = sink.sent[vorher + 1] + assert.equal(mail.to[0].address, GAST) + assert.equal(mail.subject, 'Ihre Terminanfrage bei Salon Müller – leider abgelehnt') + assert.match(mail.text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/, 'der Hinweis auf die Ablehnung bleibt') + assert.doesNotMatch(mail.text, /Bitte ruft uns einfach an/, 'der Freitext faellt weg') + assert.doesNotMatch(mail.text, /Nachricht von/i) + assert.doesNotMatch(JSON.stringify(eintraege()), /Bitte ruft uns einfach an/, 'er steht auch nicht im Postausgang') + assert.equal(buchung(b.id).status, 'abgelehnt') + + assert.match(logText(), /G04: Empfänger-Kappe erreicht/) + assert.doesNotMatch(logText(), /max@example\.com|example\.com/, 'das Log nennt keine Adresse') +}) + +test('absagen: ueber der Kappe geht G06 des Betriebs ohne Grund und Nachricht raus (Kalenderabsage und Hinweis bleiben); das Log nennt keine Adresse', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + fuelleKappe(GAST, 3) + const vorher = sink.sent.length + + assert.deepEqual( + await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, JETZT), + { status: 'abgesagt' }, + ) + await warteAufVersand() + + assert.equal(sink.sent.length, vorher + 1) + const g06 = sink.sent[vorher] + assert.equal(g06.to[0].address, GAST) + assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') + assert.match(g06.text, /Salon Müller/) + assert.doesNotMatch(g06.text, /Krankheit/) + assert.doesNotMatch(g06.text, /Gute Besserung/) + assert.equal(g06.icalEvent.method, 'CANCEL', 'die Kalenderabsage bleibt') + assert.doesNotMatch(JSON.stringify(eintraege()), /Krankheit|Gute Besserung/) + assert.equal(buchung(id).absageGrund, 'Krankheit', 'der Betrieb behaelt seinen Grund in der Buchung') + assert.match(logText(), /G06: Empfänger-Kappe erreicht/) + assert.doesNotMatch(logText(), /example\.com/) +}) + +test('anGast: Freitext in G04/G06 des Betriebs geht bis zur dritten Mail je Adresse und Stunde mit, danach ohne; nach 61 Minuten wieder mit', async () => { + const ctx = await speichere() + const { buchung: b } = await gebucht(ctx) + const g04 = (text, now) => sende('G04', ctx, b, { nachricht: text }, now) + + assert.match((await g04('Eins', JETZT)).text, /Eins/) + assert.match((await sende('G06', ctx, b, { von: 'betrieb', grund: 'Zwei', ohneIcs: true })).text, /Zwei/, 'G06 des Betriebs teilt sich die Zaehler mit G04') + assert.match((await g04('Drei', JETZT)).text, /Drei/) + const vier = await g04('Vier', JETZT) + assert.doesNotMatch(vier.text, /Vier/) + const fuenf = await sende('G06', ctx, b, { von: 'betrieb', grund: 'Fuenf', nachricht: 'Sechs', ohneIcs: true }) + assert.doesNotMatch(fuenf.text, /Fuenf|Sechs/, 'weder Grund noch Nachricht') + assert.match(fuenf.text, /Salon Müller/) + + assert.match((await g04('Sieben', plus(JETZT, 61 * MIN))).text, /Sieben/, 'nach 61 Minuten wieder') + assert.doesNotMatch(logText(), /example\.com/) +}) + +test('anGast: ohne Freitext zaehlt nichts - G04/G06 ohne Text, G06 des Gastes oder des Systems, G05 und die uebrigen Arten', async () => { + const ctx = await speichere() + const { buchung: b } = await gebucht(ctx) + const vorher = sink.sent.length + + await sende('G04', ctx, b, {}) + await sende('G04', ctx, b, { nachricht: ' ' }) + await sende('G06', ctx, b, { von: 'betrieb', ohneIcs: true }) + await sende('G06', ctx, b, { von: 'betrieb', grund: ' ', nachricht: '', ohneIcs: true }) + const gast = await sende('G06', ctx, b, { von: 'gast', grund: 'Gastgrund', nachricht: 'Gastnachricht', ohneIcs: true }) + await sende('G06', ctx, b, { von: 'system', ohneIcs: true }) + await sende('G05', ctx, b, {}) + await sende('G07', ctx, b, { altStart: b.startsAt, ohneIcs: true }) + assert.equal(sink.sent.length, vorher + 8) + assert.match(gast.text, /Sie selbst/) + assert.doesNotMatch(gast.text, /Gastgrund|Gastnachricht/, 'bei einer Absage des Gastes gibt es weder Grund noch Nachricht') + assert.equal(log.warn.mock.callCount(), 0) + + // die Kappe ist unberuehrt: alle drei Plaetze der Stunde sind frei + assert.deepEqual([1, 2, 3, 4].map(() => darfEmpfaenger(GAST, JETZT)), [true, true, true, false]) +}) + +test('anGast: die Kappe gilt je Adresse (extra.an zaehlt klein geschrieben); eine andere Adresse ist nicht betroffen', async () => { + const ctx = await speichere() + const { buchung: b } = await gebucht(ctx) + fuelleKappe(GAST, 3) + fuelleKappe('weitere@example.com', 2) + + const voll = await sende('G04', ctx, b, { nachricht: 'Nur fuer Max' }) + assert.doesNotMatch(voll.text, /Nur fuer Max/) + const umgeleitet = await sende('G04', ctx, b, { nachricht: 'An eine andere Adresse', an: 'Weitere@Example.com' }) + assert.match(umgeleitet.text, /An eine andere Adresse/, 'die Adresse in extra.an zaehlt (klein geschrieben): dort war noch ein Platz') + const nochmal = await sende('G04', ctx, b, { nachricht: 'Und noch eine', an: 'weitere@example.com' }) + assert.doesNotMatch(nochmal.text, /Und noch eine/, 'die Zaehler sind mit der Schreibweise der Adresse nicht zu umgehen') +}) + // ---------------------------------------------------------------- verschieben test('verschieben: wie beim Gast (10:00 -> 10:30 ueberlappend), Sequenz + 1, neue Zellen, G07 mit Einladung, kein K21', async () => { diff --git a/test/termineLimits.test.js b/test/termineLimits.test.js index a7b2f33..14f4608 100644 --- a/test/termineLimits.test.js +++ b/test/termineLimits.test.js @@ -11,6 +11,7 @@ import { darfHaltung, darfWiderrufsversuch, darfG01, + darfEmpfaenger, ipSchluessel, resetTermineLimitsForTests, } from '../server/services/termine/limits.js' @@ -159,13 +160,75 @@ test('darfWiderrufsversuch: 10 je IP und Stunde', () => { 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') +test('darfG01: 3 je Adresse und Stunde ueber alle Funktionen; die Zaehler der Arten stoeren sich nicht', () => { + assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, false, false, false, false]) + assert.equal(darfG01('f2', 'max@example.com', T0), false, 'die Kappe gilt ueber Betriebe (Funktionen) hinweg') 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') + assert.equal(darfG01('f1', 'max@example.com', plus(STUNDE - 1)), false, 'bis zur letzten Millisekunde der Stunde') + assert.deepEqual( + Array.from({ length: 4 }, () => darfG01('f2', 'max@example.com', plus(61 * MINUTE))), + [true, true, true, false], + 'nach 61 Minuten wieder drei', + ) + assert.deepEqual( + Array.from({ length: 4 }, () => darfG08('f1', 'max@example.com', T0)), + [true, true, true, false], + 'G08 zaehlt getrennt von G01 und hat ihre eigene Grenze von drei je Tag', + ) +}) + +test('darfG01: je Adresse hoechstens 10 Empfaenger-Mails pro Tag (3 + 3 + 3 + 1 ueber vier Stunden), die 11. nicht', () => { + // je Funktion gelten weiter hoechstens 5 G01 pro Tag; darum verteilen sich die zehn auf vier Funktionen + const adresse = 'tag@example.com' + const stunde = (n, funktion, anzahl) => Array.from({ length: anzahl }, () => darfG01(funktion, adresse, plus(n * 61 * MINUTE))) + assert.deepEqual(stunde(0, 'f1', 3), [true, true, true]) + assert.deepEqual(stunde(1, 'f2', 3), [true, true, true]) + assert.deepEqual(stunde(2, 'f3', 3), [true, true, true]) + assert.deepEqual(stunde(3, 'f4', 2), [true, false], 'die zehnte geht, die elfte nicht (der Stundenzaehler hat dann erst eine)') + assert.equal(darfG01('f5', adresse, plus(5 * 61 * MINUTE)), false, 'auch in einer neuen Stunde und mit unberuehrter Funktion') + assert.equal(darfG01('f5', 'andere@example.com', plus(5 * 61 * MINUTE)), true, 'andere Adresse') + assert.equal(darfG01('f5', adresse, plus(TAG)), true, 'am naechsten Tag wieder') +}) + +test('darfG01: je Funktion hoechstens 5 pro Adresse und Tag (die Regel der Funktion bleibt neben der Kappe ueber Betriebe)', () => { + const adresse = 'funktion@example.com' + assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, T0)), [true, true, true]) + assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, plus(61 * MINUTE))), [true, true, false], 'die sechste an dieselbe Funktion nicht') + assert.equal(darfG01('f2', adresse, plus(61 * MINUTE)), true, 'eine andere Funktion schon (noch ein Platz in der Stunde)') + assert.equal(darfG01('f1', adresse, plus(TAG)), true, 'am naechsten Tag wieder') +}) + +test('darfG01: alles oder nichts - eine abgelehnte Anfrage erhoeht keinen Zaehler, auch nicht den der Funktion', () => { + const adresse = 'nichts@example.com' + assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, T0)), [true, true, true]) + // von der Stundenkappe abgelehnt: der Zaehler der Funktion (3 von 5) bleibt bei 3 + for (let i = 0; i < 6; i += 1) assert.equal(darfG01('f1', adresse, T0), false) + assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, plus(61 * MINUTE))), [true, true, false], 'danach noch genau 2 von 5 frei') + // von der Funktionsregel abgelehnt: Stunden- und Tageszaehler (2 und 5) bleiben unveraendert + for (let i = 0; i < 4; i += 1) assert.equal(darfG01('f1', adresse, plus(62 * MINUTE)), false) + assert.deepEqual(Array.from({ length: 2 }, () => darfG01('f2', adresse, plus(62 * MINUTE))), [true, false], 'in der Stunde ist genau 1 von 3 frei') + // bisher 6 am Tag gezaehlt (3 + 2 + 1): drei weitere und dann eine ergeben genau die zehn des Tages + assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f3', adresse, plus(123 * MINUTE))), [true, true, true]) + assert.deepEqual(Array.from({ length: 2 }, () => darfG01('f4', adresse, plus(184 * MINUTE))), [true, false], 'die zehnte war die letzte des Tages') +}) + +test('darfEmpfaenger: teilt Stunden- und Tageszaehler mit darfG01, ohne die Regel der Funktion', () => { + const adresse = 'frei@example.com' + assert.deepEqual( + [darfEmpfaenger(adresse, T0), darfEmpfaenger(adresse, T0), darfG01('f1', adresse, T0), darfG01('f1', adresse, T0)], + [true, true, true, false], + 'zwei Freitext-Mails + zwei G01: die vierte Mail an dieselbe Adresse in der Stunde nicht', + ) + assert.equal(darfEmpfaenger(adresse, T0), false) + assert.equal(darfEmpfaenger('anna@example.com', T0), true, 'andere Adresse') + assert.equal(darfEmpfaenger(adresse, plus(61 * MINUTE)), true, 'nach 61 Minuten wieder') + + // die G01-Regel der Funktion (5 je Tag) zaehlt nur bei darfG01: fuenf G01 sind verbraucht, ein Freitext geht trotzdem + const andere = 'regel@example.com' + for (let i = 0; i < 3; i += 1) assert.equal(darfG01('f1', andere, plus(i * 61 * MINUTE)), true) + for (let i = 0; i < 2; i += 1) assert.equal(darfG01('f1', andere, plus(3 * 61 * MINUTE)), true) + assert.equal(darfG01('f1', andere, plus(3 * 61 * MINUTE)), false, 'sechste G01 an dieselbe Funktion') + assert.equal(darfEmpfaenger(andere, plus(3 * 61 * MINUTE)), true, 'der Freitext kennt die Regel der Funktion nicht') }) // ---------------------------------------------------------------- Speicher: IP-Zaehler getrennt, Abgelaufene zuerst @@ -174,8 +237,8 @@ test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren 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) + // G01 einer Adresse ausgeschoepft (3 je Stunde), eine Haltungsgrenze angebrochen + for (let i = 0; i < 3; 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 < 25; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true) assert.equal(darfG08('f1', 'opfer@example.com', T0), true)