三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 134 · 注册页人机验证 + 邮箱验证码】 - DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引) - 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts - routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code; 注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为) - 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF) - 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯 (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定 - Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的, 只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的 - 新增 test/register-guard.test.mjs(19 用例) 【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】 - login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形 → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name) - portal 顶栏换图标(页面名「管理门户」保留) - 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它 - 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果) - scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG 解析失败、图标静默不显示 —— 实际踩到过) - 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束) 【档案 135/136 · 域名迁移线(另一会话产出)】 - 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置 - src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新; src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs - 档案 136 = 控制面按两台中继取并集(**已立项、未落地**) 验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped| verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、 发码 delivered、四页 deepseek 命中 0、favicon 200。
490 lines
22 KiB
JavaScript
490 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: 'AI1NET' })
|
||
assert.ok(subject.includes('123456'))
|
||
assert.ok(subject.includes('AI1NET'))
|
||
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: 'AI1NET',
|
||
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: ['ai1net.com'], 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: 'ai1net.com' },
|
||
'/badaction': { success: true, action: 'login', hostname: 'ai1net.com' },
|
||
'/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: ['ai1net.com', 'www.ai1net.com'],
|
||
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://AI1net.com/', 'www.ai1net.com:443', ' ai1net.com ']), [
|
||
'ai1net.com', 'www.ai1net.com',
|
||
])
|
||
// 未配(undefined)⇒ 从 baseDomain 派生
|
||
assert.deepEqual(resolveTurnstileHostnames(undefined, 'ai1net.com'), ['ai1net.com', 'www.ai1net.com'])
|
||
// 显式配 ⇒ 只用配的(可加旧域,旧域门户仍在线)
|
||
assert.deepEqual(
|
||
resolveTurnstileHostnames(['ai1net.com', 'alotbuy.com'], 'ai1net.com'),
|
||
['ai1net.com', 'alotbuy.com'],
|
||
)
|
||
// 显式空 ⇒ 关闭(不派生)
|
||
assert.deepEqual(resolveTurnstileHostnames([], 'ai1net.com'), [])
|
||
// baseDomain 空 ⇒ 空(= 未配置完成 ⇒ 人机验证停用,不会静默放开)
|
||
assert.deepEqual(resolveTurnstileHostnames(undefined, ''), [])
|
||
assert.equal(normalizeHostnames(['localhost']).includes('ai1net.com'), 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()
|
||
}
|
||
})
|