test(auth): KTO-01 Playwright-E2E (api, web, throttle) und CI-Job e2e #42
19 changed files with 2536 additions and 0 deletions
1310
e2e/package-lock.json
generated
Normal file
1310
e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
e2e/package.json
Normal file
21
e2e/package.json
Normal 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
52
e2e/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
82
e2e/support/auth-config.ts
Normal file
82
e2e/support/auth-config.ts
Normal 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
78
e2e/support/db.ts
Normal 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
134
e2e/support/gotrue.ts
Normal 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
80
e2e/support/mailpit.ts
Normal 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(/&/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 };
|
||||
}
|
||||
72
e2e/support/start-throttle-gotrue.sh
Executable file
72
e2e/support/start-throttle-gotrue.sh
Executable 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
|
||||
77
e2e/tests/api/config.spec.ts
Normal file
77
e2e/tests/api/config.spec.ts
Normal 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} |`);
|
||||
}
|
||||
});
|
||||
});
|
||||
64
e2e/tests/api/krit01-registrierung.spec.ts
Normal file
64
e2e/tests/api/krit01-registrierung.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
58
e2e/tests/api/krit02-03-validierung.spec.ts
Normal file
58
e2e/tests/api/krit02-03-validierung.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
e2e/tests/api/krit04-enumeration.spec.ts
Normal file
80
e2e/tests/api/krit04-enumeration.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
e2e/tests/api/krit05-sitzung.spec.ts
Normal file
30
e2e/tests/api/krit05-sitzung.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
e2e/tests/api/krit06-07-anmeldung.spec.ts
Normal file
58
e2e/tests/api/krit06-07-anmeldung.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
e2e/tests/api/krit08-reset-link.spec.ts
Normal file
106
e2e/tests/api/krit08-reset-link.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
e2e/tests/api/krit09-abmeldung.spec.ts
Normal file
76
e2e/tests/api/krit09-abmeldung.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
e2e/tests/api/krit10-anzeigename.spec.ts
Normal file
64
e2e/tests/api/krit10-anzeigename.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts
Normal file
81
e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts
Normal 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
13
e2e/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue