test(auth): address review findings for e2e
Some checks failed
CI / flutter (pull_request) Successful in 1m15s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 30s
CI / e2e (pull_request) Failing after 3m56s

Refs #38

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 10:55:27 +01:00
parent da8305c12d
commit aa421b12b0
18 changed files with 225 additions and 55 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

1
e2e/package-lock.json generated
View file

@ -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"
}

View file

@ -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"
}

View file

@ -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,
},
});

View file

@ -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',

View file

@ -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<T>(fn: (c: Client) => Promise<T>): Promise<T> {
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<T>(fn: (c: Client) => Promise<T>): Promise<T> {
}
}
/** 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<number> {
return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count));
}

View file

@ -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<number> {
await sleep(windowMs);
export async function expectMailCount(address: string, expected: number): Promise<number> {
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;

View file

@ -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<Record<string, string
/** Neue Seite in einem frischen Kontext mit denselben Browser-Optionen. */
export async function freshContext(context: BrowserContext): Promise<BrowserContext> {
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<v
(globalThis as any).Date = ShiftedDate;
}, offsetMs);
}
/** Schluessel der supabase_flutter-Sitzung im localStorage (nicht fest verdrahtet, nicht ueber Index). */
export async function authStorageKey(page: Page): Promise<string | null> {
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<BrowserContext> {
return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl });
}

View file

@ -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');

View file

@ -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/);
});
});

View file

@ -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);

View file

@ -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([]);
});

View file

@ -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();

View file

@ -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$/);
});
});

View file

@ -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);

View file

@ -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);
});
});