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:
parent
2542679797
commit
d390b60bd6
7 changed files with 584 additions and 0 deletions
155
e2e/support/web.ts
Normal file
155
e2e/support/web.ts
Normal 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);
|
||||||
|
}
|
||||||
32
e2e/tests/web/krit01-registrierung.spec.ts
Normal file
32
e2e/tests/web/krit01-registrierung.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
e2e/tests/web/krit02-03-10-formular.spec.ts
Normal file
69
e2e/tests/web/krit02-03-10-formular.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
e2e/tests/web/krit04-06-07-anmeldung.spec.ts
Normal file
118
e2e/tests/web/krit04-06-07-anmeldung.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
e2e/tests/web/krit05-sitzung.spec.ts
Normal file
47
e2e/tests/web/krit05-sitzung.spec.ts
Normal 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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
e2e/tests/web/krit08-reset.spec.ts
Normal file
102
e2e/tests/web/krit08-reset.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
e2e/tests/web/krit09-abmeldung.spec.ts
Normal file
61
e2e/tests/web/krit09-abmeldung.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue