test(e2e): re-point the PIN lockout specs at the property that matters

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>
This commit is contained in:
MechaCat02
2026-07-31 23:58:17 +02:00
parent 117e67fa80
commit 9b90929269
3 changed files with 147 additions and 47 deletions

View File

@@ -75,7 +75,11 @@ test.describe('Auth — join flow', () => {
expect(storage.pin).toBe(original.pin);
});
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
test('repeated wrong PINs are throttled without locking the guest out', async ({
page,
guest,
db,
}) => {
const dave = await guest('Dave');
await clearAllStorage(page);
@@ -85,22 +89,33 @@ test.describe('Auth — join flow', () => {
await join.submit();
await expect(join.recoveryPinInput).toBeVisible();
// Wrong PIN (real one is dave.pin)
// 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 < 3; i++) {
await join.recoveryPinInput.fill(wrong);
await join.recoverySubmit.click();
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();
}
// 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/);
// 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);
// 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
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 }) => {