test(auth): add web E2E tests for KTO-01 criteria 1-10 against the Flutter web build

Refs #38

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 10:42:28 +01:00
parent 2542679797
commit d390b60bd6
7 changed files with 584 additions and 0 deletions

155
e2e/support/web.ts Normal file
View file

@ -0,0 +1,155 @@
/**
* Helfer fuer das Projekt `web` (Flutter-Web-Build, Port 3000).
*
* Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum
* eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). Locator-Namen stammen aus
* `app/lib/l10n/app_en.arb` (Browser-Sprache en-US, siehe playwright.config.ts).
*/
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
import { TERMS_VERSION } from './auth-config';
export { TERMS_VERSION };
/** Texte (app_en.arb). */
export const t = {
invalidCredentials: 'Email address or password is incorrect.',
emailNotConfirmed: 'Please confirm your email address first. We sent you an email for this.',
resendConfirmation: 'Resend confirmation email',
rateLimited: 'Too many attempts. Please wait a few minutes and try again.',
linkInvalid: 'The link has expired or has already been used.',
checkEmailTitle: 'Check your inbox',
checkEmailResent: 'We resent the email, if there is something to send for this address.',
ageNotConfirmed: 'Shuffle can only be used from age 16.',
termsNotAccepted: 'Please agree to the privacy policy and the terms of use.',
confirmSuccess: 'Email confirmed.',
passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.',
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
} as const;
/**
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
*/
export function textOf(page: Page, text: string | RegExp): Locator {
return page.getByText(text).or(page.getByLabel(text)).first();
}
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
export async function enableSemantics(page: Page): Promise<void> {
const placeholder = page.locator('flt-semantics-placeholder');
await placeholder.waitFor({ state: 'attached' });
await placeholder.evaluate((el: HTMLElement) => el.click());
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
}
/**
* Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur.
*
* BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu
* EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf
* die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer
* auf `getByRole('button', ...)` umgestellt werden.
*/
export async function pressFirstButton(page: Page, tabs = 1): Promise<void> {
for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
}
/** Navigiert und schaltet Semantics ein. */
export async function open(page: Page, path: string): Promise<void> {
await page.goto(path);
await enableSemantics(page);
}
/**
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
* (mit Wiederholung).
*/
export async function fillField(field: Locator, value: string): Promise<void> {
await expect(async () => {
await field.click();
await field.fill('');
await field.pressSequentially(value);
await expect(field).toHaveValue(value, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
export async function fillRegistration(
page: Page,
v: { email: string; password: string; displayName?: string; age?: boolean; terms?: boolean },
): Promise<void> {
await fillField(page.getByRole('textbox', { name: 'Email address' }), v.email);
await fillField(page.getByRole('textbox', { name: 'Password' }), v.password);
await fillField(page.getByRole('textbox', { name: 'Display name' }), v.displayName ?? 'E2E Tester');
if (v.age ?? true) await page.getByRole('checkbox', { name: /at least 16 years old/ }).click();
if (v.terms ?? true) await page.getByRole('checkbox', { name: /privacy policy/ }).click();
}
export async function register(page: Page, v: Parameters<typeof fillRegistration>[1]): Promise<void> {
await open(page, '/register');
await fillRegistration(page, v);
await page.getByRole('button', { name: 'Create account' }).click();
}
export async function login(page: Page, email: string, password: string): Promise<void> {
await open(page, '/login');
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
await fillField(page.getByRole('textbox', { name: 'Password' }), password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
}
export async function expectSignedIn(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/$/);
await expect(textOf(page, t.home)).toBeVisible();
}
/**
* Angemeldet <=> `/login` leitet weg (Guest-only-Route, Spec 5.3). Prueft ohne Fixture-Annahmen
* ueber den Storage-Key.
*/
export async function expectSignedInViaLoginRedirect(page: Page): Promise<void> {
await page.goto('/login');
await expect(page).toHaveURL(/\/$/);
}
/** `localStorage`-Eintraege von supabase_flutter im Web (Schluessel nicht raten, auslesen). */
export async function readAuthStorage(page: Page): Promise<Record<string, string>> {
return page.evaluate(() => {
const out: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)!;
if (k.includes('supabase.auth.token') || /sb-.*-auth-token/.test(k)) out[k] = localStorage.getItem(k)!;
}
return out;
});
}
/** Neue Seite in einem frischen Kontext mit denselben Browser-Optionen. */
export async function freshContext(context: BrowserContext): Promise<BrowserContext> {
return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' });
}
/**
* Verschiebt die Browser-Uhr ab dem naechsten Seitenladen um `offsetMs` in die Zukunft.
*
* Zweck (Krit. 9, F1): Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, 60 min) ab; der
* Test erzwingt den Refresh, ohne zu warten. `Session.expiresAt` in supabase_flutter wird aus dem
* JWT-Claim `exp` berechnet, nicht aus dem gespeicherten Feld `expires_at`; das Aendern des
* localStorage haette also keine Wirkung. Der Server rechnet weiter mit der echten Zeit.
*/
export async function shiftBrowserClock(page: Page, offsetMs: number): Promise<void> {
await page.addInitScript((offset: number) => {
const RealDate = Date;
class ShiftedDate extends RealDate {
constructor(...args: any[]) {
if (args.length === 0) super(RealDate.now() + offset);
else super(...(args as [any]));
}
static now() {
return RealDate.now() + offset;
}
}
(globalThis as any).Date = ShiftedDate;
}, offsetMs);
}

View file

@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { subjects } from '../../support/auth-config';
import { profileByEmail } from '../../support/db';
import { randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
import { expectSignedIn, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt“ und Anmeldung', () => {
test('KTO-01 Kriterium 1: Registrieren, Link in der Mail oeffnen, bestaetigen, anmelden', async ({ page }) => {
const email = randomEmail();
const password = randomPassword();
await register(page, { email, password, displayName: ' Webnutzer ' });
await expect(page).toHaveURL(/\/register\/check-email$/);
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
const { link } = extractTokenHash(mail);
expect((await profileByEmail(email)).display_name).toBe('Webnutzer');
// Der Link aus der Mail fuehrt auf die App; erst der Klick loest die Pruefung aus (Spec E3).
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.confirmSuccess)).toBeVisible();
await page.context().clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, email, password);
await expectSignedIn(page);
});
});

View file

@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test';
import { userByEmail } from '../../support/db';
import { randomEmail, randomPassword } from '../../support/gotrue';
import { fillRegistration, open, t, textOf } from '../../support/web';
/** Zaehlt Signup-Aufrufe der Seite (Krit. 2: „kein Aufruf“). */
function countSignups(page: import('@playwright/test').Page) {
const calls: string[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/auth/v1/signup')) calls.push(r.url());
});
return calls;
}
test.describe('KTO-01 Kriterien 2, 3, 10 (web): Formularvalidierung', () => {
test('KTO-01 Kriterium 2: ohne Altersbestaetigung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
const email = randomEmail();
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email, password: randomPassword(), age: false });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, t.ageNotConfirmed)).toBeVisible();
await expect(page).toHaveURL(/\/register$/);
expect(calls).toEqual([]);
expect(await userByEmail(email)).toBeUndefined();
});
test('QUE-04 Kriterium 1: ohne Zustimmung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), terms: false });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, t.termsNotAccepted)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 3: zu kurzes Passwort zeigt am Feld, wie viele Zeichen fehlen, und sendet nichts', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: 'kurz12345' });
await page.getByRole('button', { name: 'Create account' }).click();
// 9 von 10 Zeichen: noch 1 fehlt (AUTH-CFG-05).
await expect(textOf(page, /1 more character needed/)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 3: sehr verbreitetes Passwort wird am Feld abgelehnt', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: 'password123' });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, /very common and easy to guess/)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 10: leerer Anzeigename zeigt den Fehler am Feld', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), displayName: ' ' });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, 'Enter a display name.')).toBeVisible();
expect(calls).toEqual([]);
});
});

View file

