489 lines
22 KiB
JavaScript
489 lines
22 KiB
JavaScript
/**
|
||||
|
|
* 注册页「邮箱验证码 + 爆破防护」回归测试(档案 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: '[email protected]', 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(' [email protected] '), '[email protected]')
|
|||
|
|
assert.equal(isValidEmail('[email protected]'), 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('[email protected]', 'register', '123456', PEPPER)
|
|||
|
|
assert.equal(hash.length, 64)
|
|||
|
|
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123456', PEPPER)), true)
|
|||
|
|
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123457', PEPPER)), false)
|
|||
|
|
assert.equal(codeMatches(hash, hashCode('[email protected]', '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: '[email protected]', 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: '[email protected]', 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: '[email protected]', code: '1', ttlMinutes: 10 })).ok, true)
|
|||
|
|
|
|||
|
|
const unconfigured = await sendVerificationCodeMail(
|
|||
|
|
settings({ driver: 'brevo', from: '' }),
|
|||
|
|
{ to: '[email protected]', 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: '[email protected]',
|
||||
|
|
fromName: 'EXAMPLE',
|
|||
bodyTemplate: '{"to":"{{to}}","subject":"{{subject}}","payload":{"code":"{{code}}"}}',
|
||||
|
|
timeoutMs: 3000,
|
|||
|
|
},
|
|||
|
|
{ to: '[email protected]', 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, '[email protected]')
|
|||
|
|
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: '[email protected]', fromName: '', bodyTemplate: '', timeoutMs: 3000,
|
|||
|
|
},
|
|||
|
|
{ to: '[email protected]', 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: '[email protected]', username: 'newbie', ip: '198.51.100.7' })
|
|||
|
|
assert.equal(first.ok, true)
|
|||
|
|
assert.equal(first.retryAfterSeconds, 60)
|
|||
|
|
|
|||
|
|
const latest = await db.latestSentEmailCode('[email protected]', '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: '[email protected]', 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('[email protected]', '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: '[email protected]' })
|
|||
|
|
|
|||
|
|
const takenName = await requestRegisterCode(deps, { email: '[email protected]', username: 'taken', ip: null })
|
|||
|
|
assert.equal(takenName.error, 'username_taken')
|
|||
|
|
assert.equal(takenName.status, 409)
|
|||
|
|
|
|||
|
|
const takenMail = await requestRegisterCode(deps, { email: '[email protected]', 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: '[email protected]', username: 'x', ip: null })).error, 'invalid_username')
|
|||
|
|
|
|||
|
|
const countsAfter = await db.emailCodeCounts('[email protected]', 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: '[email protected]', username: 'carl', code: '111111' })
|
|||
|
|
|
|||
|
|
const first = await consumeRegisterCode(deps, { email: '[email protected]', 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: '[email protected]', username: 'carl', code: '000000', ip: null })
|
|||
|
|
}
|
|||
|
|
// 第 5 次错 ⇒ 直接作废
|
|||
|
|
const exhausted = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
|
|||
|
|
assert.equal(exhausted.error, 'code_attempts_exceeded')
|
|||
|
|
// 此时**连正确码也不认**(必须重新获取)
|
|||
|
|
const afterExhaust = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '111111', ip: null })
|
|||
|
|
assert.equal(afterExhaust.error, 'code_attempts_exceeded')
|
|||
|
|
|
|||
|
|
// 新码:一次成功、二次被拒(单次使用)
|
|||
|
|
await seedCode(db, config, { email: '[email protected]', username: 'carl', code: '222222' })
|
|||
|
|
const ok = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '222222', ip: null })
|
|||
|
|
assert.equal(ok.ok, true)
|
|||
|
|
const reuse = await consumeRegisterCode(deps, { email: '[email protected]', 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: '[email protected]', username: 'dora', code: '333333',
|
|||
|
|
createdAt: Date.now() - 20 * 60 * 1000, expiresAt: Date.now() - 10 * 60 * 1000,
|
|||
|
|
})
|
|||
|
|
assert.equal(
|
|||
|
|
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'dora', code: '333333', ip: null })).error,
|
|||
|
|
'code_expired',
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
await seedCode(db, config, { email: '[email protected]', username: 'erin', code: '444444' })
|
|||
|
|
assert.equal(
|
|||
|
|
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'someone', code: '444444', ip: null })).error,
|
|||
|
|
'code_username_mismatch',
|
|||
|
|
)
|
|||
|
|
// 大小写不敏感 + 邮箱归一化
|
|||
|
|
const ok = await consumeRegisterCode(deps, { email: '[email protected]', 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: '[email protected]' })
|
|||
|
|
const found = await db.findUserByEmail('[email protected]')
|
|||
|
|
assert.equal(found?.id, 'a')
|
|||
|
|
assert.equal(found?.email, '[email protected]')
|
|||
|
|
assert.equal(await db.findUserByEmail('[email protected]'), undefined)
|
|||
|
|
|
|||
|
|
await assert.rejects(() =>
|
|||
|
|
db.createUser({ id: 'b', username: 'bob', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' }),
|
|||
|
|
)
|
|||
|
|
// 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: '[email protected]', purpose: 'register', codeHash: null, status: 'throttled',
|
|||
|
|
createdAt: now - 40 * 24 * 60 * 60 * 1000,
|
|||
|
|
})
|
|||
|
|
await db.recordEmailCode({
|
|||
|
|
id: 'new', email: '[email protected]', 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('[email protected]', null, 0)
|
|||
|
|
assert.equal(left.emailTotal, 1)
|
|||
|
|
} finally {
|
|||
|
|
await db.close()
|
|||
|
|
}
|
|||
|
|
})
|