test(auth): KTO-01 Playwright-E2E (api, web, throttle) und CI-Job e2e #42

Merged
cschlaefke merged 9 commits from feature/38-auth-e2e into main 2026-11-03 10:42:02 +00:00
19 changed files with 2536 additions and 0 deletions
Showing only changes of commit f302c56904 - Show all commits

1310
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
e2e/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "shuffle-e2e",
"private": true,
"version": "0.0.0",
"description": "Playwright E2E tests for Shuffle (auth: KTO-01, Issue #38)",
"scripts": {
"test": "playwright test",
"test:api": "playwright test --project=api",
"test:web": "playwright test --project=web",
"test:throttle": "playwright test --project=throttle",
"test:spike": "playwright test --project=spike"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"pg": "^8.13.0",
"serve": "^14.2.4",
"typescript": "^5.6.0"
}
}

52
e2e/playwright.config.ts Normal file
View file

@ -0,0 +1,52 @@
import { defineConfig, devices, type Project } from '@playwright/test';
const webUrl = process.env.WEB_URL ?? 'http://localhost:3000';
/**
* Projekte (Spec #1 § 7):
* - api GoTrue ueber Kong + Mailpit-Helfer
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
*/
const projects: Project[] = [
{ name: 'api', testDir: './tests/api', use: {} },
{
name: 'web',
testDir: './tests/web',
use: {
...devices['Desktop Chrome'],
baseURL: webUrl,
// Englische Texte (app_en.arb); die App waehlt die Sprache nach dem Browser.
locale: 'en-US',
// Der Flutter-Service-Worker wuerde alte Builds cachen.
serviceWorkers: 'block',
},
},
];
if (process.env.THROTTLE_GOTRUE_URL) {
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
}
if (process.env.SPIKE_GOTRUE_URL) {
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
}
export default defineConfig({
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: { trace: 'retain-on-failure' },
projects,
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404.
webServer: {
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
url: webUrl,
reuseExistingServer: true,
timeout: 30_000,
},
});

View file

@ -0,0 +1,82 @@
/**
* Konstanten der Auth-Konfiguration (Spec #1 § 4.1, `docs/auth-konfiguration.md`).
*
* Diese Datei muss mit `supabase/config.toml` und `docs/auth-konfiguration.md` uebereinstimmen;
* `tests/api/config.spec.ts` prueft das. Tests lesen Zahlen nur von hier und verweisen im Namen
* auf die Zeilen-ID (z. B. `AUTH-CFG-07`).
*
* Werte, die lokal (Supabase CLI) und in Staging/Prod unterschiedlich sind, tragen hier den
* LOKALEN Wert (die E2E-Tests laufen nur lokal bzw. in der CI gegen die CLI).
*/
/** 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',
/** 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',
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',
/** Zusaetzlicher GoTrue-Container mit AUTH-CFG-13 (Projekt `throttle`, Skript start-throttle-gotrue.sh). */
throttleGotrueUrl: process.env.THROTTLE_GOTRUE_URL,
/** GoTrue-Container mit unerreichbarem SMTP (Projekt `spike`, Issue #37). */
spikeGotrueUrl: process.env.SPIKE_GOTRUE_URL,
} as const;
export const authConfig = {
/** AUTH-CFG-01: Site-URL lokal. */
siteUrl: 'http://localhost:3000',
/** AUTH-CFG-05: Passwort-Mindestlaenge. */
passwordMinLength: 10,
/** AUTH-CFG-07: Gueltigkeit Bestaetigungs- und Reset-Link in Sekunden. */
otpExpirySeconds: 3600,
/** AUTH-CFG-08: Mindestabstand zwischen Mails je Nutzer, lokal 1 s. */
maxFrequencySeconds: 1,
/** AUTH-CFG-10: Burst des Login-Limiters je IP (Login #31 -> 429). */
loginBurst: 30,
/** AUTH-CFG-11: Burst des OTP-Limiters (Signup, Recover, Resend) je IP. */
otpBurst: 30,
/** AUTH-CFG-14: JWT-Laufzeit in Sekunden. */
jwtExpirySeconds: 3600,
/** AUTH-CFG-15: Wiederverwendungsfenster der Refresh-Token-Rotation in Sekunden. */
refreshTokenReuseIntervalSeconds: 10,
/** Anzeigename: Grenzen in Codepoints (Spec 3.2). */
displayNameMaxLength: 30,
} as const;
/** Mailbetreffe (AUTH-CFG-17/-18, `supabase/config.toml`). */
export const subjects = {
confirmation: { de: 'Bestätige deine E-Mail-Adresse für Shuffle', en: 'Confirm your email address for Shuffle' },
recovery: { de: 'Passwort für Shuffle zurücksetzen', en: 'Reset your Shuffle password' },
} as const;
/** Fehlercodes von GoTrue, auf die sich die Spec stuetzt (§ 4.3/4.4). */
export const errorCodes = {
invalidCredentials: 'invalid_credentials',
emailNotConfirmed: 'email_not_confirmed',
weakPassword: 'weak_password',
samePassword: 'same_password',
otpExpired: 'otp_expired',
overRequestRateLimit: 'over_request_rate_limit',
overEmailSendRateLimit: 'over_email_send_rate_limit',
userAlreadyExists: 'user_already_exists',
} as const;
/** Trigger-Fehlertexte bei signUp (HTTP 500, Body `{"code":"P0001","message":...}`, B0 (f)). */
export const triggerErrors = {
displayNameInvalid: 'display_name_invalid',
ageConfirmationRequired: 'age_confirmation_required',
termsAcceptanceRequired: 'terms_acceptance_required',
termsVersionRequired: 'terms_version_required',
} as const;
/** Version der Rechtstexte, die der Web-Build sendet (`app/env/ci.example.json`). */
export const TERMS_VERSION = process.env.TERMS_VERSION ?? '2026-11-draft';