@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, waitForMail } from '../../support/mailpit';
import { fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();
/** Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse. */
async function screenText(page: import('@playwright/test').Page): Promise<string> {
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
return (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
}
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
test('KTO-01 Kriterium 4: Registrierung mit bestehender, bestaetigter Adresse zeigt exakt denselben Bildschirm wie mit neuer Adresse', async ({
page,
browser,
}) => {
// Neue Adresse.
await register(page, { email: randomEmail(), password: randomPassword() });
await expect(page).toHaveURL(/\/register\/check-email$/);
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
const fresh = await screenText(page);
// Vorhandene, bestaetigte Adresse (nur ueber die API angelegt).
const existing = randomEmail();
await gotrue.createConfirmedUser(existing, randomPassword());
const second = await (await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' })).newPage();
try {
await register(second, { email: existing, password: randomPassword() });
await expect(second).toHaveURL(/\/register\/check-email$/);
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
expect(await screenText(second)).toBe(fresh);
// Es geht keine Mail an das bestehende Konto.
await expectMailCount(existing, 0);
} finally {
await second.context().close();
}
});
});
test.describe('KTO-01 Kriterium 6 (web): unbestaetigtes Konto', () => {
test('KTO-01 Kriterium 6: Login mit richtigem Passwort zeigt Hinweis und Aktion; „erneut senden“ schickt eine zweite Mail', async ({
page,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
await waitForMail(email);
await waitMailInterval();
await login(page, email, password);
await expect(textOf(page, t.emailNotConfirmed)).toBeVisible();
await page.getByRole('button', { name: t.resendConfirmation }).click();
await expect(textOf(page, t.checkEmailResent)).toBeVisible();
await waitForMail(email, 2);
});
});
test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselungshinweis', () => {
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort zeigen denselben Text', async ({ page, browser }) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, `${password}-falsch`);
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
const wrongPassword = await screenText(page);
const ctx = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' });
try {
const other = await ctx.newPage();
await login(other, randomEmail('unknown'), password);
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
expect(await screenText(other)).toBe(wrongPassword);
} finally {
await ctx.close();
}
});
/**
* Die CLI drosselt nie (AUTH-CFG-13/A2), ein echter Test waere nie rot. Die 429-Antwort wird
* deshalb per `page.route` simuliert; der echte Limiter wird im Projekt `throttle` geprueft.
* CORS: Die App (localhost:3000) ruft Kong (127.0.0.1:54321) cross-origin auf; die simulierte
* Antwort braucht deshalb die CORS-Header, sonst sieht die App einen Netzfehler statt 429.
*/
test('KTO-01 Kriterium 7: AUTH-CFG-10 bei 429 erscheint der Drosselungshinweis ohne Dauer', async ({ page }) => {
await page.route('**/auth/v1/token?grant_type=password', async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') return route.fallback();
await route.fulfill({
status: 429,
contentType: 'application/json',
headers: { 'access-control-allow-origin': request.headers()['origin'] ?? '*' },
body: JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }),
});
});
await login(page, randomEmail(), randomPassword());
await expect(textOf(page, t.rateLimited)).toBeVisible();
await expect(textOf(page, t.invalidCredentials)).toHaveCount(0);
});
});
test.describe('QUE-06 (web): Ablauf nur per Tastatur', () => {
test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter', async ({ page }) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await open(page, '/login');
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
await page.keyboard.type(password);
await page.keyboard.press('Enter');
await expect(textOf(page, t.home)).toBeVisible();
});
});

View file

@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readAuthStorage } from '../../support/web';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 5 (web): Sitzung bleibt bestehen', () => {
test('KTO-01 Kriterium 5: Login, Reload und neuer Browser-Kontext mit gleichem Storage bleiben angemeldet', async ({
page,
browser,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, password);
await expectSignedIn(page);
const stored = await readAuthStorage(page);
expect(Object.keys(stored).length, 'Sitzung liegt im localStorage').toBeGreaterThan(0);
// Reload: weiterhin angemeldet (/login leitet auf die Startseite).
await page.reload();
await expectSignedInViaLoginRedirect(page);
// Neuer Browser-Kontext mit gleichem Storage (z. B. Browser neu gestartet).
const state = await page.context().storageState();
const restored = await browser.newContext({
storageState: state,
locale: 'en-US',
serviceWorkers: 'block',
baseURL: 'http://localhost:3000',
});
try {
const other = await restored.newPage();
await other.goto('/login');
await enableSemantics(other);
await expect(other).toHaveURL(/\/$/);
} finally {
await restored.close();
}
});
test('KTO-01 Kriterium 5: ohne Sitzung bleibt /login erreichbar (Gegenprobe)', async ({ page }) => {
await page.goto('/login');
await expect(page).toHaveURL(/\/login$/);
});
});

View file

