feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 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。
This commit is contained in:
1 parent
d2ef362a98
commit
971ccc3703
56 files changed
+3498
-193
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。
|
||||
*
|
||||
* 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力枢纽」/
|
||||
* 英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文**
|
||||
* —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。
|
||||
*
|
||||
* 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑),
|
||||
* 配一个够用的最小 DOM 桩,断言六种语言解析路径下的**实际渲染结果**。
|
||||
*
|
||||
* ⚠️ 桩里必须注入 `URLSearchParams` / `URL`:`vm` 上下文只含 ECMAScript 内置、
|
||||
* 不含 Web API;少了它们 `new URLSearchParams(location.search)` 会抛 ReferenceError,
|
||||
* 被 i18n.js 的 try/catch 吞掉 ⇒ 表现为"`?lang=` 不生效"的**假失败**(第一版踩过)。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const SRC = readFileSync(new URL('../web/i18n.js', import.meta.url), 'utf8')
|
||||
|
||||
/** 造一个够 i18n.js 用的最小元素桩(只需要 getAttribute / textContent)。 */
|
||||
function makeEl(attrs = {}) {
|
||||
return {
|
||||
_attrs: { ...attrs },
|
||||
textContent: attrs.__text ?? '',
|
||||
className: '',
|
||||
innerHTML: '',
|
||||
style: {},
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = value
|
||||
},
|
||||
appendChild() {},
|
||||
addEventListener() {},
|
||||
querySelectorAll() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 在最小 DOM 桩里跑一次 i18n.js,返回品牌节点的实际渲染结果与解析出的语言。 */
|
||||
function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) {
|
||||
const brandNode = makeEl({ 'data-i18n': 'brand.name', __text: 'CapabilityNet' })
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout: () => {},
|
||||
URLSearchParams,
|
||||
URL,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
cookie,
|
||||
documentElement: makeEl(),
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-i18n]' ? [brandNode] : []
|
||||
},
|
||||
createElement: () => makeEl(),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
navigator: { language, languages: [language] },
|
||||
location: { search, href: `https://ai1net.com/login.html${search}`, reload: () => {} },
|
||||
window: {},
|
||||
}
|
||||
sandbox.window = sandbox
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(SRC, sandbox, { filename: 'web/i18n.js' })
|
||||
return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang }
|
||||
}
|
||||
|
||||
test('品牌文案:中文显示「能力枢纽」,英文及其他语言显示 CapabilityNet', () => {
|
||||
const cases = [
|
||||
['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'],
|
||||
['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力枢纽'],
|
||||
['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力枢纽'],
|
||||
['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'],
|
||||
['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力枢纽'],
|
||||
['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'],
|
||||
]
|
||||
for (const [label, opts, expected] of cases) {
|
||||
const got = renderBrand(opts)
|
||||
assert.equal(got.brand, expected, label)
|
||||
}
|
||||
})
|
||||
|
||||
test('品牌文案:不会退化成裸 key、不留空', () => {
|
||||
for (const language of ['en-US', 'zh-CN', 'ja-JP', 'de-DE']) {
|
||||
const { brand } = renderBrand({ language })
|
||||
assert.notEqual(brand, 'brand.name', `裸 key 泄露(${language})`)
|
||||
assert.ok(brand.trim() !== '', `空文案(${language})`)
|
||||
}
|
||||
})
|
||||
@@ -126,7 +126,7 @@ function refusingFetch(calls) {
|
||||
|
||||
test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
// 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径
|
||||
assert.equal(directoryUrlFor('https://alotbuy.com/dshs-relay'), `https://alotbuy.com${DIRECTORY_PATH}`)
|
||||
assert.equal(directoryUrlFor('https://ai1net.com/dshs-relay'), `https://ai1net.com${DIRECTORY_PATH}`)
|
||||
assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`)
|
||||
// 已经是目录地址 ⇒ 原样
|
||||
assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`)
|
||||
@@ -134,7 +134,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`)
|
||||
|
||||
// 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据)
|
||||
assert.equal(toRelayUrl('https://alotbuy.com/dshs-relay'), 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(toRelayUrl('https://ai1net.com/dshs-relay'), 'wss://ai1net.com/dshs-relay')
|
||||
assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay')
|
||||
assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x')
|
||||
assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝')
|
||||
@@ -178,20 +178,20 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
'http://100.64.7.7/dshs-relay',
|
||||
'http://relaybox/dshs-relay',
|
||||
'http://[::1]/dshs-relay',
|
||||
'https://alotbuy.com/dshs-relay',
|
||||
'https://ai1net.com/dshs-relay',
|
||||
'https://relay.example.com/dshs-relay',
|
||||
]),
|
||||
['https://alotbuy.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
|
||||
['https://ai1net.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
|
||||
)
|
||||
|
||||
// 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本)
|
||||
assert.equal(DEFAULT_OVERLAY_SEED, 'https://alotbuy.com/dshs-relay')
|
||||
assert.equal(DEFAULT_OVERLAY_SEED, 'https://ai1net.com/dshs-relay')
|
||||
|
||||
// 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443)
|
||||
// ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继)
|
||||
assert.deepEqual(
|
||||
publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']),
|
||||
['wss://alotbuy.com/dshs-relay'],
|
||||
publicRelayEntries(['wss://ai1net.com/dshs-relay', 'https://ai1net.com/dshs-relay']),
|
||||
['wss://ai1net.com/dshs-relay'],
|
||||
)
|
||||
// …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留
|
||||
assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [
|
||||
@@ -210,7 +210,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => {
|
||||
const keys = makeKeys()
|
||||
const other = makeKeys()
|
||||
const origin = 'https://alotbuy.com/dshs-relay'
|
||||
const origin = 'https://ai1net.com/dshs-relay'
|
||||
const { doc, sig } = signedDoc(origin, keys.privatePem)
|
||||
|
||||
// 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验
|
||||
@@ -248,7 +248,7 @@ test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律
|
||||
|
||||
test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => {
|
||||
const keys = makeKeys()
|
||||
const seed = 'https://alotbuy.com/dshs-relay'
|
||||
const seed = 'https://ai1net.com/dshs-relay'
|
||||
|
||||
// ① env 显式 ⇒ 压制引导链(**一次网络都不发**)
|
||||
{
|
||||
@@ -279,7 +279,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch(calls),
|
||||
})
|
||||
assert.equal(r.source, 'cache')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.deepEqual(calls, [], '新鲜缓存不许联网')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -298,7 +298,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch(calls),
|
||||
})
|
||||
assert.equal(r.source, 'seed-fallback')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.ok(calls.length >= 1, '应当尝试过取目录')
|
||||
assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存')
|
||||
} finally {
|
||||
@@ -320,7 +320,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch([]),
|
||||
})
|
||||
assert.equal(r.source, 'stale-cache')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
// 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签)
|
||||
assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined)
|
||||
} finally {
|
||||
@@ -685,8 +685,8 @@ test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => {
|
||||
* **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」——
|
||||
* `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。
|
||||
*/
|
||||
const FB_MAIN = 'https://alotbuy.com/dshs-relay'
|
||||
const FB_ALT = 'https://relay-direct.alotbuy.com/dshs-relay'
|
||||
const FB_MAIN = 'https://ai1net.com/dshs-relay'
|
||||
const FB_ALT = 'https://relay-direct.ai1net.com/dshs-relay'
|
||||
|
||||
/** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */
|
||||
function twoEntryDoc(keys) {
|
||||
@@ -704,7 +704,7 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
|
||||
const logs = []
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(String(url))
|
||||
if (String(url).startsWith('https://alotbuy.com/')) throw new Error('cf unreachable')
|
||||
if (String(url).startsWith('https://ai1net.com/')) throw new Error('cf unreachable')
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
const r = await resolveOverlayRelay({
|
||||
@@ -717,14 +717,14 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
|
||||
// ① 逐个 origin 试,主 origin 被拒后才到兜底
|
||||
assert.equal(calls.length, 2)
|
||||
assert.ok(
|
||||
logs.some((l) => l.includes('拒绝 https://alotbuy.com/dshs-overlay/bootstrap')),
|
||||
logs.some((l) => l.includes('拒绝 https://ai1net.com/dshs-overlay/bootstrap')),
|
||||
'缺"逐 origin 拒绝原因"这一行',
|
||||
)
|
||||
// ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据)
|
||||
assert.equal(r.source, 'seed-directory')
|
||||
assert.equal(r.url, 'wss://relay-direct.alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://relay-direct.ai1net.com/dshs-relay')
|
||||
assert.ok(
|
||||
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.alotbuy.com/dshs-relay')),
|
||||
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.ai1net.com/dshs-relay')),
|
||||
'缺"为什么走了兜底"这一行(可解释性)',
|
||||
)
|
||||
})
|
||||
@@ -742,6 +742,6 @@ test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首
|
||||
fetchImpl,
|
||||
log: (l) => logs.push(l),
|
||||
})
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志')
|
||||
})
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 注册页「邮箱验证码 + 爆破防护」回归测试(档案 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()
|
||||
}
|
||||
})
|
||||
@@ -488,7 +488,7 @@ const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_
|
||||
/**
|
||||
* F12 · **目录路径没有豁免权**(E1 / D1)。
|
||||
*
|
||||
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://alotbuy.com…` —— 只因"目录里的地址变了"
|
||||
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://ai1net.com…` —— 只因"目录里的地址变了"
|
||||
* 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。
|
||||
*/
|
||||
test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => {
|
||||
@@ -858,8 +858,8 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts
|
||||
const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0)
|
||||
obs.record(
|
||||
[
|
||||
'wss://alotbuy.com/dshs-relay',
|
||||
'https://alotbuy.com/other',
|
||||
'wss://ai1net.com/dshs-relay',
|
||||
'https://ai1net.com/other',
|
||||
'wss://106.54.21.172/dshs-relay',
|
||||
],
|
||||
'cache',
|
||||
@@ -876,7 +876,7 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts
|
||||
)
|
||||
const snap = obs.snapshot()
|
||||
assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)')
|
||||
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(alotbuy.com 的两条算同一台 —— scheme 不参与)')
|
||||
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(ai1net.com 的两条算同一台 —— scheme 不参与)')
|
||||
assert.equal(snap.source, 'cache')
|
||||
assert.equal(snap.resolves, 1)
|
||||
assert.equal(snap.unresolved, false)
|
||||
|
||||
Reference in new issue
Block a user