From 2542679797487d8e208e99dac61bfdd4bd914d07 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 10:30:15 +0100 Subject: [PATCH] test(auth): add throttle and SMTP-down spike projects, start-throttle-gotrue.sh Refs #38 Co-Authored-By: Claude Sonnet 5.5 --- e2e/tests/spike/smtp-down.spec.ts | 78 ++++++++++++++++ e2e/tests/throttle/krit07-drosselung.spec.ts | 94 ++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 e2e/tests/spike/smtp-down.spec.ts create mode 100644 e2e/tests/throttle/krit07-drosselung.spec.ts diff --git a/e2e/tests/spike/smtp-down.spec.ts b/e2e/tests/spike/smtp-down.spec.ts new file mode 100644 index 0000000..45b0332 --- /dev/null +++ b/e2e/tests/spike/smtp-down.spec.ts @@ -0,0 +1,78 @@ +/** + * SPIKE (Issue #37), NICHT blockierend: Verhalten von /recover, /signup und /resend, wenn der + * SMTP-Server nicht erreichbar ist, fuer bekannte vs. unbekannte Adressen. + * + * Laeuft gegen einen dritten GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt + * (`e2e/support/start-throttle-gotrue.sh start smtp-down`, SPIKE_GOTRUE_URL). Mailpit bleibt fuer + * die anderen Projekte unberuehrt. + * + * Die Tests pruefen NICHTS ausser dem Ablauf; sie erfassen Status, Body, Dauer und den Zustand in + * der DB je Fall und schreiben ihn in die Konsole und als Anhang `smtp-down-.json`. Eine + * Auswertung steht in docs/auth-konfiguration.md (Abschnitt „SMTP-Ausfall (Spike #37)“). + */ +import { test } from '@playwright/test'; +import { env } from '../../support/auth-config'; +import { userByEmail } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue'; +import { countMails } from '../../support/mailpit'; + +const base = env.spikeGotrueUrl; +test.skip(!base, 'SPIKE_GOTRUE_URL nicht gesetzt (start-throttle-gotrue.sh start smtp-down)'); + +const spike = () => new GoTrueClient({ baseUrl: base!, apikey: null }); +const cli = new GoTrueClient(); + +async function record(info: typeof test.info extends () => infer R ? R : never, name: string, data: unknown) { + const json = JSON.stringify(data, null, 2); + console.log(`[SPIKE smtp-down] ${name}: ${json}`); + await info.attach(`smtp-down-${name}.json`, { body: json, contentType: 'application/json' }); +} + +const summarize = (r: { status: number; body: any; ms: number }) => ({ + status: r.status, + error_code: r.body?.error_code ?? null, + msg: r.body?.msg ?? r.body?.message ?? null, + hasUserId: Boolean(r.body?.id), + ms: r.ms, +}); + +test.describe('Spike #37: SMTP nicht erreichbar', () => { + test('recover: bekannte (bestaetigte) vs. unbekannte Adresse', async () => { + const known = randomEmail(); + await cli.createConfirmedUser(known, randomPassword()); + const unknown = randomEmail('unknown'); + const a = await spike().recover(known); + const b = await spike().recover(unknown); + await record(test.info(), 'recover', { known: summarize(a), unknown: summarize(b) }); + }); + + test('signup: neue vs. bestaetigte vs. unbestaetigte Adresse', async () => { + const confirmed = randomEmail(); + await cli.createConfirmedUser(confirmed, randomPassword()); + const unconfirmed = randomEmail(); + await cli.signUp(unconfirmed, randomPassword()); + const fresh = randomEmail(); + + const rFresh = await spike().signUp(fresh, randomPassword()); + const rConfirmed = await spike().signUp(confirmed, randomPassword()); + // AUTH-CFG-08: sonst antwortet GoTrue mit 429 statt dem SMTP-Fehler. + await new Promise((r) => setTimeout(r, 1300)); + const rUnconfirmed = await spike().signUp(unconfirmed, randomPassword()); + await record(test.info(), 'signup', { + newAddress: { ...summarize(rFresh), userInDbAfterwards: Boolean(await userByEmail(fresh)) }, + confirmedAddress: summarize(rConfirmed), + unconfirmedAddress: summarize(rUnconfirmed), + mailsToFresh: await countMails(fresh), + }); + }); + + test('resend: unbestaetigte vs. unbekannte Adresse', async () => { + const unconfirmed = randomEmail(); + await cli.signUp(unconfirmed, randomPassword(), validMetadata()); + await new Promise((r) => setTimeout(r, 1300)); + const unknown = randomEmail('unknown'); + const a = await spike().resend(unconfirmed); + const b = await spike().resend(unknown); + await record(test.info(), 'resend', { unconfirmed: summarize(a), unknown: summarize(b) }); + }); +}); diff --git a/e2e/tests/throttle/krit07-drosselung.spec.ts b/e2e/tests/throttle/krit07-drosselung.spec.ts new file mode 100644 index 0000000..31f691c --- /dev/null +++ b/e2e/tests/throttle/krit07-drosselung.spec.ts @@ -0,0 +1,94 @@ +/** + * KTO-01 Kriterium 7 (Drosselung), AUTH-CFG-10 und AUTH-CFG-13. + * + * Laeuft gegen den ZUSAETZLICHEN GoTrue-Container mit GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For + * (e2e/support/start-throttle-gotrue.sh, THROTTLE_GOTRUE_URL), nicht gegen die CLI: Die CLI + * drosselt nie (Spike A2, F11). Jeder Test nutzt eine eigene zufaellige IP (simuliert den Proxy, + * R13). Es wird direkt auf GoTrue zugegriffen, ohne Kong und ohne apikey. + */ +import { expect, test } from '@playwright/test'; +import { authConfig, env, errorCodes } from '../../support/auth-config'; +import { GoTrueClient, randomEmail, randomIp, randomPassword } from '../../support/gotrue'; + +const base = env.throttleGotrueUrl; +test.skip(!base, 'THROTTLE_GOTRUE_URL nicht gesetzt (siehe e2e/support/start-throttle-gotrue.sh)'); + +const client = (ip?: string) => + new GoTrueClient({ baseUrl: base!, apikey: null, headers: ip ? { 'X-Forwarded-For': ip } : {} }); + +/** Konto, das GoTrue-Instanz und DB mit der CLI teilen; Anlage ohne Header ueber die CLI (Service-Role). */ +async function createUser() { + const email = randomEmail(); + const password = randomPassword(); + const res = await new GoTrueClient().createConfirmedUser(email, password); + expect(res.status).toBe(200); + return { email, password }; +} + +test.describe('KTO-01 Kriterium 7: Drosselung der Anmeldung (throttle)', () => { + test('KTO-01 Kriterium 7: AUTH-CFG-10 30 Fehllogins einer IP -> invalid_credentials, Login #31 -> 429 over_request_rate_limit', async () => { + const { email, password } = await createUser(); + const ip = randomIp(); + const gotrue = client(ip); + + for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) { + const res = await gotrue.signIn(email, `${password}-falsch`); + expect(res.status, `Versuch ${attempt}`).toBe(400); + expect(res.body.error_code, `Versuch ${attempt}`).toBe(errorCodes.invalidCredentials); + } + + // Login #31 -> 429. Der Limiter fuellt 0,5 Token/s nach; auf langsamen Rechnern darf die + // erste 429 deshalb bis zu zwei Versuche spaeter kommen (Burst bleibt 30, AUTH-CFG-10). + let limited = await gotrue.signIn(email, `${password}-falsch`); + for (let extra = 0; extra < 2 && limited.status !== 429; extra++) { + limited = await gotrue.signIn(email, `${password}-falsch`); + } + expect(limited.status).toBe(429); + expect(limited.body.error_code).toBe(errorCodes.overRequestRateLimit); + // Auch der richtige Login dieser IP wird gedrosselt (Drosselung je IP, nicht je Passwort). + expect((await gotrue.signIn(email, password)).status).toBe(429); + }); + + test('KTO-01 Kriterium 7: AUTH-CFG-10 die 429-Antwort traegt weder Retry-After noch X-RateLimit-* (App nennt keine Dauer)', async () => { + const { email, password } = await createUser(); + const gotrue = client(randomIp()); + let limited; + for (let attempt = 1; attempt <= authConfig.loginBurst + 5; attempt++) { + const res = await gotrue.signIn(email, `${password}-falsch`); + if (res.status === 429) { + limited = res; + break; + } + } + expect(limited, 'es gab eine 429-Antwort').toBeTruthy(); + expect(limited!.headers.get('retry-after')).toBeNull(); + const rateHeaders = [...limited!.headers.keys()].filter((h) => h.toLowerCase().startsWith('x-ratelimit')); + expect(rateHeaders).toEqual([]); + }); + + test('KTO-01 Kriterium 7: AUTH-CFG-10 eine zweite IP ist von der Drosselung der ersten nicht betroffen', async () => { + const { email, password } = await createUser(); + const first = client(randomIp()); + for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) { + await first.signIn(email, `${password}-falsch`); + } + expect((await first.signIn(email, `${password}-falsch`)).status).toBe(429); + + const second = client(randomIp()); + const wrong = await second.signIn(email, `${password}-falsch`); + expect(wrong.status).toBe(400); + expect(wrong.body.error_code).toBe(errorCodes.invalidCredentials); + const right = await second.signIn(email, password); + expect(right.status).toBe(200); + expect(right.body.access_token).toBeTruthy(); + }); + + test('KTO-01 Kriterium 7: AUTH-CFG-13 Requests ohne X-Forwarded-For werden nicht gedrosselt', async () => { + const { email, password } = await createUser(); + const gotrue = client(); + for (let attempt = 1; attempt <= authConfig.loginBurst + 5; attempt++) { + const res = await gotrue.signIn(email, `${password}-falsch`); + expect(res.status, `Versuch ${attempt}`).toBe(400); + } + }); +});