@ -0,0 +1,102 @@
import { expect, test } from '@playwright/test';
import { subjects } from '../../support/auth-config';
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
import { fillField, open, pressFirstButton, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();
async function requestReset(page: import('@playwright/test').Page, email: string) {
await open(page, '/forgot-password');
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
await page.getByRole('button', { name: 'Send link' }).click();
await expect(page).toHaveURL(/\/forgot-password\/check-email$/);
}
test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
test('KTO-01 Kriterium 8: Reset anfordern, Link oeffnen, neues Passwort setzen, mit neuem Passwort anmelden', async ({ page }) => {
const email = randomEmail();
const oldPassword = randomPassword();
const newPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
await requestReset(page, email);
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.recovery.en);
const { link, type } = extractTokenHash(mail);
expect(type).toBe('recovery');
// Der Link fuehrt auf die Link-Seite; erst der Klick loest die Pruefung aus.
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(page).toHaveURL(/\/reset-password$/);
await fillField(page.getByRole('textbox', { name: 'New password' }), newPassword);
await page.getByRole('button', { name: 'Save password' }).click();
await expect(textOf(page, t.passwordChanged)).toBeVisible();
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe('invalid_credentials');
});
test('KTO-01 Kriterium 8: bereits benutzter Reset-Link zeigt „abgelaufen oder schon benutzt“', async ({ page }) => {
const email = randomEmail();
await gotrue.createConfirmedUser(email, randomPassword());
await gotrue.recover(email);
const { link, tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); // Link verbraucht
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Reset-Link (recovery_sent_at 1 h + 1 s zurueck) zeigt den Fehlerzustand', async ({ page }) => {
const email = randomEmail();
await gotrue.createConfirmedUser(email, randomPassword());
await gotrue.recover(email);
const { link } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
await expect(page.getByRole('button', { name: 'Request a new link' })).toBeVisible();
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Bestaetigungslink zeigt den Fehlerzustand', async ({ page }) => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword());
const { link } = extractTokenHash(await waitForMail(email));
await expireConfirmationLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 8: Link ohne Parameter zeigt sofort den Fehlerzustand', async ({ page }) => {
await open(page, '/auth/confirm');
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 4/8: Reset fuer unbekannte Adresse zeigt denselben Bildschirm, es geht keine Mail raus', async ({ page, browser }) => {
const known = randomEmail();
await gotrue.createConfirmedUser(known, randomPassword());
await requestReset(page, known);
await expect(textOf(page, /reset link if an account exists/)).toBeVisible();
const body = (await page.locator('body').ariaSnapshot()).trim();
const unknown = randomEmail('unknown');
const ctx = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: 'http://localhost:3000' });
try {
const other = await ctx.newPage();
await requestReset(other, unknown);
expect((await other.locator('body').ariaSnapshot()).trim()).toBe(body);
} finally {
await ctx.close();
}
await expectMailCount(unknown, 0);
});
});

View file

@ -0,0 +1,61 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readAuthStorage, shiftBrowserClock } from '../../support/web';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 9 (web): Passwort-Reset beendet andere Sitzungen', () => {
test('KTO-01 Kriterium 9: zweiter Browser-Kontext ist nach dem Reset und dem naechsten (erzwungenen) Refresh abgemeldet', async ({
page,
browser,
}) => {
const email = randomEmail();
const oldPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
// Sitzung B: ein zweites Geraet in eigenem Kontext.
await login(page, email, oldPassword);
await expectSignedIn(page);
// Reset ueber Sitzung A (API): neues Passwort setzen beendet alle anderen Sitzungen.
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const recovery = await gotrue.verify(tokenHash, 'recovery');
const update = await gotrue.updateUser(recovery.body.access_token, { password: randomPassword() });
expect(update.status).toBe(200);
// Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, bis 60 min, F1) ab: Der Test
// erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt.
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
await page.reload();
await enableSemantics(page);
// Der Refresh scheitert (refresh_token_not_found): supabase_flutter loescht die Sitzung.
await expect.poll(async () => Object.keys(await readAuthStorage(page)), { timeout: 15_000 }).toEqual([]);
// Abgemeldet: /login leitet nicht mehr auf die Startseite (Gegenprobe zu Krit. 5).
await page.goto('/login');
await enableSemantics(page);
await page.waitForTimeout(1_000);
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
});
test('KTO-01 Kriterium 9: Gegenprobe ohne Reset und mit abgelaufenem Access-Token bleibt die Sitzung bestehen (Refresh klappt)', async ({
page,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, password);
await expectSignedIn(page);
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
const before = JSON.parse(Object.values(await readAuthStorage(page))[0]).access_token;
await page.reload();
await expectSignedInViaLoginRedirect(page);
// Der Refresh lief wirklich: neues Access-Token im Storage.
await expect
.poll(async () => JSON.parse(Object.values(await readAuthStorage(page))[0] ?? '{}').access_token)
.not.toBe(before);
});
});