Files
dsh_ai1net_server/test/register-guard.test.mjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

490 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 注册页「邮箱验证码 + 爆破防护」回归测试(档案 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()
}
})