template-monorepo/test/k6/journeys/totp_full.js

82 lines
3.2 KiB
JavaScript
Raw Permalink Normal View History

2026-05-26 06:05:33 +00:00
// Journey: TOTP full lifecycle (enroll → verify → backup-codes → disable)
//
// Endpoints exercised:
// GET /api/v1/members/me/totp (before)
// POST /api/v1/members/me/totp/enroll-start
// POST /api/v1/members/me/totp/enroll-confirm
// POST /api/v1/members/me/totp/verify
// POST /api/v1/members/me/totp/backup-codes (regenerate)
// GET /api/v1/members/me/totp (mid; enrolled=true)
// DELETE /api/v1/members/me/totp
// GET /api/v1/members/me/totp (after; enrolled=false)
//
// TOTP code generation is local (lib/totp.js) — no external authenticator.
import { sleep } from 'k6';
import { get, post, del, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { generateTOTP, parseOTPAuth } from '../lib/totp.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// 1. status before enroll
const before = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (before)').data;
if (before.enrolled !== false) throw new Error('totp should be disabled initially');
// 2. enroll-start → get otpauth URL
const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data;
parseOTPAuth(enroll.otpauth_url); // validate it parses
const code = generateTOTP(enroll.otpauth_url);
// 3. enroll-confirm
const confirm = checkEnvelope(
post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer),
'POST /me/totp/enroll-confirm',
).data;
if (!Array.isArray(confirm.backup_codes) || confirm.backup_codes.length === 0) {
throw new Error('enroll-confirm: backup_codes missing');
}
// 4. status mid (enrolled=true)
const mid = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (mid)').data;
if (mid.enrolled !== true) throw new Error('totp should be enrolled after confirm');
// 5. verify — wait until current TOTP differs from the one just consumed
// so the replay guard (Member.TOTP.ReplayTTLSeconds) does not reject it.
let verifyCode = generateTOTP(enroll.otpauth_url);
for (let i = 0; verifyCode === code && i < 30; i++) {
sleep(1.1);
verifyCode = generateTOTP(enroll.otpauth_url);
}
if (verifyCode === code) {
throw new Error('TOTP window did not advance; cannot avoid replay');
}
checkEnvelope(
post('/api/v1/members/me/totp/verify', { code: verifyCode }, bearer),
'POST /me/totp/verify',
);
// 6. backup-codes (regenerate)
const regen = checkEnvelope(
post('/api/v1/members/me/totp/backup-codes', null, bearer),
'POST /me/totp/backup-codes',
).data;
if (!Array.isArray(regen.backup_codes) || regen.backup_codes.length === 0) {
throw new Error('backup-codes: missing codes');
}
// 7. disable
checkEnvelope(del('/api/v1/members/me/totp', null, bearer), 'DELETE /me/totp');
// 8. status after disable
const after = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (after)').data;
if (after.enrolled !== false) throw new Error('totp should be disabled after DELETE');
}