/** * 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(); // The join form's landing state. There is no "Willkommen!" heading — the wedding // redesign (f243bfe) split it into a "Willkommen bei" lead-in plus the event name as // the

, and this assertion was never updated, so it had been failing since. // Anchor on the testid the markup provides rather than on copy. await expect(page.getByTestId('join-event-name')).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('repeated wrong PINs are throttled without locking the guest out', 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), four times — one more than the OLD lock threshold of 3. // Typed digit by digit so the 4th character auto-submits (see pin-auto-submit.spec.ts); // clicking as well would double-submit and race the disabled state of the button. const wrong = dave.pin === '0000' ? '1111' : '0000'; for (let i = 0; i < 4; i++) { await join.recoveryPinInput.fill(''); await join.recoveryPinInput.pressSequentially(wrong, { delay: 30 }); await expect(join.recoveryError).toBeVisible(); await expect(join.recoverySubmit).toBeEnabled(); } // THE PROPERTY THIS TEST EXISTS FOR, stated the way a guest experiences it: Dave can still // get into his own account. // // The lock threshold used to be 3, BELOW the per-(IP, name) ceiling — so these very // keystrokes locked Dave out for 15 minutes, and anyone who can read his name off the feed // could do it to him on repeat. Rate limits are disabled in this environment (see // config `rate_limits_enabled`), so what is exercised here is purely the account-lock tier; // the throttle tier is covered in 07-adversarial/auth-tampering.spec.ts. expect( await db.isPinLocked(dave.userId), 'four wrong PINs from one device must not lock a guest out of their own account' ).toBe(false); await join.recoveryPinInput.fill(''); await join.recoveryPinInput.pressSequentially(dave.pin, { delay: 30 }); await page.waitForURL('**/feed'); }); 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'); }); });