Terminplaner: Zaehler je IP getrennt und Abgelaufene zuerst, Notantwort beim Schreiben, Pruefung aller Antworten auf interne IDs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 2b0c05b8e9
commit ae1886c030
4 files changed
+226 -31

No files matched your search

+71 -3
View File
@@ -127,9 +127,38 @@ let log
let clientIp = 0
let ip
/** Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test. */
function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) {
return send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
// Nichts Internes in irgendeiner Antwort: weder Felder noch Werte mit IDs des Portals oder Appwrite-Metadaten.
// Erlaubt sind die Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen); Buchungs-IDs sind UUIDs.
const VERBOTENE_FELDER = new Set([
'customerId', 'projectId', 'featureId', 'bookingId', '$id', '$collectionId', '$databaseId', '$createdAt', '$updatedAt', '$permissions',
'verifyHash', 'manageHash', 'feedToken',
])
const INTERNE_IDS = [KUNDE, PROJEKT, FEATURE]
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i
/** Wirft, wenn irgendwo im (JSON-)Wert ein verbotenes Feld, eine interne ID oder eine Buchungs-UUID steht. */
function pruefeOhneInternes(wert, pfad = '$', { uuid = true } = {}) {
if (typeof wert === 'string') {
for (const id of INTERNE_IDS) assert.ok(!wert.includes(id), `${pfad}: interne ID ${id} in der Antwort`)
if (uuid) assert.ok(!UUID_RE.test(wert), `${pfad}: UUID (Buchungs-ID?) in der Antwort`)
} else if (Array.isArray(wert)) {
wert.forEach((w, i) => pruefeOhneInternes(w, `${pfad}[${i}]`, { uuid }))
} else if (wert !== null && typeof wert === 'object') {
for (const [feld, w] of Object.entries(wert)) {
assert.ok(!VERBOTENE_FELDER.has(feld), `${pfad}.${feld}: internes Feld in der Antwort`)
pruefeOhneInternes(w, `${pfad}.${feld}`, { uuid })
}
}
}
/**
* Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test.
* JEDE Antwort (Erfolg wie Fehler) wird auf interne IDs geprueft; der Kalender-Text (UID = Buchungs-ID ist dort Pflicht) nur auf Portal-IDs.
*/
async function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) {
const res = await send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
pruefeOhneInternes(res.body, `${method} ${pfad}`, { uuid: typeof res.body !== 'string' })
return res
}
const buchen = (over, opts) => api('POST', `/${SITE_KEY}/buchungen`, { body: buchungBody(over), ...opts })
const tokenAnruf = (aktion, body, opts = {}) => api('POST', `/termin/${aktion}`, { body, origin: O_PORTAL, ...opts })
@@ -999,3 +1028,42 @@ test('Antworten tragen kein Set-Cookie und fuer Browser nosniff', async () => {
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
}
})
test('der Pruefer fuer interne IDs hat Zaehne: Felder, Werte, verschachtelt, UUIDs - die Buchungsnummer ist erlaubt', () => {
assert.doesNotThrow(() => pruefeOhneInternes({ ok: true, termin: { nummer: 'AB12CD34', leistung: 'Damenschnitt' }, ressourcen: [{ id: R1 }] }))
for (const schlecht of [
{ featureId: 'x' }, { a: { b: [{ customerId: 'x' }] } }, { $id: 'x' }, { $collectionId: 'termineBookings' }, { text: `Funktion ${FEATURE}` },
{ liste: [KUNDE] }, { id: '0b2a3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d' }, { projectId: PROJEKT },
]) {
assert.throws(() => pruefeOhneInternes(schlecht), assert.AssertionError, JSON.stringify(schlecht))
}
})
test('schreibt die Antwort selbst nicht (Serialisierung wirft): 503 { fehler } statt haengender Anfrage und unbehandelter Ablehnung', async () => {
await speichere()
const unbehandelt = []
const merke = (grund) => unbehandelt.push(grund)
process.on('unhandledRejection', merke)
const original = Buffer.byteLength
const ersatz = mock.method(Buffer, 'byteLength', function byteLength(text, ...rest) {
if (typeof text === 'string' && text.includes('"formToken"')) throw new Error('Nachbau kaputt: geheimer Rohtext')
return original.call(this, text, ...rest)
})
let res
try {
res = await Promise.race([
api('GET', `/${SITE_KEY}/config`),
new Promise((_, ab) => setTimeout(() => ab(new Error('Anfrage haengt: keine Antwort')), 3000).unref()),
])
await new Promise((resolve) => setImmediate(resolve)) // eine unbehandelte Ablehnung meldet sich erst danach
} finally {
ersatz.mock.restore()
process.off('unhandledRejection', merke)
}
assert.deepEqual([res.status, res.body], [503, NICHT_ERREICHBAR])
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.deepEqual(unbehandelt, [])
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
assert.ok(log.error.mock.calls.some((c) => String(c.arguments[0]).includes('Antwort nicht geschrieben')), 'steht im Log')
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'danach geht es normal weiter')
})
+52
View File
@@ -167,3 +167,55 @@ test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren
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)
})