/** * 注册页「邮箱验证码 + 爆破防护」回归测试(档案 134)。 * * 为什么这几条必须钉在测试里(而不是靠"上线上点一遍"): * · **冷却与配额是时间相关的** —— 手点是验不出边界的(第 5 次到底拦没拦、retryAfter 是不是 * 正好等于剩余秒数),只有把 `now` 当入参才钉得住; * · **`sent` / `throttled` / `failed` 三种事件直接决定配额与可校验性** —— 记错一种就变成 * "发信失败也允许校验"或"被限流不计入配额(于是可以无限重试)"; * · **单次使用 + 试错作废** —— 猜码防护的全部价值就在这里。 * * 全程用 **`log` 驱动 + 内存 SQLite**:不打网络、不发真邮件,但仍走完整的编排与落库路径 * (唯一的例外是 `http` 驱动那两条 —— 它们打到本测试起的本地 HTTP 服务上,验证头/模板)。 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { createServer } from 'node:http' import { randomUUID } from 'node:crypto' import { SqliteAdapter } from '../lib/db/sqlite.js' import { DEFAULT_GUARD_POLICY, HOUR_MS, evaluateSendGuard, evaluateVerifyGuard, formatRetryAfter, } from '../lib/web/register-guard.js' import { codeMatches, consumeRegisterCode, generateCode, hashCode, isValidEmail, isValidUsername, normalizeEmail, requestRegisterCode, } from '../lib/web/email-code.js' import { renderVerificationMail, sendVerificationCodeMail } from '../lib/web/mail.js' import { turnstileEnabled, verifyTurnstile } from '../lib/web/turnstile.js' import { normalizeHostnames, resolveTurnstileHostnames } from '../lib/config.js' const PEPPER = 'test-pepper' /** 一份最小可用的配置(只填本模块真的会读的字段)。 */ function makeConfig(overrides = {}) { return { encryptionSecret: PEPPER, emailCodeTtlMs: DEFAULT_GUARD_POLICY.codeTtlMs, emailCodeMaxAttempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode, emailCodeGuard: { emailPerHour: DEFAULT_GUARD_POLICY.emailPerHour, emailSentPerDay: DEFAULT_GUARD_POLICY.emailSentPerDay, ipPerHour: DEFAULT_GUARD_POLICY.ipPerHour, globalPerHour: DEFAULT_GUARD_POLICY.globalPerHour, cooldownLadderMs: [...DEFAULT_GUARD_POLICY.cooldownLadderMs], }, mailDriver: 'log', mailApiUrl: '', mailApiKey: '', mailAuthHeader: '', mailFrom: '', mailFromName: '', mailBodyTemplate: '', mailTimeoutMs: 3000, registerRequireEmailCode: true, registerRequireCaptcha: false, turnstileSiteKey: '', turnstileSecret: '', ...overrides, } } /** 计数快照构造器:把 5 个数字包成 `evaluateSendGuard` 需要的形状。 */ function counts(over = {}) { const zero = { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 } return { hour: { ...zero, ...over }, day: { ...zero, ...over } } } test('冷却阶梯:首次放行,紧接着再发被拦且 retryAfter = 剩余秒数', () => { const now = 1_700_000_000_000 assert.deepEqual(evaluateSendGuard(counts(), now), { allowed: true, cooldownMs: 60_000 }) // 30 秒前发过一次 ⇒ 还差 30 秒 const recent = evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 30_000 }), now) assert.equal(recent.allowed, false) assert.equal(recent.reason, 'email_cooldown') assert.equal(recent.retryAfterSeconds, 30) // 冷却已过 ⇒ 放行 assert.equal(evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 61_000 }), now).allowed, true) }) test('冷却阶梯随"一小时内已发起次数"递增(脚本化重试收益递减)', () => { const now = 1_700_000_000_000 const ladder = DEFAULT_GUARD_POLICY.cooldownLadderMs // 索引 0…5 恰好覆盖全部 6 级(`emailPerHour` = 6 就是为了让最后一级可达) for (const n of [0, 1, 2, 3, 4, 5]) { const verdict = evaluateSendGuard(counts({ emailTotal: n, emailLastAt: 0 }), now) assert.equal(verdict.allowed, true, `hourCount=${n} 应放行`) assert.equal(verdict.cooldownMs, ladder[n], `hourCount=${n}`) } assert.equal(ladder.length, DEFAULT_GUARD_POLICY.emailPerHour, '每一级都必须可达(否则白写一级)') }) test('配额:每小时 6 次 / 每天 8 封 / IP 20 次 / 全局 200 次各自独挡一面', () => { const now = 1_700_000_000_000 const hourQuota = evaluateSendGuard(counts({ emailTotal: DEFAULT_GUARD_POLICY.emailPerHour }), now) assert.equal(hourQuota.allowed, false) assert.equal(hourQuota.reason, 'email_hourly_quota') const dayQuota = evaluateSendGuard( { hour: { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 }, day: { emailTotal: 8, emailSent: 8, emailLastAt: now, ipTotal: 0, globalTotal: 0 } }, now, ) assert.equal(dayQuota.allowed, false) assert.equal(dayQuota.reason, 'email_daily_quota') assert.equal(evaluateSendGuard(counts({ ipTotal: 20 }), now).reason, 'ip_hourly_quota') assert.equal(evaluateSendGuard(counts({ globalTotal: 200 }), now).reason, 'global_hourly_quota') }) test('校验前置判定:过期 / 已用 / 试错超限 三种不可用状态分得开', () => { const now = 1_700_000_000_000 const row = (over) => ({ id: 'x', email: 'a@b.com', purpose: 'register', code_hash: 'h', status: 'sent', attempts: 0, ip: null, username: null, reason: null, created_at: now - 1000, expires_at: now + 1000, consumed_at: null, ...over, }) assert.equal(evaluateVerifyGuard(row({}), now), 'ok') assert.equal(evaluateVerifyGuard(row({ expires_at: now - 1 }), now), 'expired') assert.equal(evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: 1 }), now), 'used') assert.equal( evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode }), now), 'too_many_attempts', ) assert.equal(evaluateVerifyGuard(undefined, now), 'missing') }) test('验证码/邮箱/用户名 的取值口径', () => { const seen = new Set(Array.from({ length: 200 }, () => generateCode())) for (const code of seen) assert.match(code, /^\d{6}$/) assert.ok(seen.size > 150, '随机性抽样:200 次不应大量重复') assert.equal(normalizeEmail(' Alice@Example.COM '), 'alice@example.com') assert.equal(isValidEmail('a@b.co'), true) assert.equal(isValidEmail('a@b'), false) assert.equal(isValidEmail('nope'), false) assert.equal(isValidUsername('ab'), false) assert.equal(isValidUsername('a_b-1'), true) const hash = hashCode('a@b.com', 'register', '123456', PEPPER) assert.equal(hash.length, 64) assert.equal(codeMatches(hash, hashCode('a@b.com', 'register', '123456', PEPPER)), true) assert.equal(codeMatches(hash, hashCode('a@b.com', 'register', '123457', PEPPER)), false) assert.equal(codeMatches(hash, hashCode('a@b.com', 'register', '123456', 'other-pepper')), false) }) test('retryAfter 人话格式化', () => { assert.equal(formatRetryAfter(45), '45 秒') assert.equal(formatRetryAfter(120), '2 分钟') assert.equal(formatRetryAfter(7200), '2 小时') }) test('mail: 正文含验证码与有效期,且明确告知"非本人操作请忽略"', () => { const { subject, text } = renderVerificationMail({ to: 'a@b.com', code: '123456', ttlMinutes: 10, brand: 'EXAMPLE' }) assert.ok(subject.includes('123456')) assert.ok(subject.includes('EXAMPLE')) assert.ok(text.includes('123456')) assert.ok(text.includes('10')) assert.ok(text.includes('ignore this e-mail')) // 站点名缺失时**不得**回落到平台内部名(邮件是给终端用户看的) const plain = renderVerificationMail({ to: 'a@b.com', code: '654321', ttlMinutes: 10 }) assert.equal(plain.subject, '654321 is your verification code') assert.ok(!/DSH|dshs/i.test(plain.subject + plain.text), '不得出现平台内部名') assert.ok(plain.text.includes('654321')) }) test('mail: log 驱动不发信但算成功;未配置的通道直接判失败(不静默吞掉)', async () => { const settings = (over) => ({ driver: 'log', apiUrl: '', apiKey: '', authHeader: '', from: '', fromName: '', bodyTemplate: '', timeoutMs: 1000, ...over, }) assert.equal((await sendVerificationCodeMail(settings({}), { to: 'a@b.com', code: '1', ttlMinutes: 10 })).ok, true) const unconfigured = await sendVerificationCodeMail( settings({ driver: 'brevo', from: '' }), { to: 'a@b.com', code: '1', ttlMinutes: 10 }, ) assert.equal(unconfigured.ok, false) assert.equal(unconfigured.error, 'mail_not_configured') }) test('mail: http 驱动把 URL / 鉴权头 / body 模板交给配置(换供应商不用改代码)', async () => { const seen = [] const server = createServer((req, res) => { let body = '' req.on('data', (chunk) => { body += chunk }) req.on('end', () => { seen.push({ url: req.url, headers: req.headers, body }) res.writeHead(200, { 'content-type': 'application/json' }) res.end('{"ok":true}') }) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const port = server.address().port try { const result = await sendVerificationCodeMail( { driver: 'http', apiUrl: `http://127.0.0.1:${port}/send`, apiKey: 'sekret', authHeader: 'x-api-key', from: 'no-reply@example.net', fromName: 'EXAMPLE', bodyTemplate: '{"to":"{{to}}","subject":"{{subject}}","payload":{"code":"{{code}}"}}', timeoutMs: 3000, }, { to: 'user@example.com', code: '987654', ttlMinutes: 10 }, ) assert.equal(result.ok, true) assert.equal(seen.length, 1) assert.equal(seen[0].headers['x-api-key'], 'sekret') const payload = JSON.parse(seen[0].body) assert.equal(payload.to, 'user@example.com') assert.equal(payload.payload.code, '987654') assert.ok(payload.subject.includes('987654')) } finally { await new Promise((resolve) => server.close(resolve)) } }) test('mail: 上游 5xx ⇒ 明确失败(不重试、不假装成功)', async () => { const server = createServer((req, res) => { res.writeHead(502); res.end('bad gateway') }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const port = server.address().port try { const result = await sendVerificationCodeMail( { driver: 'http', apiUrl: `http://127.0.0.1:${port}/send`, apiKey: '', authHeader: '', from: 'a@b.com', fromName: '', bodyTemplate: '', timeoutMs: 3000, }, { to: 'user@example.com', code: '1', ttlMinutes: 10 }, ) assert.equal(result.ok, false) assert.equal(result.status, 502) } finally { await new Promise((resolve) => server.close(resolve)) } }) test('turnstile: 只填一把钥匙视为未配置(半配置不许当"已启用")', () => { const base = { siteKey: 'k', secret: 's', action: 'signup', hostnames: ['example.net'], timeoutMs: 1000 } assert.equal(turnstileEnabled(base), true) assert.equal(turnstileEnabled({ ...base, secret: '' }), false) assert.equal(turnstileEnabled({ ...base, siteKey: '' }), false) // 🔴 主机名白名单为空 ⇒ **视为未配置完成**(否则 sitekey 泄露后可在任意站点伪造挑战) assert.equal(turnstileEnabled({ ...base, hostnames: [] }), false) }) test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三项)', async () => { // 本地假 siteverify:按 path 决定回什么,从而逐组合断言判定逻辑 const cases = { '/ok': { success: true, action: 'signup', hostname: 'example.net' }, '/badaction': { success: true, action: 'login', hostname: 'example.net' }, '/badhost': { success: true, action: 'signup', hostname: 'evil.example' }, '/nohost': { success: true, action: 'signup' }, '/fail': { success: false, 'error-codes': ['invalid-input-response'] }, } let hits = 0 const server = createServer((req, res) => { hits += 1 const body = cases[req.url] ?? { success: false, 'error-codes': ['bad-request'] } res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const port = server.address().port const settings = (path) => ({ siteKey: 'k', secret: 's', timeoutMs: 3000, action: 'signup', hostnames: ['example.net', 'www.example.net'], verifyUrl: `http://127.0.0.1:${port}${path}`, }) try { assert.equal((await verifyTurnstile(settings('/ok'), 'tok')).ok, true, '三项齐备 ⇒ 放行') assert.equal((await verifyTurnstile(settings('/badaction'), 'tok')).error, 'action_mismatch: login') assert.equal((await verifyTurnstile(settings('/badhost'), 'tok')).error, 'hostname_mismatch: evil.example') assert.equal((await verifyTurnstile(settings('/nohost'), 'tok')).error, 'hostname_mismatch: (none)') assert.equal((await verifyTurnstile(settings('/fail'), 'tok')).error, 'invalid-input-response') // 空 token / 超长 token:**本地就拒**,不浪费一次上游往返 const before = hits assert.equal((await verifyTurnstile(settings('/ok'), '')).error, 'missing-input-response') assert.equal((await verifyTurnstile(settings('/ok'), 'x'.repeat(2049))).error, 'invalid-input-response') assert.equal(hits, before, '本地拒绝不应打到 siteverify') // 缺 secret ⇒ not_configured(不请求上游) assert.equal((await verifyTurnstile({ ...settings('/ok'), secret: '' }, 'tok')).error, 'not_configured') } finally { await new Promise((resolve) => server.close(resolve)) } }) test('config: 期望主机名归一 + 派生 + ⛔ 绝不自动加 localhost', () => { assert.deepEqual(normalizeHostnames(['https://EXAMPLE.net/', 'www.example.net:443', ' example.net ']), [ 'example.net', 'www.example.net', ]) // 未配(undefined)⇒ 从 baseDomain 派生 assert.deepEqual(resolveTurnstileHostnames(undefined, 'example.net'), ['example.net', 'www.example.net']) // 显式配 ⇒ 只用配的(可加旧域,旧域门户仍在线) assert.deepEqual( resolveTurnstileHostnames(['example.net', 'example.org'], 'example.net'), ['example.net', 'example.org'], ) // 显式空 ⇒ 关闭(不派生) assert.deepEqual(resolveTurnstileHostnames([], 'example.net'), []) // baseDomain 空 ⇒ 空(= 未配置完成 ⇒ 人机验证停用,不会静默放开) assert.deepEqual(resolveTurnstileHostnames(undefined, ''), []) assert.equal(normalizeHostnames(['localhost']).includes('example.net'), false) }) /* ── 编排层:真库 + log 驱动 ─────────────────────────────────────────────── */ /** 直接插一条"可校验"的事件行(发码走真实路径时验证码只在邮件里,测试需要已知码)。 */ async function seedCode(db, config, { email, username, code, createdAt = Date.now(), expiresAt }) { await db.recordEmailCode({ id: randomUUID(), email, purpose: 'register', codeHash: hashCode(email, 'register', code, config.encryptionSecret), status: 'sent', ip: '203.0.113.9', username, reason: null, createdAt, expiresAt: expiresAt ?? createdAt + config.emailCodeTtlMs, }) } test('编排:发码成功落 sent,冷却期内再发落 throttled 并回 429', async () => { const db = new SqliteAdapter(':memory:', 100000) const config = makeConfig() const deps = { db, config } try { const first = await requestRegisterCode(deps, { email: 'New@Example.com', username: 'newbie', ip: '198.51.100.7' }) assert.equal(first.ok, true) assert.equal(first.retryAfterSeconds, 60) const latest = await db.latestSentEmailCode('new@example.com', 'register') assert.ok(latest, '应留下一条可校验的 sent 行') assert.equal(latest.status, 'sent') assert.equal(latest.username, 'newbie') assert.equal(latest.code_hash?.length, 64, '只存哈希、不存明文') const second = await requestRegisterCode(deps, { email: 'new@example.com', username: 'newbie', ip: '198.51.100.7' }) assert.equal(second.ok, false) assert.equal(second.status, 429) assert.equal(second.error, 'email_cooldown') assert.ok(second.retryAfterSeconds > 0 && second.retryAfterSeconds <= 60) const hour = await db.emailCodeCounts('new@example.com', '198.51.100.7', Date.now() - HOUR_MS) assert.equal(hour.emailTotal, 2, '被拒的那次也必须计入配额') } finally { await db.close() } }) test('编排:用户名/邮箱占用与格式错误在发码阶段就被挡(不浪费配额)', async () => { const db = new SqliteAdapter(':memory:', 100000) const config = makeConfig() const deps = { db, config } try { await db.createUser({ id: 'u1', username: 'taken', passHash: 'x', role: 'active', homeDir: '/h', email: 'used@example.com' }) const takenName = await requestRegisterCode(deps, { email: 'x@example.com', username: 'taken', ip: null }) assert.equal(takenName.error, 'username_taken') assert.equal(takenName.status, 409) const takenMail = await requestRegisterCode(deps, { email: 'USED@example.com', username: 'fresh', ip: null }) assert.equal(takenMail.error, 'email_taken') assert.equal(takenMail.status, 409) assert.equal((await requestRegisterCode(deps, { email: 'bad', username: 'fresh', ip: null })).error, 'invalid_email') assert.equal((await requestRegisterCode(deps, { email: 'a@b.com', username: 'x', ip: null })).error, 'invalid_username') const countsAfter = await db.emailCodeCounts('x@example.com', null, Date.now() - HOUR_MS) assert.equal(countsAfter.emailTotal, 0, '被前置挡掉的请求不应占用配额') } finally { await db.close() } }) test('编排:试错递增、达上限立即作废;正确码单次使用', async () => { const db = new SqliteAdapter(':memory:', 100000) const config = makeConfig() const deps = { db, config } try { await seedCode(db, config, { email: 'c@example.com', username: 'carl', code: '111111' }) const first = await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '000000', ip: null }) assert.equal(first.error, 'code_invalid') assert.equal(first.attemptsLeft, config.emailCodeMaxAttempts - 1) for (let i = 1; i < config.emailCodeMaxAttempts; i += 1) { await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '000000', ip: null }) } // 第 5 次错 ⇒ 直接作废 const exhausted = await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '000000', ip: null }) assert.equal(exhausted.error, 'code_attempts_exceeded') // 此时**连正确码也不认**(必须重新获取) const afterExhaust = await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '111111', ip: null }) assert.equal(afterExhaust.error, 'code_attempts_exceeded') // 新码:一次成功、二次被拒(单次使用) await seedCode(db, config, { email: 'c@example.com', username: 'carl', code: '222222' }) const ok = await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '222222', ip: null }) assert.equal(ok.ok, true) const reuse = await consumeRegisterCode(deps, { email: 'c@example.com', username: 'carl', code: '222222', ip: null }) assert.equal(reuse.error, 'code_used') } finally { await db.close() } }) test('编排:过期 / 用户名不匹配 / 大小写无关', async () => { const db = new SqliteAdapter(':memory:', 100000) const config = makeConfig() const deps = { db, config } try { await seedCode(db, config, { email: 'd@example.com', username: 'dora', code: '333333', createdAt: Date.now() - 20 * 60 * 1000, expiresAt: Date.now() - 10 * 60 * 1000, }) assert.equal( (await consumeRegisterCode(deps, { email: 'd@example.com', username: 'dora', code: '333333', ip: null })).error, 'code_expired', ) await seedCode(db, config, { email: 'e@example.com', username: 'erin', code: '444444' }) assert.equal( (await consumeRegisterCode(deps, { email: 'e@example.com', username: 'someone', code: '444444', ip: null })).error, 'code_username_mismatch', ) // 大小写不敏感 + 邮箱归一化 const ok = await consumeRegisterCode(deps, { email: 'E@Example.com', username: 'ERIN', code: '444444', ip: null }) assert.equal(ok.ok, true) } finally { await db.close() } }) test('v10 迁移:users.email 大小写不敏感唯一 + findUserByEmail', async () => { const db = new SqliteAdapter(':memory:', 100000) try { await db.createUser({ id: 'a', username: 'alice', passHash: 'x', role: 'active', homeDir: '/h', email: 'Alice@Example.com' }) const found = await db.findUserByEmail('alice@example.com') assert.equal(found?.id, 'a') assert.equal(found?.email, 'Alice@Example.com') assert.equal(await db.findUserByEmail('nobody@example.com'), undefined) await assert.rejects(() => db.createUser({ id: 'b', username: 'bob', passHash: 'x', role: 'active', homeDir: '/h', email: 'alice@EXAMPLE.com' }), ) // email 可选(老路径 / k8s 路径不受影响) await db.createUser({ id: 'c', username: 'carol', passHash: 'x', role: 'active', homeDir: '/h' }) assert.equal((await db.findUserById('c'))?.email, null) } finally { await db.close() } }) test('v10:事件表自维护(purge 只清旧行)', async () => { const db = new SqliteAdapter(':memory:', 100000) const now = Date.now() try { await db.recordEmailCode({ id: 'old', email: 'o@example.com', purpose: 'register', codeHash: null, status: 'throttled', createdAt: now - 40 * 24 * 60 * 60 * 1000, }) await db.recordEmailCode({ id: 'new', email: 'o@example.com', purpose: 'register', codeHash: null, status: 'throttled', createdAt: now, }) const removed = await db.purgeEmailCodes(now - 30 * 24 * 60 * 60 * 1000) assert.equal(removed, 1) const left = await db.emailCodeCounts('o@example.com', null, 0) assert.equal(left.emailTotal, 1) } finally { await db.close() } })