/** * USER_JOURNEYS.md §3 (recovery on a new device). Uses the standalone * /recover route as well as the inline-recovery flow on /join. */ import { test, expect } from '../../fixtures/test'; import { RecoverPage } from '../../page-objects'; import { clearAllStorage } from '../../helpers/storage-helpers'; test.describe('Auth — /recover route', () => { test('happy path: correct name + PIN → /feed', async ({ page, guest }) => { const handle = await guest('Greta'); await clearAllStorage(page); const recover = new RecoverPage(page); await recover.goto(); await recover.recover('Greta', handle.pin); await page.waitForURL('**/feed'); }); test('wrong PIN shows the localized error', async ({ page, guest }) => { const handle = await guest('Hans'); await clearAllStorage(page); const recover = new RecoverPage(page); await recover.goto(); await recover.recover('Hans', handle.pin === '9999' ? '0000' : '9999'); await expect(recover.errorMessage).toContainText(/PIN ist falsch|falsch/i); }); // An unknown name must be INDISTINGUISHABLE from a wrong PIN — same status, same message. // // This test used to assert the exact opposite: that an unknown name produced a distinct // "nicht gefunden" error. That IS the account-enumeration oracle the F4 audit fix removed // (recover now answers an unknown name with the same 401 "PIN ist falsch."), so reintroducing // the vulnerability would have made this test pass and `recover-enumeration.spec.ts` fail — two // tests asserting contradictory things about the same behaviour, one of them for the attacker. // // It also passed for a reason unrelated to the name: this test takes no `guest` fixture, so the // per-test TRUNCATE leaves no event row at all, and recover 404s with "Event nicht gefunden." — // which the old regex happily matched. test('an unknown name is indistinguishable from a wrong PIN (no enumeration oracle)', async ({ page, guest, }) => { const handle = await guest('Known'); await clearAllStorage(page); const recover = new RecoverPage(page); await recover.goto(); await recover.recover('Doesnt-Exist-' + Date.now(), '1234'); const unknownNameError = (await recover.errorMessage.textContent())?.trim(); await recover.goto(); await recover.recover('Known', handle.pin === '9999' ? '0000' : '9999'); const wrongPinError = (await recover.errorMessage.textContent())?.trim(); expect(unknownNameError).toBeTruthy(); expect( unknownNameError, 'an unknown name must not be distinguishable from a wrong PIN — that is an account-enumeration oracle' ).toBe(wrongPinError); expect(unknownNameError).not.toMatch(/nicht gefunden|kein benutzer/i); }); test('lockout expires and counter resets (per recover handler)', async ({ api, guest, db }) => { const handle = await guest('Ida'); await db.lockUserPin(handle.userId, 15); // Direct API call: even the correct PIN must fail while locked. await api.recover('Ida', handle.pin, { expectedStatus: [429] }); // Now move the lockout into the past. await db.lockUserPin(handle.userId, -1); // Correct PIN must succeed, AND the counter must be reset so the next wrong attempt isn't immediately re-locked. const { body } = await api.recover('Ida', handle.pin, { expectedStatus: [200] }); expect(body.jwt).toBeTruthy(); }); });