78
e2e/support/db.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Direkter Datenbankzugriff fuer Tests. NUR lokal/CI: der Helfer bricht ab, wenn der Host nicht
* lokal ist (er veraendert `auth.users`). Spec 7.1 Krit. 8 (B0 (i)): Maßgeblich fuer den
* Linkablauf sind `auth.users.recovery_sent_at` und `confirmation_sent_at`, nicht
* `auth.one_time_tokens.created_at`.
*/
import { Client } from 'pg';
import { authConfig, env } 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`);
}
}
export async function withDb<T>(fn: (c: Client) => Promise<T>): Promise<T> {
assertLocal(env.dbUrl);
const client = new Client({ connectionString: env.dbUrl });
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
export async function countUsers(): Promise<number> {
return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count));
}
export async function userByEmail(email: string) {
return withDb(async (c) => (await c.query('select * from auth.users where email = $1', [email.toLowerCase()])).rows[0]);
}
export async function profileByEmail(email: string) {
return withDb(
async (c) =>
(
await c.query(
`select p.* from public.profiles p join auth.users u on u.id = p.id where u.email = $1`,
[email.toLowerCase()],
)
).rows[0],
);
}
/** Setzt den Link als abgelaufen: `recovery_sent_at = now() - (otp_expiry + 1 s)` (AUTH-CFG-07). */
export async function expireRecoveryLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
await withDb((c) =>
c.query(`update auth.users set recovery_sent_at = now() - make_interval(secs => $2) where email = $1`, [
email.toLowerCase(),
secondsAgo,
]),
);
}
/** Wie oben fuer den Bestaetigungslink (`confirmation_sent_at`). */
export async function expireConfirmationLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
await withDb((c) =>
c.query(`update auth.users set confirmation_sent_at = now() - make_interval(secs => $2) where email = $1`, [
email.toLowerCase(),
secondsAgo,
]),
);
}
/** `not_after` aller Sitzungen des Nutzers (AUTH-CFG-16: nicht gesetzt). */
export async function sessionNotAfter(email: string): Promise<(string | null)[]> {
return withDb(async (c) =>
(
await c.query(
`select s.not_after from auth.sessions s join auth.users u on u.id = s.user_id where u.email = $1`,
[email.toLowerCase()],
)
).rows.map((r) => r.not_after),
);
}

134
e2e/support/gotrue.ts Normal file
View file

@ -0,0 +1,134 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { authConfig, env, TERMS_VERSION } from './auth-config';
/** Zufaellige E-Mail-Adresse je Test (Spec 7: jeder Test nutzt eigene Adressen). */
export function randomEmail(prefix = 'e2e'): string {
return `${prefix}-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}@example.test`;
}
/** Zufaelliges, ausreichend langes Passwort (AUTH-CFG-05: mind. 10 Zeichen). */
export function randomPassword(): string {
return `Pw-${randomBytes(8).toString('hex')}-x`;
}
/** Zufaellige IPv4 fuer `X-Forwarded-For` (nur Projekt `throttle`, nie gegen die CLI, R13). */
export function randomIp(): string {
const octet = () => 1 + Math.floor(Math.random() * 253);
return `${octet()}.${octet()}.${octet()}.${octet()}`;
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Wartet etwas mehr als AUTH-CFG-08 (1 s), damit Folgemails nicht an 429 scheitern. */
export const waitMailInterval = () => sleep(authConfig.maxFrequencySeconds * 1000 + 300);
export interface GoTrueResponse<T = any> {
status: number;
body: T;
headers: Headers;
/** Dauer in Millisekunden. */
ms: number;
}
export interface ClientOptions {
/** Basis-URL inkl. `/auth/v1` (Standard: Kong). */
baseUrl?: string;
/** Beim Zugriff ueber Kong noetig, direkt auf GoTrue nicht. */
apikey?: string | null;
/** Zusatz-Header, z. B. `X-Forwarded-For` (nur `throttle`). */
headers?: Record<string, string>;
}
/** Minimaler GoTrue-Client (REST, wie `supabase_flutter`). */
export class GoTrueClient {
readonly baseUrl: string;
private readonly apikey: string | null;
private readonly extraHeaders: Record<string, string>;
constructor(opts: ClientOptions = {}) {
this.baseUrl = opts.baseUrl ?? `${env.supabaseUrl}/auth/v1`;
this.apikey = opts.apikey === undefined ? env.anonKey : opts.apikey;
this.extraHeaders = opts.headers ?? {};
}
async request<T = any>(
method: string,
path: string,
body?: unknown,
token?: string,
extra: Record<string, string> = {},
): Promise<GoTrueResponse<T>> {
const headers: Record<string, string> = { 'content-type': 'application/json', ...this.extraHeaders, ...extra };
if (this.apikey) headers.apikey = this.apikey;
if (token) headers.authorization = `Bearer ${token}`;
const started = Date.now();
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let parsed: any = text;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
/* Body ist kein JSON */
}
return { status: res.status, body: parsed, headers: res.headers, ms: Date.now() - started };
}
signUp(email: string, password: string, data: Record<string, unknown> | null = validMetadata()) {
return this.request('POST', '/signup', { email, password, ...(data ? { data } : {}) });
}
signIn(email: string, password: string) {
return this.request('POST', '/token?grant_type=password', { email, password });
}
refresh(refreshToken: string) {
return this.request('POST', '/token?grant_type=refresh_token', { refresh_token: refreshToken });
}
recover(email: string) {
return this.request('POST', '/recover', { email });
}
resend(email: string, type: 'signup' = 'signup') {
return this.request('POST', '/resend', { email, type });
}
/** `verifyOTP(tokenHash, type)`. */
verify(tokenHash: string, type: 'email' | 'signup' | 'recovery') {
return this.request('POST', '/verify', { token_hash: tokenHash, type });
}
getUser(accessToken: string) {
return this.request('GET', '/user', undefined, accessToken);
}
updateUser(accessToken: string, attributes: Record<string, unknown>) {
return this.request('PUT', '/user', attributes, accessToken);
}
/** Legt ein bestaetigtes Konto direkt an (Service-Role, nur lokal), fuer Tests, die die Anmeldung pruefen. */
async createConfirmedUser(
email: string,
password: string,
data: Record<string, unknown> = validMetadata(),
) {
return this.request(
'POST',
'/admin/users',
{ email, password, email_confirm: true, user_metadata: data },
env.serviceRoleKey,
);
}
}
/** Gueltige Registrierungs-Metadaten (Spec 4.2). */
export function validMetadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const data: Record<string, unknown> = {
display_name: 'E2E Tester',
age_confirmed: true,
terms_accepted: true,
terms_version: TERMS_VERSION,
locale: 'en',
...overrides,
};
for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
return data;
}
export const uniqueId = () => randomUUID();

80
e2e/support/mailpit.ts Normal file
View file

@ -0,0 +1,80 @@
import { env } from './auth-config';
import { sleep } from './gotrue';
export interface MailSummary {
ID: string;
Subject: string;
To: { Address: string }[];
Created: string;
}
export interface Mail extends MailSummary {
html: string;
text: string;
}
async function getJson(path: string): Promise<any> {
const res = await fetch(`${env.mailpitUrl}/api/v1${path}`);
if (!res.ok) throw new Error(`Mailpit ${path}: HTTP ${res.status}`);
return res.json();
}
/** Alle Mails an eine Adresse (Suche per `to:`; nie global loeschen, Tests laufen parallel). */
export async function mailsTo(address: string): Promise<MailSummary[]> {
const result = await getJson(`/search?query=${encodeURIComponent(`to:"${address}"`)}&limit=50`);
return (result.messages ?? []) as MailSummary[];
}
export async function countMails(address: string): Promise<number> {
return (await mailsTo(address)).length;
}
export async function readMail(id: string): Promise<Mail> {
const m = await getJson(`/message/${id}`);
return { ID: m.ID, Subject: m.Subject, To: m.To, Created: m.Date ?? '', html: m.HTML ?? '', text: m.Text ?? '' };
}
/**
* Wartet, bis mindestens `count` Mails an die Adresse vorliegen, und liefert die neueste.
* Faellt nach `timeoutMs` mit einer aussagekraeftigen Meldung durch.
*/
export async function waitForMail(address: string, count = 1, timeoutMs = 10_000): Promise<Mail> {
const deadline = Date.now() + timeoutMs;
let found: MailSummary[] = [];
while (Date.now() < deadline) {
found = await mailsTo(address);
if (found.length >= count) {
// Mailpit liefert die neueste zuerst.
return readMail(found[0].ID);
}
await sleep(200);
}
throw new Error(`Keine ${count}. Mail an ${address} nach ${timeoutMs} ms (vorhanden: ${found.length})`);
}
/**
* Belegt „es kommt keine (weitere) Mail": wartet ein festes Fenster und zaehlt dann.
* 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);
const actual = await countMails(address);
if (actual !== expected) throw new Error(`Mails an ${address}: erwartet ${expected}, vorhanden ${actual}`);
return actual;
}
/** Link aus der Mail: `.../auth/confirm?token_hash=...&type=email|recovery` (HTML-Entities decodiert). */
export function extractConfirmLink(mail: Mail): URL {
const decoded = mail.html.replace(/&amp;/g, '&');
const match = decoded.match(/https?:\/\/[^"'\s<]+\/auth\/confirm\?[^"'\s<]+/);
if (!match) throw new Error(`Kein Bestaetigungslink in der Mail "${mail.Subject}"`);
return new URL(match[0]);
}
export function extractTokenHash(mail: Mail): { tokenHash: string; type: string; link: URL } {
const link = extractConfirmLink(mail);
const tokenHash = link.searchParams.get('token_hash');
const type = link.searchParams.get('type');
if (!tokenHash || !type) throw new Error('token_hash oder type fehlen im Link');
return { tokenHash, type, link };
}

View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Startet einen ZWEITEN GoTrue-Container neben der Supabase CLI (Spec #1 D2/T3, F11).
#
# Profile
# throttle (Standard) AUTH-CFG-13: GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For, Port 9998.
# Fuer das Playwright-Projekt `throttle` (THROTTLE_GOTRUE_URL=http://127.0.0.1:9998).
# smtp-down SMTP-Spike (Issue #37): wie die CLI, aber SMTP-Port 1 (Verbindung abgelehnt), Port 9997.
# Fuer das Playwright-Projekt `spike` (SPIKE_GOTRUE_URL=http://127.0.0.1:9997).
#
# Aufruf
# e2e/support/start-throttle-gotrue.sh start [throttle|smtp-down]
# e2e/support/start-throttle-gotrue.sh stop [throttle|smtp-down]
#
# Der Container nutzt dasselbe Image, dieselbe DB und dasselbe Docker-Netz wie der Auth-Container
# der CLI; die Umgebung wird per `docker inspect` uebernommen (Version nicht hart kodiert). Die
# Rate-Limit-Buckets liegen im Speicher und sind damit von der CLI getrennt (R13).
# Nur lokal/CI benutzen: Die kopierte Umgebung enthaelt die lokalen Demo-Secrets der CLI.
set -euo pipefail
ACTION="${1:-start}"
PROFILE="${2:-throttle}"
SOURCE="${SOURCE_CONTAINER:-$(docker ps --format '{{.Names}}' | grep -E '^supabase_auth_' | head -n1 || true)}"
case "$PROFILE" in
throttle) NAME="${CONTAINER_NAME:-shuffle_auth_throttle}"; HOST_PORT="${HOST_PORT:-9998}" ;;
smtp-down) NAME="${CONTAINER_NAME:-shuffle_auth_smtp_down}"; HOST_PORT="${HOST_PORT:-9997}" ;;
*) echo "Unbekanntes Profil: $PROFILE (throttle|smtp-down)" >&2; exit 2 ;;
esac
if [ "$ACTION" = "stop" ]; then
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "Container $NAME entfernt."
exit 0
fi
[ "$ACTION" = "start" ] || { echo "Aktion: start|stop" >&2; exit 2; }
if [ -z "$SOURCE" ]; then
echo "Kein laufender supabase_auth_*-Container gefunden. Zuerst 'supabase start'." >&2
exit 1
fi
IMAGE="$(docker inspect "$SOURCE" --format '{{.Config.Image}}')"
NETWORK="$(docker inspect "$SOURCE" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')"
docker rm -f "$NAME" >/dev/null 2>&1 || true
ENV_ARGS=()
while IFS= read -r -d '' entry; do
case "$entry" in
GOTRUE_RATE_LIMIT_HEADER=*) continue ;; # wird unten gezielt gesetzt
GOTRUE_SMTP_PORT=*) [ "$PROFILE" = "smtp-down" ] && continue ;;
esac
ENV_ARGS+=(-e "$entry")
done < <(docker inspect "$SOURCE" --format '{{json .Config.Env}}' | jq -j '.[] + "\u0000"')
case "$PROFILE" in
throttle) ENV_ARGS+=(-e "GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For") ;;
smtp-down) ENV_ARGS+=(-e "GOTRUE_SMTP_PORT=1") ;; # Host bleibt Mailpit, Port 1 lehnt die Verbindung ab
esac
docker run -d --name "$NAME" --network "$NETWORK" -p "127.0.0.1:${HOST_PORT}:9999" \
"${ENV_ARGS[@]}" "$IMAGE" auth >/dev/null
for _ in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then
echo "Container $NAME ($IMAGE, Profil $PROFILE) laeuft auf http://127.0.0.1:${HOST_PORT}"
exit 0
fi
sleep 0.5
done
echo "Container $NAME wurde nicht gesund. Log:" >&2
docker logs --tail 30 "$NAME" >&2
exit 1

View file

@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { authConfig } from '../../support/auth-config';
const root = resolve(__dirname, '../../..');
const toml = readFileSync(resolve(root, 'supabase/config.toml'), 'utf8');
const doc = readFileSync(resolve(root, 'docs/auth-konfiguration.md'), 'utf8');
/** Aktive (nicht auskommentierte) Zeilen von `config.toml`. */
const active = toml
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
function value(key: string, section?: string): string | undefined {
let current = '';
for (const line of active) {
const sec = line.match(/^\[(.+)\]$/);
if (sec) {
current = sec[1];
continue;
}
if (section !== undefined && current !== section) continue;
const kv = line.match(new RegExp(`^${key}\\s*=\\s*(.+)$`));
if (kv) return kv[1].replace(/^"|"$/g, '');
}
return undefined;
}
test.describe('Auth-Konfiguration: auth-config.ts stimmt mit config.toml und Doku ueberein', () => {
test('AUTH-CFG-01: Site-URL', () => {
expect(value('site_url', 'auth')).toBe(authConfig.siteUrl);
});
test('AUTH-CFG-04: E-Mail-Bestaetigung Pflicht', () => {
expect(value('enable_confirmations', 'auth.email')).toBe('true');
});
test('AUTH-CFG-05/-06: Passwort-Mindestlaenge und keine Zeichenklassen', () => {
expect(Number(value('minimum_password_length', 'auth'))).toBe(authConfig.passwordMinLength);
expect(value('password_requirements', 'auth')).toBe('');
});
test('AUTH-CFG-07: Gueltigkeit der Links', () => {
expect(Number(value('otp_expiry', 'auth.email'))).toBe(authConfig.otpExpirySeconds);
});
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', () => {
expect(Number(value('sign_in_sign_ups', 'auth.rate_limit'))).toBe(authConfig.otpBurst);
});
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');
expect(Number(value('refresh_token_reuse_interval', 'auth'))).toBe(authConfig.refreshTokenReuseIntervalSeconds);
});
test('KTO-01 Kriterium 5: AUTH-CFG-16 Sitzungsbegrenzung ist nicht gesetzt (timebox, inactivity_timeout)', () => {
expect(active.some((l) => /^timebox\s*=/.test(l))).toBe(false);
expect(active.some((l) => /^inactivity_timeout\s*=/.test(l))).toBe(false);
expect(active).not.toContain('[auth.sessions]');
});
test('AUTH-CFG-19: alle Benachrichtigungs-Mails aus', () => {
const sections = active.filter((l) => l.startsWith('[auth.email.notification.'));
expect(sections.length).toBeGreaterThan(0);
for (const s of sections) {
expect(value('enabled', s.slice(1, -1)), s).toBe('false');
}
});
test('AUTH-CFG-24: SMS-Autoconfirm aus (enable_confirmations = true), Voraussetzung fuer den Enumerationsschutz', () => {
expect(value('enable_confirmations', 'auth.sms')).toBe('true');
});
test('docs/auth-konfiguration.md fuehrt AUTH-CFG-01 bis -25', () => {
for (let i = 1; i <= 25; i++) {
const id = `AUTH-CFG-${String(i).padStart(2, '0')}`;
expect(doc, id).toContain(`| ${id} |`);
}
});
});

View file

@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { subjects, TERMS_VERSION } from '../../support/auth-config';
import { profileByEmail, userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 1: Registrierung und Bestaetigung (api)', () => {
test('KTO-01 Kriterium 1: Signup legt Nutzer und Profil an, Sitzung bleibt aus, Bestaetigungsmail im Faenger', async () => {
const email = randomEmail();
const res = await gotrue.signUp(
email,
randomPassword(),
validMetadata({ display_name: ' Anna Beispiel ', locale: 'de' }),
);
expect(res.status).toBe(200);
expect(res.body.id).toBeTruthy();
expect(res.body.session ?? res.body.access_token ?? null).toBeNull();
expect(res.body.email_confirmed_at ?? null).toBeNull();
const profile = await profileByEmail(email);
expect(profile).toBeTruthy();
expect(profile.display_name).toBe('Anna Beispiel');
expect(profile.locale).toBe('de');
expect(profile.terms_version).toBe(TERMS_VERSION);
expect(profile.terms_accepted_at).toBeTruthy();
expect(profile.age_confirmed_at).toBeTruthy();
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.de);
const { type, link } = extractTokenHash(mail);
expect(type).toBe('email');
// AUTH-CFG-01: Link baut auf der Site-URL auf.
expect(link.origin).toBe('http://localhost:3000');
expect(link.pathname).toBe('/auth/confirm');
});
test('KTO-01 Kriterium 1: Link einloesen bestaetigt die Adresse, danach ist die Anmeldung moeglich', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const verified = await gotrue.verify(tokenHash, 'email');
expect(verified.status).toBe(200);
expect(verified.body.access_token).toBeTruthy();
expect(verified.body.user.email_confirmed_at).toBeTruthy();
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(200);
expect(login.body.access_token).toBeTruthy();
expect((await userByEmail(email)).email_confirmed_at).toBeTruthy();
});
test('KTO-01 Kriterium 1: ohne Bestaetigung kein Zugang (AUTH-CFG-04)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(400);
expect(login.body.error_code).toBe('email_not_confirmed');
});
});

View file

@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { authConfig, triggerErrors } from '../../support/auth-config';
import { userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => {
test('KTO-01 Kriterium 2: Signup ohne age_confirmed -> 500 age_confirmation_required, kein Nutzer', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
expect(await userByEmail(email)).toBeUndefined();
});
test('KTO-01 Kriterium 2: age_confirmed=false wird ebenfalls abgelehnt', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: false }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
expect(await userByEmail(email)).toBeUndefined();
});
});
test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (api)', () => {
test('QUE-04 Kriterium 1: Signup ohne terms_accepted -> terms_acceptance_required', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired);
expect(await userByEmail(email)).toBeUndefined();
});
test('QUE-04 Kriterium 1: Signup ohne terms_version -> terms_version_required', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsVersionRequired);
expect(await userByEmail(email)).toBeUndefined();
});
});
test.describe('KTO-01 Kriterium 3: Passwortregeln (api)', () => {
test(`KTO-01 Kriterium 3: AUTH-CFG-05 ${authConfig.passwordMinLength - 1} Zeichen -> 422 weak_password (length)`, async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, 'a'.repeat(authConfig.passwordMinLength - 1));
expect(res.status).toBe(422);
expect(res.body.error_code).toBe('weak_password');
expect(res.body.weak_password?.reasons).toContain('length');
expect(await userByEmail(email)).toBeUndefined();
});
test(`KTO-01 Kriterium 3: AUTH-CFG-05/-06 genau ${authConfig.passwordMinLength} Zeichen ohne Zeichenklassen werden akzeptiert`, async () => {
const res = await gotrue.signUp(randomEmail(), 'a'.repeat(authConfig.passwordMinLength));
expect(res.status).toBe(200);
});
});

View file

@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
import { countUsers, profileByEmail, userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
import { countMails, expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => {
/**
* Regressionstest fuer AUTH-CFG-04 (MAILER_AUTOCONFIRM=false) UND AUTH-CFG-24 (SMS_AUTOCONFIRM=false).
* Mit SMS_AUTOCONFIRM=true (CLI-Standard, Self-Hosting-Vorlage) kaeme hier 422 user_already_exists.
*/
test('KTO-01 Kriterium 4: AUTH-CFG-04/-24 Signup mit bestaetigter Adresse -> 200 (nicht 422), identities leer, erfundener Nutzer, keine Mail, altes Passwort gilt', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const created = await gotrue.signUp(email, oldPassword);
const realId = created.body.id;
const { tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
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' }));
expect(again.status, 'nicht 422 user_already_exists (AUTH-CFG-24)').toBe(200);
expect(again.body.error_code).toBeUndefined();
expect(again.body.identities ?? []).toEqual([]);
expect(again.body.id).toBeTruthy();
expect(again.body.id).not.toBe(realId);
expect(await countUsers()).toBe(usersBefore);
// Keine (weitere) Mail an die Adresse: nach festem Fenster weiterhin nur die erste Bestaetigungsmail.
await expectMailCount(email, 1);
// Das urspruengliche Passwort gilt weiter, das neue nicht; das Profil bleibt unveraendert.
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
const wrong = await gotrue.signIn(email, newPassword);
expect(wrong.status).toBe(400);
expect(wrong.body.error_code).toBe('invalid_credentials');
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
});
test('KTO-01 Kriterium 4: Signup mit unbestaetigter Adresse -> 200, Mail erneut an das bestehende Konto, Passwort und Profil unveraendert', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const first = await gotrue.signUp(email, oldPassword);
await waitForMail(email);
await waitMailInterval();
const newPassword = randomPassword();
const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' }));
expect(again.status).toBe(200);
expect(again.body.id).toBe(first.body.id);
// Die Bestaetigungsmail geht erneut an das bestehende Konto.
const second = await waitForMail(email, 2);
const { tokenHash } = extractTokenHash(second);
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
expect((await gotrue.signIn(email, newPassword)).body.error_code).toBe('invalid_credentials');
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
expect((await userByEmail(email)).id).toBe(first.body.id);
});
test('KTO-01 Kriterium 4: Reset fuer unbekannte Adresse -> 200, keine Mail; fuer bekannte Adresse 200 mit Mail', async () => {
const unknown = randomEmail('unknown');
const res = await gotrue.recover(unknown);
expect(res.status).toBe(200);
await expectMailCount(unknown, 0);
const known = randomEmail();
await gotrue.createConfirmedUser(known, randomPassword());
const ok = await gotrue.recover(known);
expect(ok.status).toBe(200);
expect(ok.body).toEqual(res.body);
await waitForMail(known);
});
});

View file

@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { authConfig } from '../../support/auth-config';
import { sessionNotAfter } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 5: Sitzung bleibt bestehen (api)', () => {
test('KTO-01 Kriterium 5: AUTH-CFG-14/-16 Sitzung ohne Zeitbegrenzung, JWT-Laufzeit 3600 s', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(200);
expect(login.body.expires_in).toBe(authConfig.jwtExpirySeconds);
// AUTH-CFG-16: weder timebox noch inactivity_timeout -> not_after bleibt leer.
expect(await sessionNotAfter(email)).toEqual([null]);
});
test('KTO-01 Kriterium 5: AUTH-CFG-15 Refresh liefert neue Tokens, die Sitzung bleibt gueltig', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const login = await gotrue.signIn(email, password);
const refreshed = await gotrue.refresh(login.body.refresh_token);
expect(refreshed.status).toBe(200);
expect(refreshed.body.refresh_token).not.toBe(login.body.refresh_token);
expect((await gotrue.getUser(refreshed.body.access_token)).status).toBe(200);
});
});

View file

@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 6: unbestaetigtes Konto (api)', () => {
test('KTO-01 Kriterium 6: richtiges Passwort -> email_not_confirmed, falsches Passwort -> invalid_credentials', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const right = await gotrue.signIn(email, password);
expect(right.status).toBe(400);
expect(right.body.error_code).toBe('email_not_confirmed');
const wrong = await gotrue.signIn(email, `${password}-falsch`);
expect(wrong.status).toBe(400);
expect(wrong.body.error_code).toBe('invalid_credentials');
});
test('KTO-01 Kriterium 6: erneut senden -> zweite Mail, deren Link das Konto bestaetigt (AUTH-CFG-08: lokal 1 s)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
await waitForMail(email);
await waitMailInterval();
const resend = await gotrue.resend(email);
expect(resend.status).toBe(200);
const second = await waitForMail(email, 2);
const { tokenHash } = extractTokenHash(second);
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
expect((await gotrue.signIn(email, password)).status).toBe(200);
});
test('KTO-01 Kriterium 6: erneut senden fuer unbekannte Adresse -> 200, keine Mail (keine Enumeration)', async () => {
const unknown = randomEmail('unknown');
const res = await gotrue.resend(unknown);
expect(res.status).toBe(200);
await expectMailCount(unknown, 0);
});
});
test.describe('KTO-01 Kriterium 7: keine Auskunft ueber Konten (api)', () => {
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort liefern identische Antworten', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const wrongPassword = await gotrue.signIn(email, `${password}-falsch`);
const wrongEmail = await gotrue.signIn(randomEmail('unknown'), password);
expect(wrongPassword.status).toBe(400);
expect(wrongEmail.status).toBe(wrongPassword.status);
expect(wrongEmail.body.error_code).toBe('invalid_credentials');
expect(wrongEmail.body).toEqual(wrongPassword.body);
});
});

View file

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import { authConfig, errorCodes } from '../../support/auth-config';
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
async function confirmedUser() {
const email = randomEmail();
const password = randomPassword();
const created = await gotrue.createConfirmedUser(email, password);
expect(created.status).toBe(200);
return { email, password };
}
test.describe('KTO-01 Kriterium 8: Passwort-Reset-Link (api)', () => {
test('KTO-01 Kriterium 8: Reset anfordern -> Mail mit type=recovery -> einloesen ok -> zweites Einloesen 403 otp_expired', async () => {
const { email } = await confirmedUser();
expect((await gotrue.recover(email)).status).toBe(200);
const { tokenHash, type } = extractTokenHash(await waitForMail(email));
expect(type).toBe('recovery');
const first = await gotrue.verify(tokenHash, 'recovery');
expect(first.status).toBe(200);
expect(first.body.access_token).toBeTruthy();
const second = await gotrue.verify(tokenHash, 'recovery');
expect(second.status).toBe(403);
expect(second.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: neue Reset-Anforderung ersetzt den alten Link', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const oldLink = extractTokenHash(await waitForMail(email));
await waitMailInterval();
await gotrue.recover(email);
const newLink = extractTokenHash(await waitForMail(email, 2));
expect(newLink.tokenHash).not.toBe(oldLink.tokenHash);
const old = await gotrue.verify(oldLink.tokenHash, 'recovery');
expect(old.status).toBe(403);
expect(old.body.error_code).toBe(errorCodes.otpExpired);
expect((await gotrue.verify(newLink.tokenHash, 'recovery')).status).toBe(200);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Reset-Link ist nach 1 h + 1 s abgelaufen (recovery_sent_at zurueckgesetzt)', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email);
const res = await gotrue.verify(tokenHash, 'recovery');
expect(res.status).toBe(403);
expect(res.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Grenze: Reset-Link kurz vor Ablauf ist noch gueltig', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email, authConfig.otpExpirySeconds - 60);
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Bestaetigungslink ist nach 1 h + 1 s abgelaufen (confirmation_sent_at zurueckgesetzt)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireConfirmationLink(email);
const res = await gotrue.verify(tokenHash, 'email');
expect(res.status).toBe(403);
expect(res.body.error_code).toBe(errorCodes.otpExpired);
// Konto bleibt unbestaetigt.
expect((await gotrue.signIn(email, password)).body.error_code).toBe(errorCodes.emailNotConfirmed);
});
test('KTO-01 Kriterium 8: Bestaetigungslink ist einmalig', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword());
const { tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
const again = await gotrue.verify(tokenHash, 'email');
expect(again.status).toBe(403);
expect(again.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: Reset fuer unbekannte Adresse -> 200, keine Mail', async () => {
const unknown = randomEmail('unknown');
expect((await gotrue.recover(unknown)).status).toBe(200);
await expectMailCount(unknown, 0);
});
test('KTO-01 Kriterium 8: nach dem Setzen des neuen Passworts ist der Reset-Link verbraucht', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const session = await gotrue.verify(tokenHash, 'recovery');
const update = await gotrue.updateUser(session.body.access_token, { password: randomPassword() });
expect(update.status).toBe(200);
expect((await gotrue.verify(tokenHash, 'recovery')).body.error_code).toBe(errorCodes.otpExpired);
});
});

View file

@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';
import { env, errorCodes } from '../../support/auth-config';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
async function resetSession(email: string) {
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const res = await gotrue.verify(tokenHash, 'recovery');
expect(res.status).toBe(200);
return res.body.access_token as string;
}
test.describe('KTO-01 Kriterium 9: Passwort aendern beendet andere Sitzungen (api)', () => {
test('KTO-01 Kriterium 9: nach Reset sind die Refresh-Tokens anderer Sitzungen ungueltig, die Reset-Sitzung bleibt', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const newPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
const a = (await gotrue.signIn(email, oldPassword)).body;
const b = (await gotrue.signIn(email, oldPassword)).body;
expect(a.refresh_token).not.toBe(b.refresh_token);
const recoveryToken = await resetSession(email);
const update = await gotrue.updateUser(recoveryToken, { password: newPassword });
expect(update.status).toBe(200);
for (const [name, session] of [['A', a], ['B', b]] as const) {
const refreshed = await gotrue.refresh(session.refresh_token);
expect(refreshed.status, `Sitzung ${name}`).toBeGreaterThanOrEqual(400);
expect(['refresh_token_not_found', 'session_not_found']).toContain(
refreshed.body.error_code,
);
}
// Neues Passwort gilt, altes nicht.
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe(errorCodes.invalidCredentials);
});
test('KTO-01 Kriterium 9 / R8: GoTrue /user lehnt das Access-Token der beendeten Sitzung ab (PostgREST nicht: bis zu 60 min, F1)', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
const a = (await gotrue.signIn(email, oldPassword)).body;
const recoveryToken = await resetSession(email);
await gotrue.updateUser(recoveryToken, { password: randomPassword() });
const user = await gotrue.getUser(a.access_token);
expect([401, 403]).toContain(user.status);
// Dokumentiert das akzeptierte Restrisiko R8 (AUTH-CFG-14): PostgREST prueft keine Sitzungen.
const rest = await fetch(`${env.supabaseUrl}/rest/v1/profiles?select=id`, {
headers: { apikey: env.anonKey, authorization: `Bearer ${a.access_token}` },
});
expect(rest.status).toBe(200);
});
test('KTO-01 Kriterium 9: gleiches Passwort -> 422 same_password, zu kurzes -> 422 weak_password', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const token = await resetSession(email);
const same = await gotrue.updateUser(token, { password });
expect(same.status).toBe(422);
expect(same.body.error_code).toBe(errorCodes.samePassword);
const weak = await gotrue.updateUser(token, { password: 'kurz' });
expect(weak.status).toBe(422);
expect(weak.body.error_code).toBe(errorCodes.weakPassword);
});
});

View file

@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { authConfig, triggerErrors } from '../../support/auth-config';
import { profileByEmail, userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 10: Anzeigename (api)', () => {
test('KTO-01 Kriterium 10: 31 Zeichen -> HTTP 500, Body {"code":"P0001","message":"display_name_invalid"}, kein Nutzer', async () => {
const email = randomEmail();
const res = await gotrue.signUp(
email,
randomPassword(),
validMetadata({ display_name: 'a'.repeat(authConfig.displayNameMaxLength + 1) }),
);
// Format aus Spike B0 (f), Mapping-Regel 4.4: maßgeblich ist HTTP 500 beim signUp.
expect(res.status).toBe(500);
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.displayNameInvalid });
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
expect(await userByEmail(email)).toBeUndefined();
});
for (const [label, value] of [
['fehlend', undefined],
['leer', ''],
['nur Leerzeichen', ' '],
] as const) {
test(`KTO-01 Kriterium 10: Anzeigename ${label} -> display_name_invalid`, async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: value }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.displayNameInvalid);
expect(await userByEmail(email)).toBeUndefined();
});
}
test('KTO-01 Kriterium 10: genau 30 Zeichen werden akzeptiert', async () => {
const email = randomEmail();
const name = 'b'.repeat(authConfig.displayNameMaxLength);
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: name }));
expect(res.status).toBe(200);
expect((await profileByEmail(email)).display_name).toBe(name);
});
test('KTO-01 Kriterium 10: Codepoints zaehlen, nicht UTF-16-Einheiten (30 Emoji ok, 31 abgelehnt)', async () => {
const ok = randomEmail();
const accepted = await gotrue.signUp(ok, randomPassword(), validMetadata({ display_name: '😀'.repeat(30) }));
expect(accepted.status).toBe(200);
expect([...(await profileByEmail(ok)).display_name]).toHaveLength(30);
const bad = randomEmail();
const rejected = await gotrue.signUp(bad, randomPassword(), validMetadata({ display_name: '😀'.repeat(31) }));
expect(rejected.status).toBe(500);
expect(rejected.body.message).toBe(triggerErrors.displayNameInvalid);
});
test('KTO-01 Kriterium 10: Anzeigename wird getrimmt gespeichert; 30 Zeichen plus Rand-Leerzeichen sind zulaessig', async () => {
const email = randomEmail();
const name = 'c'.repeat(authConfig.displayNameMaxLength);
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: ` ${name} ` }));
expect(res.status).toBe(200);
expect((await profileByEmail(email)).display_name).toBe(name);
});
});

View file

@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { env, subjects } from '../../support/auth-config';
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 11: Mails nur in der Profilsprache, nur die zwei Mailarten (api)', () => {
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=de -> deutsche Bestaetigungsmail', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'de' }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.de);
expect(mail.html).toContain('<html lang="de">');
});
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=en -> englische Bestaetigungsmail', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'en' }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
expect(mail.html).toContain('<html lang="en">');
});
test('KTO-01 Kriterium 11: ohne locale -> Englisch (F6)', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: undefined }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
});
test('KTO-01 Kriterium 11: AUTH-CFG-18 Profilsprache auf en aendern -> Reset-Mail englisch; vorher deutsch', async () => {
const email = randomEmail();
const password = randomPassword();
const created = await gotrue.createConfirmedUser(email, password, validMetadata({ locale: 'de' }));
const userId = created.body.id as string;
await gotrue.recover(email);
const german = await waitForMail(email);
expect(german.Subject).toBe(subjects.recovery.de);
expect(german.html).toContain('<html lang="de">');
expect(extractTokenHash(german).type).toBe('recovery');
// Profilsprache wie die App ueber PostgREST mit dem Token des Nutzers aendern (Sync-Trigger -> user_metadata).
const token = (await gotrue.signIn(email, password)).body.access_token as string;
const patch = await fetch(`${env.supabaseUrl}/rest/v1/profiles?id=eq.${userId}`, {
method: 'PATCH',
headers: {
apikey: env.anonKey,
authorization: `Bearer ${token}`,
'content-type': 'application/json',
prefer: 'return=representation',
},
body: JSON.stringify({ locale: 'en' }),
});
expect(patch.status).toBe(200);
expect((await patch.json())[0].locale).toBe('en');
await waitMailInterval();
await gotrue.recover(email);
const english = await waitForMail(email, 2);
expect(english.Subject).toBe(subjects.recovery.en);
expect(english.html).toContain('<html lang="en">');
});
test('KTO-01 Kriterium 11: AUTH-CFG-19 nach Reset und Login keine weitere Mail (Benachrichtigungen aus)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const session = await gotrue.verify(tokenHash, 'recovery');
const newPassword = randomPassword();
expect((await gotrue.updateUser(session.body.access_token, { password: newPassword })).status).toBe(200);
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
// Genau die eine Reset-Mail; keine password_changed-Benachrichtigung.
await expectMailCount(email, 1);
});
});

13
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["support", "tests", "playwright.config.ts"]
}