diff --git a/e2e/support/web.ts b/e2e/support/web.ts new file mode 100644 index 0000000..b5508aa --- /dev/null +++ b/e2e/support/web.ts @@ -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 { + 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 { + 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 { + await page.goto(path); + await enableSemantics(page); +} + +/** + * Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte 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 { + 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 { + 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[1]): Promise { + 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 { + 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 { + 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 { + 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> { + return page.evaluate(() => { + const out: Record = {}; + 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 { + 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 { + 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); +} diff --git a/e2e/tests/web/krit01-registrierung.spec.ts b/e2e/tests/web/krit01-registrierung.spec.ts new file mode 100644 index 0000000..ef7336b --- /dev/null +++ b/e2e/tests/web/krit01-registrierung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/web/krit02-03-10-formular.spec.ts b/e2e/tests/web/krit02-03-10-formular.spec.ts new file mode 100644 index 0000000..a2170a8 --- /dev/null +++ b/e2e/tests/web/krit02-03-10-formular.spec.ts @@ -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([]); + }); +}); diff --git a/e2e/tests/web/krit04-06-07-anmeldung.spec.ts b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts new file mode 100644 index 0000000..695b72c --- /dev/null +++ b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts @@ -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 { + // 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(); + }); +}); diff --git a/e2e/tests/web/krit05-sitzung.spec.ts b/e2e/tests/web/krit05-sitzung.spec.ts new file mode 100644 index 0000000..ac8be3e --- /dev/null +++ b/e2e/tests/web/krit05-sitzung.spec.ts @@ -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$/); + }); +}); diff --git a/e2e/tests/web/krit08-reset.spec.ts b/e2e/tests/web/krit08-reset.spec.ts new file mode 100644 index 0000000..98829f0 --- /dev/null +++ b/e2e/tests/web/krit08-reset.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/web/krit09-abmeldung.spec.ts b/e2e/tests/web/krit09-abmeldung.spec.ts new file mode 100644 index 0000000..b7d6572 --- /dev/null +++ b/e2e/tests/web/krit09-abmeldung.spec.ts @@ -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); + }); +});