From aa421b12b0fc25cd7e6f339d7396acefc372927c Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 10:55:27 +0100 Subject: [PATCH] test(auth): address review findings for e2e Refs #38 Co-Authored-By: Claude Sonnet 5.5 --- docs/auth-konfiguration.md | 2 + docs/specs/1-auth-registrierung-anmeldung.md | 7 ++- e2e/README.md | 2 +- e2e/package-lock.json | 1 + e2e/package.json | 1 + e2e/playwright.config.ts | 6 ++- e2e/support/auth-config.ts | 55 ++++++++++++++++---- e2e/support/db.ts | 39 +++++++++++--- e2e/support/mailpit.ts | 18 +++++-- e2e/support/web.ts | 28 +++++++++- e2e/tests/api/config.spec.ts | 9 +++- e2e/tests/api/guards.spec.ts | 35 +++++++++++++ e2e/tests/api/krit04-enumeration.spec.ts | 8 +-- e2e/tests/throttle/krit07-drosselung.spec.ts | 23 +++++--- e2e/tests/web/krit04-06-07-anmeldung.spec.ts | 16 +++--- e2e/tests/web/krit05-sitzung.spec.ts | 10 ++-- e2e/tests/web/krit08-reset.spec.ts | 4 +- e2e/tests/web/krit09-abmeldung.spec.ts | 16 +++--- 18 files changed, 225 insertions(+), 55 deletions(-) create mode 100644 e2e/tests/api/guards.spec.ts diff --git a/docs/auth-konfiguration.md b/docs/auth-konfiguration.md index c3b2c5c..62701c0 100644 --- a/docs/auth-konfiguration.md +++ b/docs/auth-konfiguration.md @@ -202,6 +202,8 @@ Der Job `e2e` in `.forgejo/workflows/ci.yml` läuft auf dem Runner `docker-host` Das Projekt `spike` (SMTP-Ausfall, `SPIKE_GOTRUE_URL`) läuft in CI nicht. Der Job hat `needs: supabase-tests` (gleiche Container-Namen und Ports im dind) und läuft nicht für Fork-PRs: Wer PR-Code auf `docker-host` ausführt, hat Zugriff auf den dind-Daemon des Runners. +**Achtung Artefakte:** Playwright-Reports, Traces und Screenshots (CI-Artefakte, `test-results/`, `playwright-report/`) enthalten Tokens, Passwörter und Mailinhalte der angelegten Testkonten. Die Konten existieren nur in der Wegwerf-Instanz (lokale CLI/CI), dennoch Artefakte nicht in ein öffentliches Repo oder eine öffentliche Release-Seite legen und nur kurz aufbewahren. Die Tests lehnen nicht-lokale Hosts ab (`SUPABASE_URL`, `MAILPIT_URL`, `E2E_DB_URL`; CI-Hosts nur explizit über `E2E_ALLOWED_HOSTS`). + ### SMTP-Ausfall (Spike #37, Stand 2026-11-03, GoTrue `v2.197.0`) Messung gegen einen zusätzlichen GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt (`e2e/support/start-throttle-gotrue.sh start smtp-down`, Test `e2e/tests/spike/smtp-down.spec.ts`). Mailpit bleibt für die anderen Tests unberührt. Die Tests prüfen nichts, sie protokollieren. diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 3c654f6..e6dccc2 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -355,7 +355,7 @@ Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“). | Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 | | Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 | | Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 | -| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 | +| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt; ein geändertes `expires_at` im `localStorage` wirkt nicht, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 | | Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 | | Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 | | QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 | @@ -509,6 +509,11 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien 8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert). 9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab. +### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`) + +1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit. +2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`). + ### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris 1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst. diff --git a/e2e/README.md b/e2e/README.md index 5879132..3685cd0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -37,7 +37,7 @@ THROTTLE_GOTRUE_URL=http://127.0.0.1:9998 SPIKE_GOTRUE_URL=http://127.0.0.1:9997 Umgebungsvariablen (Standard = lokale CLI-Demo-Werte, keine Secrets): `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`, -`TERMS_VERSION`. +`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]). ## Aufbau diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 2a9f153..aa5e01a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -12,6 +12,7 @@ "@types/node": "^22.0.0", "@types/pg": "^8.11.0", "pg": "^8.13.0", + "pg-connection-string": "^2.7.0", "serve": "^14.2.4", "typescript": "^5.6.0" } diff --git a/e2e/package.json b/e2e/package.json index 773e036..438a262 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -15,6 +15,7 @@ "@types/node": "^22.0.0", "@types/pg": "^8.11.0", "pg": "^8.13.0", + "pg-connection-string": "^2.7.0", "serve": "^14.2.4", "typescript": "^5.6.0" } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0591fdc..64494be 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig, devices, type Project } from '@playwright/test'; -const webUrl = process.env.WEB_URL ?? 'http://localhost:3000'; +import { env } from './support/auth-config'; + +const webUrl = env.webUrl; /** * Projekte (Spec #1 § 7): @@ -46,7 +48,7 @@ export default defineConfig({ webServer: { command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard', url: webUrl, - reuseExistingServer: true, + reuseExistingServer: !process.env.CI, timeout: 30_000, }, }); diff --git a/e2e/support/auth-config.ts b/e2e/support/auth-config.ts index c9c620e..5522221 100644 --- a/e2e/support/auth-config.ts +++ b/e2e/support/auth-config.ts @@ -9,19 +9,56 @@ * LOKALEN Wert (die E2E-Tests laufen nur lokal bzw. in der CI gegen die CLI). */ +/** Hosts, die als „lokal“ gelten (URL-Hostnamen; IPv6 steht in URL.hostname mit Klammern). */ +const LOCAL_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; + +/** + * Zusaetzlich erlaubte Hosts fuer die CI (z. B. der Docker-Host `docker`), kommagetrennt in + * E2E_ALLOWED_HOSTS. Nur setzen, wenn der Dienst ein Wegwerf-Stack ist. + */ +function allowedHosts(): string[] { + const extra = (process.env.E2E_ALLOWED_HOSTS ?? '').split(',').map((h) => h.trim()).filter(Boolean); + return [...LOCAL_HOSTS, ...extra]; +} + +export function isLocalHost(hostname: string): boolean { + return allowedHosts().includes(hostname.toLowerCase()); +} + +/** Bricht ab, wenn die URL nicht auf einen lokalen (oder explizit erlaubten) Host zeigt. */ +export function assertLocalUrl(name: string, value: string): string { + const host = new URL(value).hostname; + if (!isLocalHost(host)) { + throw new Error(`${name}: Host "${host}" ist nicht lokal. Die E2E-Tests legen Testkonten an; ggf. E2E_ALLOWED_HOSTS setzen.`); + } + return value; +} + +const DEMO_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; +const DEMO_SERVICE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseUrl = assertLocalUrl('SUPABASE_URL', process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321'); +const isDefaultLocal = ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(supabaseUrl).hostname); + +function key(envName: string, demo: string): string { + const v = process.env[envName]; + if (v) return v; + // Demo-Schluessel nur bei lokaler URL; sonst muss der Aufrufer den Schluessel setzen. + if (!isDefaultLocal) throw new Error(`${envName} muss gesetzt sein, wenn SUPABASE_URL nicht lokal ist.`); + return demo; +} + /** Umgebung (Standardwerte = lokale Demo-Werte der Supabase CLI, keine Secrets). */ export const env = { /** Kong, wie die App. */ - supabaseUrl: process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321', - /** Legacy-JWT-Anon-Key der CLI (oeffentlicher Demo-Wert). */ - anonKey: - process.env.SUPABASE_ANON_KEY ?? - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0', + supabaseUrl, + /** Legacy-JWT-Anon-Key der CLI (oeffentlicher Demo-Wert, nur bei lokaler URL als Fallback). */ + anonKey: key('SUPABASE_ANON_KEY', DEMO_ANON_KEY), /** Legacy-Service-Role-Key der CLI (oeffentlicher Demo-Wert, nur lokal). */ - serviceRoleKey: - process.env.SUPABASE_SERVICE_ROLE_KEY ?? - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU', - mailpitUrl: process.env.MAILPIT_URL ?? 'http://127.0.0.1:54324', + serviceRoleKey: key('SUPABASE_SERVICE_ROLE_KEY', DEMO_SERVICE_KEY), + mailpitUrl: assertLocalUrl('MAILPIT_URL', process.env.MAILPIT_URL ?? 'http://127.0.0.1:54324'), dbUrl: process.env.E2E_DB_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres', /** Web-Build. Immer `localhost` (nicht 127.0.0.1): Die Mail-Links nutzen AUTH-CFG-01. */ webUrl: process.env.WEB_URL ?? 'http://localhost:3000', diff --git a/e2e/support/db.ts b/e2e/support/db.ts index 60c5118..b525653 100644 --- a/e2e/support/db.ts +++ b/e2e/support/db.ts @@ -5,18 +5,34 @@ * `auth.one_time_tokens.created_at`. */ import { Client } from 'pg'; -import { authConfig, env } from './auth-config'; +import { parse } from 'pg-connection-string'; +import { authConfig, env, isLocalHost } from './auth-config'; -function assertLocal(url: string): void { - const host = new URL(url).hostname; - if (!['127.0.0.1', 'localhost', '::1', 'host.docker.internal'].includes(host)) { - throw new Error(`e2e/support/db.ts nur lokal/CI: Host "${host}" ist nicht erlaubt`); +/** + * Parst die Verbindungszeichenfolge mit demselben Parser wie `pg` und prueft den Host des + * GEPARSTEN Ergebnisses (auch `?host=...`, Unix-Sockets und mehrere Hosts werden so erfasst). + * Der Client wird aus genau diesem Ergebnis gebaut, nicht erneut aus dem String. + */ +export function localDbConfig(connectionString: string) { + const cfg = parse(connectionString); + const host = (cfg.host ?? '').replace(/^\[|\]$/g, ''); + // `parse` liefert IPv6 ohne Klammern; unsere Liste fuehrt sie mit Klammern. + const candidate = host.includes(':') ? `[${host}]` : host; + if (!candidate || !isLocalHost(candidate)) { + throw new Error(`e2e/support/db.ts nur lokal/CI: Host "${cfg.host}" ist nicht erlaubt`); } + return cfg; } export async function withDb(fn: (c: Client) => Promise): Promise { - assertLocal(env.dbUrl); - const client = new Client({ connectionString: env.dbUrl }); + const cfg = localDbConfig(env.dbUrl); + const client = new Client({ + host: cfg.host ?? undefined, + port: cfg.port ? Number(cfg.port) : undefined, + user: cfg.user, + password: cfg.password, + database: cfg.database ?? undefined, + }); await client.connect(); try { return await fn(client); @@ -25,6 +41,15 @@ export async function withDb(fn: (c: Client) => Promise): Promise { } } +/** Zeilen aus `auth.users` zu E-Mail oder ID (nie global zaehlen: Tests laufen parallel). */ +export async function usersWhere(by: { email?: string; id?: string }) { + return withDb(async (c) => + by.email !== undefined + ? (await c.query('select id from auth.users where email = $1', [by.email.toLowerCase()])).rows + : (await c.query('select id from auth.users where id = $1', [by.id])).rows, + ); +} + export async function countUsers(): Promise { return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count)); } diff --git a/e2e/support/mailpit.ts b/e2e/support/mailpit.ts index 805b822..cc34374 100644 --- a/e2e/support/mailpit.ts +++ b/e2e/support/mailpit.ts @@ -1,5 +1,5 @@ import { env } from './auth-config'; -import { sleep } from './gotrue'; +import { GoTrueClient, randomEmail, randomPassword, sleep } from './gotrue'; export interface MailSummary { ID: string; @@ -53,11 +53,21 @@ export async function waitForMail(address: string, count = 1, timeoutMs = 10_000 } /** - * Belegt „es kommt keine (weitere) Mail": wartet ein festes Fenster und zaehlt dann. + * Belegt „es kommt keine (weitere) Mail“ ohne festes Zeitfenster: GoTrue versendet synchron im + * Request, Mailpit nimmt die Mails der Reihe nach an. Der Helfer loest deshalb eine bekannte + * Folgemail (Passwort-Reset fuer ein frisches Sentinel-Konto) aus, wartet auf deren Eintreffen + * und zaehlt danach die Mails an `address`. Ist die Sentinel-Mail da, waeren zuvor gesendete + * Mails es auch. * Nur dort einsetzen, wo die Abwesenheit selbst das Kriterium ist (Krit. 4, 8, 11). */ -export async function expectMailCount(address: string, expected: number, windowMs = 2_000): Promise { - await sleep(windowMs); +export async function expectMailCount(address: string, expected: number): Promise { + const client = new GoTrueClient(); + const sentinel = randomEmail('sentinel'); + const created = await client.createConfirmedUser(sentinel, randomPassword()); + if (created.status !== 200) throw new Error(`Sentinel-Konto nicht anlegbar: HTTP ${created.status}`); + const res = await client.recover(sentinel); + if (res.status !== 200) throw new Error(`Sentinel-Reset fehlgeschlagen: HTTP ${res.status}`); + await waitForMail(sentinel); const actual = await countMails(address); if (actual !== expected) throw new Error(`Mails an ${address}: erwartet ${expected}, vorhanden ${actual}`); return actual; diff --git a/e2e/support/web.ts b/e2e/support/web.ts index b5508aa..bb93888 100644 --- a/e2e/support/web.ts +++ b/e2e/support/web.ts @@ -6,7 +6,7 @@ * `app/lib/l10n/app_en.arb` (Browser-Sprache en-US, siehe playwright.config.ts). */ import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'; -import { TERMS_VERSION } from './auth-config'; +import { env, TERMS_VERSION } from './auth-config'; export { TERMS_VERSION }; @@ -127,7 +127,7 @@ export async function readAuthStorage(page: Page): Promise { - return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' }); + return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl }); } /** @@ -153,3 +153,27 @@ export async function shiftBrowserClock(page: Page, offsetMs: number): Promise { + return page.evaluate(() => { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i)!; + if (/^sb-.*-auth-token$/.test(k)) return k; + } + return null; + }); +} + +/** Aktuelle Sitzung aus dem localStorage oder `null`. */ +export async function readSession(page: Page): Promise<{ access_token: string; refresh_token: string } | null> { + const key = await authStorageKey(page); + if (!key) return null; + const raw = await page.evaluate((k) => localStorage.getItem(k), key); + return raw ? JSON.parse(raw) : null; +} + +/** Frischer Browser-Kontext mit den Projekt-Optionen (en-US, kein Service Worker, Basis-URL aus der Config). */ +export async function freshBrowserContext(browser: import('@playwright/test').Browser): Promise { + return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl }); +} diff --git a/e2e/tests/api/config.spec.ts b/e2e/tests/api/config.spec.ts index 7d175ff..9d86af9 100644 --- a/e2e/tests/api/config.spec.ts +++ b/e2e/tests/api/config.spec.ts @@ -45,9 +45,16 @@ test.describe('Auth-Konfiguration: auth-config.ts stimmt mit config.toml und Dok test('AUTH-CFG-08: Mindestabstand lokal 1 s', () => { expect(value('max_frequency', 'auth.email')).toBe(`${authConfig.maxFrequencySeconds}s`); }); - test('AUTH-CFG-10/-11: Burst der Drosselung', () => { + test('AUTH-CFG-10: Login-/Token-Refresh-Drosselung (150 je 5 min, Burst 30)', () => { + expect(Number(value('token_refresh', 'auth.rate_limit'))).toBe(150); + expect(authConfig.loginBurst).toBe(30); + }); + test('AUTH-CFG-11: Signup/Reset/Resend-Drosselung (30 je 5 min)', () => { expect(Number(value('sign_in_sign_ups', 'auth.rate_limit'))).toBe(authConfig.otpBurst); }); + test('AUTH-CFG-12: Link-Pruefung (30 je 5 min)', () => { + expect(Number(value('token_verifications', 'auth.rate_limit'))).toBe(30); + }); test('AUTH-CFG-14/-15: JWT-Laufzeit und Refresh-Token-Rotation', () => { expect(Number(value('jwt_expiry', 'auth'))).toBe(authConfig.jwtExpirySeconds); expect(value('enable_refresh_token_rotation', 'auth')).toBe('true'); diff --git a/e2e/tests/api/guards.spec.ts b/e2e/tests/api/guards.spec.ts new file mode 100644 index 0000000..b61d509 --- /dev/null +++ b/e2e/tests/api/guards.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { assertLocalUrl } from '../../support/auth-config'; +import { localDbConfig } from '../../support/db'; + +test.describe('E2E-Guards: Tests laufen nur gegen lokale Dienste', () => { + test('db.ts: lokale Hosts werden akzeptiert (inkl. IPv6 in Klammern)', () => { + for (const url of [ + 'postgresql://postgres:postgres@127.0.0.1:54322/postgres', + 'postgresql://postgres:postgres@localhost:54322/postgres', + 'postgresql://postgres:postgres@[::1]:54322/postgres', + ]) { + expect(() => localDbConfig(url), url).not.toThrow(); + } + }); + + test('db.ts: fremde Hosts werden abgelehnt', () => { + expect(() => localDbConfig('postgresql://u:p@db.example.com:5432/postgres')).toThrow(/nicht erlaubt/); + }); + + test('db.ts: ?host= kann den Guard nicht umgehen', () => { + expect(() => localDbConfig('postgresql://u:p@127.0.0.1:5432/postgres?host=db.example.com')).toThrow(/nicht erlaubt/); + expect(() => localDbConfig('postgresql://u:p@localhost/postgres?host=/var/run/postgresql,db.example.com')).toThrow(); + }); + + test('db.ts: Host-Namen mit lokalem Praefix/Suffix gelten nicht als lokal', () => { + expect(() => localDbConfig('postgresql://u:p@localhost.evil.com:5432/postgres')).toThrow(); + expect(() => localDbConfig('postgresql://u:p@127.0.0.1.evil.com:5432/postgres')).toThrow(); + }); + + test('SUPABASE_URL/MAILPIT_URL: nur lokale Hosts', () => { + expect(() => assertLocalUrl('X', 'http://127.0.0.1:54321')).not.toThrow(); + expect(() => assertLocalUrl('X', 'http://[::1]:54321')).not.toThrow(); + expect(() => assertLocalUrl('X', 'https://abc.supabase.co')).toThrow(/nicht lokal/); + }); +}); diff --git a/e2e/tests/api/krit04-enumeration.spec.ts b/e2e/tests/api/krit04-enumeration.spec.ts index 3408fb1..83b1ae5 100644 --- a/e2e/tests/api/krit04-enumeration.spec.ts +++ b/e2e/tests/api/krit04-enumeration.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { countUsers, profileByEmail, userByEmail } from '../../support/db'; +import { profileByEmail, userByEmail, usersWhere } from '../../support/db'; import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue'; import { countMails, expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; @@ -20,7 +20,6 @@ test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => { expect(await countMails(email)).toBe(1); await waitMailInterval(); - const usersBefore = await countUsers(); const newPassword = randomPassword(); const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' })); @@ -29,7 +28,10 @@ test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => { expect(again.body.identities ?? []).toEqual([]); expect(again.body.id).toBeTruthy(); expect(again.body.id).not.toBe(realId); - expect(await countUsers()).toBe(usersBefore); + // Kein zweites Konto: genau eine Zeile fuer die Adresse, keine mit der erfundenen ID + // (unabhaengig von parallel laufenden Tests, kein globaler Zaehler). + expect(await usersWhere({ email })).toHaveLength(1); + expect(await usersWhere({ id: again.body.id })).toHaveLength(0); // Keine (weitere) Mail an die Adresse: nach festem Fenster weiterhin nur die erste Bestaetigungsmail. await expectMailCount(email, 1); diff --git a/e2e/tests/throttle/krit07-drosselung.spec.ts b/e2e/tests/throttle/krit07-drosselung.spec.ts index 31f691c..0268eed 100644 --- a/e2e/tests/throttle/krit07-drosselung.spec.ts +++ b/e2e/tests/throttle/krit07-drosselung.spec.ts @@ -13,6 +13,9 @@ import { GoTrueClient, randomEmail, randomIp, randomPassword } from '../../suppo const base = env.throttleGotrueUrl; test.skip(!base, 'THROTTLE_GOTRUE_URL nicht gesetzt (siehe e2e/support/start-throttle-gotrue.sh)'); +/** Zusaetzliche Versuche, die der Nachfuellrate (0,5/s) der gemessenen Dauer entsprechen: ceil(t * 0,5) + 1. */ +const refillTolerance = (elapsedMs: number) => Math.ceil((elapsedMs / 1000) * 0.5) + 1; + const client = (ip?: string) => new GoTrueClient({ baseUrl: base!, apikey: null, headers: ip ? { 'X-Forwarded-For': ip } : {} }); @@ -31,16 +34,18 @@ test.describe('KTO-01 Kriterium 7: Drosselung der Anmeldung (throttle)', () => { const ip = randomIp(); const gotrue = client(ip); + const started = Date.now(); 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); } + const tolerance = refillTolerance(Date.now() - started); - // 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). + // Login #31 -> 429. Der Limiter fuellt 0,5 Token/s nach (AUTH-CFG-10); je Sekunde Laufzeit + // der ersten 30 Versuche darf die erste 429 einen halben Versuch spaeter kommen. let limited = await gotrue.signIn(email, `${password}-falsch`); - for (let extra = 0; extra < 2 && limited.status !== 429; extra++) { + for (let extra = 0; extra < tolerance && limited.status !== 429; extra++) { limited = await gotrue.signIn(email, `${password}-falsch`); } expect(limited.status).toBe(429); @@ -52,17 +57,21 @@ test.describe('KTO-01 Kriterium 7: Drosselung der Anmeldung (throttle)', () => { 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()); + const started = Date.now(); let limited; - for (let attempt = 1; attempt <= authConfig.loginBurst + 5; attempt++) { + let attempts = 0; + // Hoechstens Burst + Toleranz aus der gemessenen Dauer; danach muss eine 429 gekommen sein. + while (attempts < authConfig.loginBurst + refillTolerance(Date.now() - started) + 1) { + attempts++; 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')); + if (!limited) throw new Error(`Keine 429 nach ${attempts} Versuchen in ${Date.now() - started} ms`); + expect(limited.headers.get('retry-after')).toBeNull(); + const rateHeaders = [...limited.headers.keys()].filter((h) => h.toLowerCase().startsWith('x-ratelimit')); expect(rateHeaders).toEqual([]); }); diff --git a/e2e/tests/web/krit04-06-07-anmeldung.spec.ts b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts index 695b72c..e78b931 100644 --- a/e2e/tests/web/krit04-06-07-anmeldung.spec.ts +++ b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue'; import { expectMailCount, waitForMail } from '../../support/mailpit'; -import { fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web'; +import { freshBrowserContext, fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web'; const gotrue = new GoTrueClient(); @@ -25,7 +25,7 @@ test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorha // Vorhandene, bestaetigte Adresse (nur ueber die API angelegt). const existing = randomEmail(); await gotrue.createConfirmedUser(existing, randomPassword()); - const second = await (await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' })).newPage(); + const second = await (await freshBrowserContext(browser)).newPage(); try { await register(second, { email: existing, password: randomPassword() }); await expect(second).toHaveURL(/\/register\/check-email$/); @@ -67,7 +67,7 @@ test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselung await expect(textOf(page, t.invalidCredentials)).toBeVisible(); const wrongPassword = await screenText(page); - const ctx = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' }); + const ctx = await freshBrowserContext(browser); try { const other = await ctx.newPage(); await login(other, randomEmail('unknown'), password); @@ -102,15 +102,19 @@ test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselung }); test.describe('QUE-06 (web): Ablauf nur per Tastatur', () => { - test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter', async ({ page }) => { + test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter (E-Mail-Feld per Tab erreicht)', async ({ page }) => { const email = randomEmail(); const password = randomPassword(); await gotrue.createConfirmedUser(email, password); await open(page, '/login'); - await fillField(page.getByRole('textbox', { name: 'Email address' }), email); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + // Kein Klick: Tab fokussiert das erste Feld (E-Mail), Tab das zweite (Passwort). await page.keyboard.press('Tab'); - await page.waitForTimeout(500); + await expect(page.getByRole('textbox', { name: 'Email address' })).toBeFocused(); + await page.keyboard.type(email); + await page.keyboard.press('Tab'); + await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused(); await page.keyboard.type(password); await page.keyboard.press('Enter'); await expect(textOf(page, t.home)).toBeVisible(); diff --git a/e2e/tests/web/krit05-sitzung.spec.ts b/e2e/tests/web/krit05-sitzung.spec.ts index ac8be3e..9bb5def 100644 --- a/e2e/tests/web/krit05-sitzung.spec.ts +++ b/e2e/tests/web/krit05-sitzung.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; -import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readAuthStorage } from '../../support/web'; +import { env } from '../../support/auth-config'; +import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, open, readAuthStorage } from '../../support/web'; const gotrue = new GoTrueClient(); @@ -28,7 +29,7 @@ test.describe('KTO-01 Kriterium 5 (web): Sitzung bleibt bestehen', () => { storageState: state, locale: 'en-US', serviceWorkers: 'block', - baseURL: 'http://localhost:3000', + baseURL: env.webUrl, }); try { const other = await restored.newPage(); @@ -41,7 +42,10 @@ test.describe('KTO-01 Kriterium 5 (web): Sitzung bleibt bestehen', () => { }); test('KTO-01 Kriterium 5: ohne Sitzung bleibt /login erreichbar (Gegenprobe)', async ({ page }) => { - await page.goto('/login'); + await open(page, '/login'); + // Erst nach dem Flutter-Boot pruefen: das Formular ist sichtbar, ein spaeter Redirect waere bis dahin passiert. + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + await page.waitForTimeout(500); await expect(page).toHaveURL(/\/login$/); }); }); diff --git a/e2e/tests/web/krit08-reset.spec.ts b/e2e/tests/web/krit08-reset.spec.ts index 98829f0..5ee03c2 100644 --- a/e2e/tests/web/krit08-reset.spec.ts +++ b/e2e/tests/web/krit08-reset.spec.ts @@ -3,7 +3,7 @@ import { subjects } from '../../support/auth-config'; import { expireConfirmationLink, expireRecoveryLink } from '../../support/db'; import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; -import { fillField, open, pressFirstButton, t, textOf } from '../../support/web'; +import { freshBrowserContext, fillField, open, pressFirstButton, t, textOf } from '../../support/web'; const gotrue = new GoTrueClient(); @@ -89,7 +89,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => { const body = (await page.locator('body').ariaSnapshot()).trim(); const unknown = randomEmail('unknown'); - const ctx = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' }); + const ctx = await freshBrowserContext(browser); try { const other = await ctx.newPage(); await requestReset(other, unknown); diff --git a/e2e/tests/web/krit09-abmeldung.spec.ts b/e2e/tests/web/krit09-abmeldung.spec.ts index b7d6572..cf4099d 100644 --- a/e2e/tests/web/krit09-abmeldung.spec.ts +++ b/e2e/tests/web/krit09-abmeldung.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; import { extractTokenHash, waitForMail } from '../../support/mailpit'; -import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readAuthStorage, shiftBrowserClock } from '../../support/web'; +import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readSession, shiftBrowserClock } from '../../support/web'; const gotrue = new GoTrueClient(); @@ -25,13 +25,14 @@ test.describe('KTO-01 Kriterium 9 (web): Passwort-Reset beendet andere Sitzungen const update = await gotrue.updateUser(recovery.body.access_token, { password: randomPassword() }); expect(update.status).toBe(200); - // Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, bis 60 min, F1) ab: Der Test - // erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt. + // Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, bis 60 min, F1) ab. Der Test + // erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt (Session.expiresAt kommt in + // supabase_flutter aus dem JWT-Claim `exp`, ein geaendertes `expires_at` im Storage wirkt nicht). await shiftBrowserClock(page, 2 * 60 * 60 * 1000); await page.reload(); await enableSemantics(page); - // Der Refresh scheitert (refresh_token_not_found): supabase_flutter loescht die Sitzung. - await expect.poll(async () => Object.keys(await readAuthStorage(page)), { timeout: 15_000 }).toEqual([]); + // Der Refresh scheitert (refresh_token_not_found): supabase_flutter loescht die Sitzung aus dem Storage. + await expect.poll(async () => await readSession(page), { timeout: 15_000 }).toBeNull(); // Abgemeldet: /login leitet nicht mehr auf die Startseite (Gegenprobe zu Krit. 5). await page.goto('/login'); await enableSemantics(page); @@ -50,12 +51,13 @@ test.describe('KTO-01 Kriterium 9 (web): Passwort-Reset beendet andere Sitzungen await expectSignedIn(page); await shiftBrowserClock(page, 2 * 60 * 60 * 1000); - const before = JSON.parse(Object.values(await readAuthStorage(page))[0]).access_token; + const before = (await readSession(page))?.access_token; + expect(before, 'Sitzung vor dem Reload').toBeTruthy(); await page.reload(); await expectSignedInViaLoginRedirect(page); // Der Refresh lief wirklich: neues Access-Token im Storage. await expect - .poll(async () => JSON.parse(Object.values(await readAuthStorage(page))[0] ?? '{}').access_token) + .poll(async () => (await readSession(page))?.access_token ?? before, { timeout: 15_000 }) .not.toBe(before); }); });