import http from 'k6/http'; import { check, sleep } from 'k6'; import { Trend } from 'k6/metrics'; import { BASE, as, me } from './lib.ts'; // A flood of sign-in attempts with wrong passwords while everyone else goes on using the site. // Two things only many requests at once show: whether checking passwords, tens of milliseconds // of CPU each, starves the rest of the server, and whether a wrong password for a name that is // an account takes longer to refuse than one for a name that is not -- the answers read the // same, and the time would tell anyone which names are accounts here. const gap = new Trend('signin_name_gap', true); export const options = { scenarios: { attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' }, everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' }, }, thresholds: { http_req_failed: ['rate==0'], checks: ['rate==1'], // 57ms on 2026-10-05 on SQLite, 68 on Postgres; 4.3s while password checks ran on the async // workers (#137). 'http_req_duration{name:meanwhile}': ['p(95)<300'], // Known name against unknown, one straight after the other: no gap but noise. 0.2ms on // 2026-10-05; 31ms while an unknown name was refused without a check (#138). signin_name_gap: ['med<10'], }, }; // Each attempt from an address of its own, as Caddy would name it: one address is held off after // ten (#409), and the flood to measure is one from many, which still has every password checked. const refused = (n: number) => ({ headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `10.${(n >> 16) & 255}.${(n >> 8) & 255}.${n & 255}` }, responseCallback: http.expectedStatuses(401), }); export function attempt() { const n = __VU * 10_000 + __ITER; const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }), { ...refused(2 * n), tags: { name: 'signin-known' } }); const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }), { ...refused(2 * n + 1), tags: { name: 'signin-unknown' } }); check(known, { 'a wrong password refused': r => r.status === 401 }); check(unknown, { 'an unknown name refused': r => r.status === 401 }); gap.add(known.timings.duration - unknown.timings.duration); } export function browse() { const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile')); check(r, { 'the site still answers': r => r.status === 200 }); sleep(0.5); }