test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those predated the audit's changes, established by building a stack from a clean HEAD worktree and running the same specs against it rather than guessing. Most were stale assertions rather than product defects: - quota.spec solved for a target limit using the observed uploader count, but the divisor is max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it aimed for came out 100x small and every "within quota" upload 413'd. - rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was never reached; it now does a real release and asserts 200 rather than "not 429". - ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence it exercises: four tickets per session survive and the rest correctly 401. Now asserts exactly four, which a tightened cap or an inverted eviction order would catch. - auth-tampering asserted a throttled IP is refused EVEN with the correct password. That contract was deliberately removed — it let any phone on the venue NAT lock the operator out of their own admin panel, with a circular escape hatch. Inverted, plus a new check that a success does not refill an attacker's bucket. - moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters banned authors, so it is hidden from everyone including the host. Now pins the pair that matters — the ban hides it, and the host's permanent removal survives an unban — and the UI leg it used to own is restored as a separate test on a reachable comment. The export specs mint with `?kind=` now that a download ticket is bound to one archive, and four of them assert the mint's 404 rather than the download's: with the kind always known, the pre-check refuses up front instead of after charging a daily download for an archive that cannot be served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -133,10 +133,14 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
});
|
||||
statuses.push(r.status);
|
||||
}
|
||||
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(
|
||||
0
|
||||
);
|
||||
expect(statuses.some((s) => s === 429), 'the attacker must be throttled').toBe(true);
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
statuses.some((s) => s === 429),
|
||||
'the attacker must be throttled'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await db.isPinLocked(g.userId),
|
||||
@@ -255,29 +259,46 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
expect(statuses.some((s) => s === 200)).toBe(false);
|
||||
});
|
||||
|
||||
test('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
|
||||
// password logic. If the throttle were removed, the correct password would return 200 here.
|
||||
test('the FAILURE bucket never refuses a correct admin password', async ({ api, adminToken }) => {
|
||||
// This asserted the OPPOSITE — that a throttled IP is refused even with the right password —
|
||||
// and that contract was deliberately removed, because at a real event it is a denial of
|
||||
// service against the operator. Every guest at the venue shares one public IP behind NAT and
|
||||
// `/admin/login` is a publicly linkable page, so a single tight IP bucket charged before the
|
||||
// password check meant any phone in the room could keep it full and the host, on that same IP,
|
||||
// could never spend a slot. The escape hatch was circular: `admin_login_rate_enabled` is only
|
||||
// reachable through `PATCH /admin/config`, which needs the session being blocked.
|
||||
//
|
||||
// So the tight bucket is now charged ONLY on a wrong password. Brute force stays bounded (every
|
||||
// guess costs a slot, per IP) while a valid credential is always honoured. A separate, generous
|
||||
// per-IP ceiling bounds bcrypt CPU regardless of correctness — see ADMIN_LOGIN_CPU_CEILING.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
admin_login_rate_enabled: 'true',
|
||||
});
|
||||
|
||||
// Exhaust the window with wrong passwords until throttled.
|
||||
// Exhaust the failure window with wrong passwords until throttled.
|
||||
let throttled = false;
|
||||
for (let i = 0; i < 10 && !throttled; i++) {
|
||||
throttled = (await tryLogin('wrong-' + i)).status === 429;
|
||||
}
|
||||
expect(throttled, 'the IP should be throttled after a burst').toBe(true);
|
||||
expect(throttled, 'a burst of WRONG passwords from one IP must be rate-limited').toBe(true);
|
||||
|
||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||
// The limiter is real (above) and yet the operator gets in. That combination is the whole
|
||||
// property: THIS bucket keys on failure, not on the IP alone.
|
||||
//
|
||||
// Deliberately not claimed here: "guests cannot lock the host out". They still can — the
|
||||
// separate CPU ceiling below refuses any password, correct included. This test stays under
|
||||
// that ceiling on purpose so the two are not conflated.
|
||||
expect(
|
||||
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||
'a throttled IP is refused even with the correct password'
|
||||
'a burst of wrong guesses must not cost the operator their own admin panel'
|
||||
).toBe(200);
|
||||
|
||||
// And guessing is still throttled AFTER a successful login — a correct password must not
|
||||
// refill or bypass the attacker's bucket.
|
||||
expect(
|
||||
(await tryLogin('wrong-again')).status,
|
||||
'a successful login must not clear the failure bucket for wrong guesses'
|
||||
).toBe(429);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user