Three specs asserted the OLD policy — that three wrong PINs lock an account — which is exactly the behaviour the previous commit removed, because that threshold sat below the per-(IP, name) throttle ceiling and so let any single IP lock any guest whose display name is readable off the feed. Rewritten to assert the distinction the fix introduces, which a status code alone cannot show: both tiers answer 429, but only the account lock costs the VICTIM. The new specs read the row via db.isPinLocked rather than the response, so: - one IP hammering /recover is throttled and the account stays UNLOCKED; - a distributed guesser (counter preloaded via db.setFailedPinAttempts, since no single source can reach the threshold any more) still trips the lock, and it holds even against the correct PIN; - concurrent wrong PINs are all counted — the atomicity property the old parallel test was really about, now asserted on the counter instead of inferred from a 429 that the throttle could equally have produced. The UI spec asserts the user-visible half: after four wrong PINs Dave can still get into his own account. It also now types the PIN digit by digit rather than filling and clicking, because the 4th digit auto-submits (pin-auto-submit.spec.ts) and doing both raced the button's disabled state. The adversarial spec enables rate_limits_enabled for its own run — it is off by default in this environment, so without that the throttle tier would silently not be exercised — and restores it in afterEach so it cannot leak into other specs sharing the stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
6.4 KiB
TypeScript
162 lines
6.4 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();
|
|
|
|
// 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 <h1>, 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');
|
|
});
|
|
});
|