// 家长监护的【强制闸】回归测试(《人工智能拟人化互动服务管理暂行办法》第十四条第二款)。 // // ⚠ 这组测试存在的理由跟代码本身一样重要:commit 7c423f3 把这些闸门整个删掉过一次, // 没有任何测试变红,所以没有人发现。删一个纯函数会被 guardian.test.js 拦住,但 // 「把 requireAdult 从 wallet 路由上摘掉」「gateAsk 里不再查屏蔽」这类删法,纯函数测试 // 一条都拦不住 —— 它们只在【接线】上体现。所以这里测的是接线: // · 未成年人模式的账号,钱包是不是真的 403(限制充值消费) // · 监护人屏蔽了某个分身后,gateAsk 是不是真的拦(屏蔽特定角色) // · 成年人是不是完全不受影响(不误伤 —— 误伤了运营就会把整个功能关掉) // 哪天有人再"顺手清理"一次,这里会红。 // // 需要本地 MongoDB;连不上则整组 skip(不阻塞无 DB 的 CI),与 store-idor.test.js 同一口径。 process.env.NODE_ENV = 'test'; process.env.MONGO_DB = process.env.MONGO_DB_TEST || 'digital_me_test'; process.env.FEATURE_MINOR_MODE = 'on'; process.env.MINOR_DAILY_LIMIT_MIN = process.env.MINOR_DAILY_LIMIT_MIN || '60'; process.env.JWT_SECRET = process.env.JWT_SECRET || 'guardian-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, db, closeDb, mongoUp = true; let ObjectId, signToken, gateAsk, G; let kid, adult, twinA, twinB; before(async () => { const dbm = await import('../src/db.js'); closeDb = dbm.closeDb; try { db = await dbm.connectDb(); } catch { mongoUp = false; return; } ({ ObjectId } = await import('mongodb')); ({ signToken } = await import('../src/middleware/auth.js')); ({ gateAsk } = await import('../src/services/minor.js')); G = await import('../src/services/guardian.js'); const { walletRouter } = await import('../src/routes/wallet.js'); const app = express(); app.use(express.json()); app.use('/api/wallet', walletRouter); server = http.createServer(app).listen(0); await new Promise((ok) => server.once('listening', ok)); base = `http://127.0.0.1:${server.address().port}`; kid = new ObjectId(); adult = new ObjectId(); twinA = new ObjectId(); twinB = new ObjectId(); await db.collection('users').insertOne({ _id: kid, name: '测试未成年人', birthYear: 2014, minorMode: 'on', minorLocked: true, // 不满 14 周岁 → 没有监护人同意的话 gateAsk 会先被第十四条第三款那道闸拦下, // 就测不到本组要测的屏蔽闸了。这里补上,把测试对准要测的东西。 guardianConsent: { at: new Date(), level: 'attested' }, }); await db.collection('users').insertOne({ _id: adult, name: '测试成年人' }); await db.collection('twins').insertOne({ _id: twinA, displayName: '测试分身A', slug: `t-a-${Date.now()}` }); await db.collection('twins').insertOne({ _id: twinB, displayName: '测试分身B', slug: `t-b-${Date.now()}` }); }); after(async () => { if (server) server.close(); if (mongoUp && db) { const ids = [kid, adult].filter(Boolean); if (ids.length) await db.collection('users').deleteMany({ _id: { $in: ids } }); const tids = [twinA, twinB].filter(Boolean); if (tids.length) await db.collection('twins').deleteMany({ _id: { $in: tids } }); if (kid) { await db.collection('guardian_blocks').deleteMany({ wardId: kid }); await db.collection('minor_usage').deleteMany({ userId: kid }); } } if (mongoUp && closeDb) await closeDb(); }); const walletGet = async (id) => fetch(`${base}/api/wallet/`, { headers: { authorization: `Bearer ${signToken({ id: id.toString(), email: 't@e.st' })}` } }); // ── 限制充值消费 ── test('【第十四条第二款·限制充值消费】未成年人模式的账号:钱包 403', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); const res = await walletGet(kid); const body = await res.json(); assert.equal(res.status, 403, 'requireAdult 被从 walletRouter 上摘掉了?'); assert.equal(body.error, 'minor-spending-blocked'); // 前端 errMsg 优先取 data.message —— 只回英文码的话用户看到的是一串机器码。 assert.match(body.message, /未成年人模式/); }); test('不误伤:成年账号钱包照常 200', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); const res = await walletGet(adult); assert.equal(res.status, 200, '误伤成年人 = 运营会把整个未成年人模式关掉,那才是最坏结局'); }); // ── 屏蔽特定角色 ── test('【第十四条第二款·屏蔽特定角色】屏蔽后 gateAsk 服务端硬拦,解除后恢复', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); await db.collection('guardian_blocks').deleteMany({ wardId: kid }); const before_ = await gateAsk(kid.toString(), '你好啊', twinA); assert.ok(!before_.blocked, '未屏蔽时应正常放行'); await G.blockTwin(adult.toString(), kid.toString(), twinA.toString()); const after_ = await gateAsk(kid.toString(), '你好啊', twinA); assert.ok(after_.blocked, '★ 屏蔽后必须拦住 —— 前端藏起来是没用的,curl 直接就绕过去了'); assert.equal(after_.code, 'guardian-twin-blocked'); const other = await gateAsk(kid.toString(), '你好啊', twinB); assert.ok(!other.blocked, '没被屏蔽的分身不能受影响'); await G.unblockTwin(kid.toString(), twinA.toString()); const restored = await gateAsk(kid.toString(), '你好啊', twinA); assert.ok(!restored.blocked, '解除屏蔽后必须恢复'); }); test('屏蔽是幂等的(唯一索引 + 11000 当成功):重复点不报错、不产生第二条', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); await db.collection('guardian_blocks').deleteMany({ wardId: kid }); await G.blockTwin(adult.toString(), kid.toString(), twinA.toString()); await G.blockTwin(adult.toString(), kid.toString(), twinA.toString()); assert.equal((await G.listBlocks(kid.toString())).length, 1); await db.collection('guardian_blocks').deleteMany({ wardId: kid }); }); test('不误伤:成年人(未开未成年人模式)即使库里有屏蔽记录也一律放行', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); await db.collection('guardian_blocks').insertOne({ wardId: adult, twinId: twinA, guardianId: kid, createdAt: new Date() }); const g = await gateAsk(adult.toString(), '你好', twinA); assert.ok(!g.blocked && !g.notice, 'minorMode 未开时 gateAsk 应在读到 mode 后立刻返回,连屏蔽都不查'); await db.collection('guardian_blocks').deleteMany({ wardId: adult }); }); // ── 针对不同年龄段的时长上限 ── test('【第十四条第二款·针对不同年龄段保护需要】监护人只能把上限调得更严,不能更松', async (t) => { if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试'); const { getMinorState } = await import('../src/services/minor.js'); await G.setWardDailyLimit(kid.toString(), 20); assert.equal((await getMinorState(kid.toString())).dailyLimitMin, 20, '家长设的更严的值必须生效'); await G.setWardDailyLimit(kid.toString(), 999); assert.equal( (await getMinorState(kid.toString())).dailyLimitMin, 60, '★ 家长不能把上限调得比平台默认更松 —— 否则等于给每个家长发了一个"关掉时长限制"的按钮' ); await G.setWardDailyLimit(kid.toString(), 0); assert.equal((await getMinorState(kid.toString())).dailyLimitMin, 60, '设 0 = 取消覆盖,回落平台默认'); });