template-monorepo/test/k6/lib/otp.js

111 lines
4.6 KiB
JavaScript
Raw Normal View History

2026-05-26 06:05:33 +00:00
// OTP retrieval helpers (P1 skeleton).
//
// fetchEmailOTP(email) → reads the latest MailHog mail for `email`, scans body
// for a 6-digit code. Full implementation in P2 lib.
// fetchSMSOTP(phone) → reads "dev:notification:last:sms:<phone>" from Redis
// (k6/experimental/redis). Implementation in P3.
//
// Both poll for up to cfg.otpPollTimeoutMs because Notifier writes happen
// asynchronously (Send goroutine + Redis hook).
import http from 'k6/http';
import { sleep } from 'k6';
// k6 v2.0+ uses 'k6/x/redis' (the experimental module was promoted). The
// default export is a namespace; the constructor is `redis.Client`.
import redis from 'k6/x/redis';
import { cfg } from './config.js';
const SIX_DIGITS = /\b(\d{6})\b/;
// CSS hex colors (#aabbcc) look like 6-digit OTPs to a naive regex; strip
// them before scanning. We also strip quoted-printable soft-line-breaks
// (`=\n`) since MailHog returns bodies QP-encoded.
const CSS_HEX = /#[0-9a-fA-F]{6}\b/g;
const QP_SOFT_BREAK = /=\r?\n/g;
function sleepMs(ms) {
sleep(ms / 1000);
}
// extractOTPFromText returns the LAST 6-digit number found (after stripping
// CSS hex colors). "Last" because the OTP is typically rendered near the
// bottom of the email body, after the header/branding markup.
export function extractOTPFromText(text) {
if (!text) return '';
const cleaned = String(text).replace(QP_SOFT_BREAK, '').replace(CSS_HEX, '');
const all = cleaned.match(/\b\d{6}\b/g);
if (!all || all.length === 0) return '';
return all[all.length - 1];
}
// fetchEmailOTP polls MailHog's /api/v2/search?kind=to&query=<email>
// until a 6-digit OTP can be parsed out of the most recent message body.
//
// opts.since (ms epoch, default 0): ignore mails delivered before this ts —
// needed when the same address has multiple OTPs in MailHog (e.g. register
// then email-verify), so the verify step picks up the new mail rather than
// the register one.
// opts.limit (default 5): number of latest mails to inspect each poll.
export function fetchEmailOTP(email, opts = {}) {
const since = opts.since || 0;
const limit = opts.limit || 5;
const deadline = Date.now() + cfg.otpPollTimeoutMs;
const u = `${cfg.mailhogUrl}/api/v2/search?kind=to&query=${encodeURIComponent(email)}&start=0&limit=${limit}`;
let last = '';
while (Date.now() < deadline) {
const res = http.get(u);
if (res.status === 200) {
try {
const body = JSON.parse(res.body);
const items = (body && body.items) || [];
for (const item of items) {
const created = item.Created ? Date.parse(item.Created) : 0;
if (created && created < since) continue;
const candidates = [
item.Content && item.Content.Body,
item.MIME && item.MIME.Parts && item.MIME.Parts.map((p) => p.Body).join('\n'),
].filter(Boolean);
for (const c of candidates) {
const code = extractOTPFromText(c);
if (code) return code;
}
}
if (items.length > 0) last = `no 6-digit code in ${items.length} items (since=${since})`;
} catch (e) {
last = `parse-error: ${e}`;
}
} else {
last = `mailhog status ${res.status}`;
}
sleepMs(cfg.otpPollIntervalMs);
}
throw new Error(`fetchEmailOTP(${email}) timed out: ${last}`);
}
// fetchSMSOTP polls Redis key "dev:notification:last:sms:<phone>" set by the
// mock SMS sender (see internal/model/notification/provider/sms/mock_sender.go).
//
// opts.prevBody: when supplied, the poll ignores writes whose body equals
// prevBody. This is essential when the same phone has two OTPs in a single
// test (e.g. register-resend or re-verify); the caller passes the body it
// already consumed so the helper waits for the next one.
//
// Requires k6 v2.0+ which exposes the redis client at k6/x/redis (the
// `k6/experimental/redis` module was removed in v2.0). Statically imported
// above so this works in default k6 compatibility mode.
export async function fetchSMSOTP(phone, opts = {}) {
const prev = opts.prevBody || '';
const [host, port] = cfg.redisAddr.split(':');
const client = new redis.Client({ socket: { host: host, port: parseInt(port || '6379', 10) } });
const key = `dev:notification:last:sms:${phone}`;
const deadline = Date.now() + cfg.otpPollTimeoutMs;
while (Date.now() < deadline) {
const body = await client.get(key);
if (body && body !== prev) {
const code = extractOTPFromText(body);
if (code) return { code, body };
}
sleepMs(cfg.otpPollIntervalMs);
}
throw new Error(`fetchSMSOTP(${phone}) timed out`);
}