111 lines
4.6 KiB
JavaScript
111 lines
4.6 KiB
JavaScript
|
|
// 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`);
|
||
|
|
}
|