test(auth): address review findings for e2e
Refs #38 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
da8305c12d
commit
aa421b12b0
18 changed files with 225 additions and 55 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
1
e2e/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
35
e2e/tests/api/guards.spec.ts
Normal file
35
e2e/tests/api/guards.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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$/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue