224 lines
12 KiB
JavaScript
224 lines
12 KiB
JavaScript
// 唤醒仪式【年龄闸】的回归测试(《人工智能拟人化互动服务管理暂行办法》第十四条第三款)。
|
|
//
|
|
// ⚠ 这组测试存在的理由,跟 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);
|
|
});
|