// 唤醒仪式【年龄闸】的回归测试(《人工智能拟人化互动服务管理暂行办法》第十四条第三款)。 // // ⚠ 这组测试存在的理由,跟 guardian-gate.test.js 一字不差:commit 7c423f3 把合规闸门整个删掉过 // 一次,【没有任何测试变红】,所以没有人发现。年龄闸落地时同样【一条测试都没有】—— // 验收时做过一次变异实验:把 requireAwakenAdult 从 /voice /seg /reply /name 四个端点上摘掉, // `node --test` 依旧 59/59 全绿。也就是说这道闸当时正处在"下一个人顺手清理掉也没人知道"的状态, // 与它要修的那个历史问题一模一样。这个文件就是补上那一格。 // // 测的是【接线】,不是纯函数: // · 四个烧 AI/做克隆的端点,没有 pass 是不是真的 403(而不是只在前端问一句年份) // · 拿着未成年人的 pass 是不是真的 403 // · /voice /seg 的闸是不是在 multer 【之前】(否则未成年人的 12MB 录音会先被完整收下来才被拒 —— // 既白烧带宽,也等于我们收了一份不该收的未成年人声纹) // · kind 混淆:登录 token / 声音凭据(kind:'awaken') 能不能冒充年龄凭据 // (三者同一个 JWT_SECRET 签发,不查 kind 就等于任意站内 token 都是年龄凭据) // // ⚠ 本组【不需要 MongoDB】:requireAwakenAdult 只读 header,在闸上就返回了,压根走不到 handler。 // 所以它在没有 DB 的 CI 上也会跑、也会红 —— 这正是它该有的样子。 // // ⚠ env 必须在【动态 import 之前】赋值:ESM 的 `import` 声明是提升的,静态 import 会在模块体的 // 任何一行执行【之前】就把 config.js 求值完(config 在模块顶层读 process.env)。所以这里静态只 // import 不读 config 的东西(node:test/assert/express/http),应用侧模块一律走 before() 里的 // `await import()`。与 guardian-gate.test.js 同一口径 —— 这个坑真实发生过。 process.env.NODE_ENV = 'test'; process.env.FEATURE_MINOR_MODE = 'on'; process.env.JWT_SECRET = 'awaken-age-gate-test'; import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import express from 'express'; import http from 'node:http'; let server, base; let mintAgePass, verifyAgePass, signToken, jwt, config; // 取【最小可能年龄】的口径(minor.js minPossibleAge):只有年份时假定生日没到 → 年差 - 1。 const THIS_YEAR = new Date().getFullYear(); const ADULT_YEAR = THIS_YEAR - 30; // 明显成年 const MINOR_YEAR = THIS_YEAR - 10; // 明显未成年 before(async () => { ({ mintAgePass, verifyAgePass } = await import('../src/services/awaken.js')); ({ signToken } = await import('../src/middleware/auth.js')); ({ config } = await import('../src/config.js')); jwt = (await import('jsonwebtoken')).default; const { awakenRouter } = await import('../src/routes/awaken.js'); const app = express(); app.use(express.json()); app.use('/api/awaken', awakenRouter); server = http.createServer(app).listen(0); await new Promise((ok) => server.once('listening', ok)); base = `http://127.0.0.1:${server.address().port}`; }); after(() => { if (server) server.close(); }); const adultPass = () => mintAgePass(ADULT_YEAR).pass; const minorPass = () => mintAgePass(MINOR_YEAR).pass; // 带/不带 pass 打一个 JSON 端点。 const hit = (path, pass) => fetch(`${base}/api/awaken/${path}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(pass ? { 'X-Awaken-Pass': pass } : {}) }, body: JSON.stringify({ said: '你好', name: '测试' }), }); // 带/不带 pass 打一个 multipart 端点(真的塞一段"录音"进去)。 const hitUpload = (path, pass) => { const fd = new FormData(); fd.append('sample', new Blob([new Uint8Array(2048)], { type: 'audio/webm' }), 'a.webm'); return fetch(`${base}/api/awaken/${path}`, { method: 'POST', headers: pass ? { 'X-Awaken-Pass': pass } : {}, body: fd, }); }; // ── 判定口径 ────────────────────────────────────────────────────────────── test('mintAgePass:成年→allowed,未成年→发 pass 但 allowed:false,乱填→报错', () => { const a = mintAgePass(ADULT_YEAR); assert.equal(a.minor, false); assert.equal(a.allowed, true); assert.ok(a.pass, '成年人必须拿到 pass,否则仪式走不下去'); const m = mintAgePass(MINOR_YEAR); assert.equal(m.minor, true); assert.equal(m.allowed, false); // 未成年人【也】发 pass:登录后靠它把声明回填到账号,省得他重填一遍, // 也不给他"这回换个年份说"的机会。那张 pass 过不了下面的闸。 assert.ok(m.pass, '未成年人也要发 pass(用于登录后回填),只是过不了闸'); assert.ok(mintAgePass('').error, '空年份必须报错'); assert.ok(mintAgePass('abcd').error, '非数字必须报错'); assert.ok(mintAgePass(THIS_YEAR + 5).error, '未来年份必须报错'); assert.ok(mintAgePass(1800).error, '离谱年份必须报错'); }); test('边界:取最小可能年龄 —— 宁可多保护一年,不可漏保护一个', () => { // 年差 = 18 的那一年:生日可能还没到 → 仍按未成年处理。 assert.equal(mintAgePass(THIS_YEAR - 18).minor, true, '年差 18 = 可能还没过生日 → 必须仍算未成年'); assert.equal(mintAgePass(THIS_YEAR - 19).minor, false, '年差 19 = 无论生日过没过都已成年'); }); // ── kind 混淆 ───────────────────────────────────────────────────────────── test('★ kind 严校验:登录 token / 声音凭据 / 别的密钥签的,都不能当年龄凭据', () => { assert.equal(verifyAgePass('').error, 'age-required', '空 → 没有凭据'); assert.equal(verifyAgePass('garbage').error, 'age-required', '垃圾串 → 没有凭据'); // 登录 token:同一个 JWT_SECRET 签的,不查 kind 就会被当成年龄凭据。 const loginToken = signToken({ id: '507f1f77bcf86cd799439011', email: 't@e.st' }); assert.equal(verifyAgePass(loginToken).error, 'age-required', '★ 登录 token 不能冒充年龄凭据'); // 声音凭据(kind:'awaken'):同上,而且它是【未成年人也可能持有的】… // 更要紧的是它由 /voice 发放,不该反过来当门票用。 const voiceTicket = jwt.sign({ kind: 'awaken', voiceId: 'v1' }, config.jwt.secret, { expiresIn: '1h' }); assert.equal(verifyAgePass(voiceTicket).error, 'age-required', '★ 声音凭据不能冒充年龄凭据'); // 别的密钥签的 → 验签不过。 const forged = jwt.sign({ kind: 'awaken-age', birthYear: ADULT_YEAR, minor: false }, 'not-the-secret'); assert.equal(verifyAgePass(forged).error, 'age-required', '★ 没有 JWT_SECRET 就伪造不出 pass'); // 过期的。 const expired = jwt.sign({ kind: 'awaken-age', birthYear: ADULT_YEAR, minor: false }, config.jwt.secret, { expiresIn: -10 }); assert.equal(verifyAgePass(expired).error, 'age-required', '过期 pass 必须失效'); }); test('★ minor 不信 payload 里的布尔值,拿 birthYear 现算', () => { // 手改 minor:false 但 birthYear 仍是未成年 —— 签名是我们自己签的(模拟"服务端某天签错了" // 或旧 pass),此时【必须】以 birthYear 为准,不能信那个布尔值。 const lying = jwt.sign({ kind: 'awaken-age', birthYear: MINOR_YEAR, minor: false }, config.jwt.secret, { expiresIn: '1h' }); assert.equal(verifyAgePass(lying).error, 'minor', '★ minor 必须按 birthYear 现算,不能信 payload 的布尔值'); }); test('verifyAgePass:诚实声明未成年的 pass → error:minor(而不是 age-required)', () => { const v = verifyAgePass(minorPass()); assert.equal(v.error, 'minor', '未成年 pass 要能和"没有 pass"区分开 —— 前端要据此显示不同的出路'); assert.equal(v.birthYear, MINOR_YEAR); }); test('成年 pass 能过闸', () => { const v = verifyAgePass(adultPass()); assert.ok(v.ok); assert.equal(v.minor, false); }); // ── 服务端强制(接线)—— 这一段是本文件的核心 ──────────────────────────── // ⚠ 下面每一条 assert 的失败信息都写明了"你可能删掉了什么",别精简。 for (const path of ['reply', 'name']) { test(`★【第十四条第三款·服务端强制】/${path} 没有 pass → 403`, async () => { const res = await hit(path); assert.equal(res.status, 403, `★ /${path} 的 requireAwakenAdult 被摘掉了? 前端闸=没闸,curl 直接就绕过去了`); assert.equal((await res.json()).error, 'awaken-age-required'); }); test(`★【第十四条第三款·服务端强制】/${path} 拿未成年 pass → 403`, async () => { const res = await hit(path, minorPass()); assert.equal(res.status, 403, `★ /${path} 必须拦住未成年人`); const body = await res.json(); assert.equal(body.error, 'awaken-minor-blocked'); // 文案是"陈述 + 出路",不训人 —— 他没做错任何事,是这个功能不对他开放。 assert.match(body.message, /成年人/); }); } for (const path of ['voice', 'seg']) { test(`★【第十四条第三款·服务端强制】/${path} 没有 pass → 403,且不收录音`, async () => { const res = await hitUpload(path); assert.equal(res.status, 403, `★ /${path} 的 requireAwakenAdult 被摘掉了?`); assert.equal((await res.json()).error, 'awaken-age-required'); }); test(`★【闸在 multer 之前】/${path} 拿未成年 pass → 403,不该收下他的声纹`, async () => { const res = await hitUpload(path, minorPass()); assert.equal(res.status, 403, `★ /${path} 必须拦住未成年人`); assert.equal((await res.json()).error, 'awaken-minor-blocked'); }); } test('成年 pass 能真的过闸(闸不是"对谁都 403")', async () => { // /reply 在没有 LLM key 时也会优雅降级返回 200(见 services/awaken.js), // 这里只断言"不是被闸拦下的那两个 403" —— 否则上面那些 403 断言可能只是因为端点整个坏了。 const res = await hit('reply', adultPass()); assert.notEqual(res.status, 403, '★ 成年人被闸拦下了 = 误伤,仪式整个走不下去'); }); // ── 公开的声明端点 ──────────────────────────────────────────────────────── test('POST /age:成年→pass 可用;未成年→200 但 allowed:false;乱填→400', async () => { const ok = await fetch(`${base}/api/awaken/age`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ birthYear: ADULT_YEAR }), }); assert.equal(ok.status, 200); const okBody = await ok.json(); assert.equal(okBody.allowed, true); // 端到端闭环:/age 拿到的 pass 必须真的能过 /reply 的闸。 const used = await hit('reply', okBody.pass); assert.notEqual(used.status, 403, '/age 发的成年 pass 必须能过闸'); const m = await fetch(`${base}/api/awaken/age`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ birthYear: MINOR_YEAR }), }); assert.equal(m.status, 200, '未成年人不是错误,是另一条路 —— 不该 4xx'); assert.equal((await m.json()).allowed, false); const bad = await fetch(`${base}/api/awaken/age`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ birthYear: 'nope' }), }); assert.equal(bad.status, 400); }); test('POST /age/claim 未登录 → 401(回填必须是登录态的事)', async () => { const res = await fetch(`${base}/api/awaken/age/claim`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pass: adultPass() }), }); assert.equal(res.status, 401); });