The running test container had been built from an older tree; rebuilding it to pick up current app code surfaced several tests that predated shipped security/UX features: - file-upload-attacks / rate-limit: assert the magic-byte rejection wording and upload a real decodable JPEG (a zero-buffer is now rejected at the boundary). - ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=. - recover-page / join: the PIN field auto-submits on the 4th digit, so don't race an explicit submit click against the ensuing navigation. - gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and anchor the radio accessible-name match at the start (gated dialog semantics). playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw "Unknown permission: camera" on firefox/webkit and failed the test at context creation); grant them per-Chromium-project. firefox-android drops the unsupported isMobile flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
/**
|
|
* USER_JOURNEYS.md §1 (First-time guest), §2 (Returning guest, same device),
|
|
* §3 (Returning guest, new device). Covers the happy path through
|
|
* /join, the PIN modal, the onboarding overlay landing, and the
|
|
* name-already-taken recovery transformation.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { JoinPage } from '../../page-objects';
|
|
import { readStorage, STORAGE_KEYS, clearAllStorage } from '../../helpers/storage-helpers';
|
|
|
|
test.describe('Auth — join flow', () => {
|
|
test('happy path: name → PIN modal → feed @smoke', async ({ page }) => {
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
|
|
await expect(page.getByRole('heading', { name: 'Willkommen!' })).toBeVisible();
|
|
|
|
const { pin } = await join.joinAs('Alice');
|
|
expect(pin).toMatch(/^\d{4}$/);
|
|
|
|
// PIN copy button toggles to "Kopiert!" on click
|
|
await join.pinCopyButton.click();
|
|
await expect(join.pinCopyButton).toHaveText(/Kopiert/i);
|
|
|
|
await join.continueToFeed();
|
|
await expect(page).toHaveURL(/\/feed$/);
|
|
|
|
const storage = await readStorage(page);
|
|
expect(storage.jwt, 'JWT in localStorage').toMatch(/^eyJ/);
|
|
expect(storage.pin).toBe(pin);
|
|
expect(storage.userId).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(storage.displayName).toBe('Alice');
|
|
});
|
|
|
|
test('returning guest with valid JWT is redirected to /feed', async ({ page, guest, signIn }) => {
|
|
const alice = await guest('Bob');
|
|
await signIn(page, alice);
|
|
|
|
// Now visit the root — should auto-redirect to /feed.
|
|
await page.goto('/');
|
|
await page.waitForURL('**/feed', { timeout: 5_000 });
|
|
});
|
|
|
|
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
|
|
const original = await guest('Charlie');
|
|
|
|
// Brand-new browser context (cleared storage) — landing on /join with same name
|
|
await clearAllStorage(page);
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
await join.fillName('Charlie');
|
|
await join.submit();
|
|
|
|
await expect(join.recoveryPinInput).toBeVisible();
|
|
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
|
|
|
|
// Type correct PIN → land on /feed with a new JWT. Filling the 4th digit
|
|
// auto-submits (see pin-auto-submit.spec), so an explicit submit click would
|
|
// race the navigation; click only as a fallback if the button is still around.
|
|
await join.recoveryPinInput.fill(original.pin);
|
|
if (await join.recoverySubmit.isEnabled().catch(() => false)) {
|
|
await join.recoverySubmit.click().catch(() => {});
|
|
}
|
|
await page.waitForURL('**/feed');
|
|
|
|
const storage = await readStorage(page);
|
|
expect(storage.userId).toBe(original.userId);
|
|
expect(storage.pin).toBe(original.pin);
|
|
});
|
|
|
|
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
|
|
const dave = await guest('Dave');
|
|
await clearAllStorage(page);
|
|
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
await join.fillName('Dave');
|
|
await join.submit();
|
|
await expect(join.recoveryPinInput).toBeVisible();
|
|
|
|
// Wrong PIN (real one is dave.pin)
|
|
const wrong = dave.pin === '0000' ? '1111' : '0000';
|
|
for (let i = 0; i < 3; i++) {
|
|
await join.recoveryPinInput.fill(wrong);
|
|
await join.recoverySubmit.click();
|
|
await expect(join.recoveryError).toBeVisible();
|
|
}
|
|
|
|
// Fourth attempt should hit the 429 lockout (even with the correct PIN now)
|
|
await join.recoveryPinInput.fill(dave.pin);
|
|
await join.recoverySubmit.click();
|
|
await expect(join.recoveryError).toContainText(/15 Minuten/);
|
|
|
|
// Sanity: DB row reflects the lock
|
|
// (The handler sets pin_locked_until directly — verify via API "recover" returning 429)
|
|
void db; // unused for now, documenting that db.lockUserPin exists if we want shortcut path
|
|
});
|
|
|
|
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
|
|
await guest('Eve');
|
|
await clearAllStorage(page);
|
|
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
await join.fillName('Eve');
|
|
await join.submit();
|
|
await expect(join.recoveryPinInput).toBeVisible();
|
|
|
|
await join.tryDifferentNameButton.click();
|
|
await expect(join.nameInput).toBeVisible();
|
|
await expect(join.recoveryPinInput).not.toBeVisible();
|
|
});
|
|
|
|
test('"Ich habe bereits einen Account" link routes to /recover', async ({ page }) => {
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
await join.linkToRecover.click();
|
|
await expect(page).toHaveURL(/\/recover$/);
|
|
});
|
|
|
|
test('JWT and PIN keys are exactly the ones auth.ts expects', async ({ page, guest, signIn }) => {
|
|
const handle = await guest('Frank');
|
|
await signIn(page, handle);
|
|
|
|
// Read raw localStorage to make sure no test accidentally uses a different key.
|
|
const raw = await page.evaluate(() => ({
|
|
jwt: localStorage.getItem('eventsnap_jwt'),
|
|
pin: localStorage.getItem('eventsnap_pin'),
|
|
userId: localStorage.getItem('eventsnap_user_id'),
|
|
displayName: localStorage.getItem('eventsnap_display_name'),
|
|
}));
|
|
expect(raw.jwt).toBe(handle.jwt);
|
|
expect(raw.pin).toBe(handle.pin);
|
|
expect(raw.userId).toBe(handle.userId);
|
|
expect(raw.displayName).toBe('Frank');
|
|
// Sanity: the keys we just checked match STORAGE_KEYS in storage-helpers.ts
|
|
expect(STORAGE_KEYS.jwt).toBe('eventsnap_jwt');
|
|
});
|
|
});
|