/** * 接线断言:「网关把重复 relay_key 判为幂等命中」时,各桥必须**当场拒绝**, * 而不是把那句 `await 决策` 一直等下去。 * * # 为什么需要一条这样的测试 * * 判据本身(`isDuplicateRelay`)在 `lib/relay-key.js` 里有单测 —— 但那次缺口 * 不在判据,而在**接线**:只有 zcode 桥认这个回包,pi/opencode/dsh 把它当成功, * 然后等一个永远不会来的 `permission_decision`,表现为静默挂死。 * 纯函数测试对这种缺口天生无能为力:函数是对的,只是没人调用它。 * * # 为什么读源码而不是跑起来 * * 三个桥的入口都**不是可导入的模块**:pi 的 worker 是子进程、opencode 把入口的 * 每个导出当插件工厂检查、dsh 的入口拉起整个 Cordis 插件。让它们可测需要把 * 这些路径重构成可注入的形状,那是更大的改动面 —— 而这里要钉住的只有一件事: * 「那个判断还在、且落在权限转发这条路上」。 * * 与 `narrow-layout.test.mjs` 同一取舍:**验形态,不验行为**。 * 代价是它对重构敏感(改动这一段的写法时这里会红),这正是它该做的提醒。 * * # 这条测试自己也有判据自检 * * 一个「永远为真」的接线断言比没有更糟:它会让下一个人以为这里被覆盖着。 * 所以下面先证明「拿一段没有该判断的源码喂给它,它会红」。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); /** * 向上找到「装着各桥的那个目录」。 * * 不写死 `../..`:这个文件会被复制到每个桥的 test/ 下,也可能被从别的 cwd 跑。 * 找不到时**必须判红**,不能静默返回空列表 —— 那会让下面每条断言都不执行, * 而结果看起来是「全绿」(这条自检第一次跑就是这么逮住我自己的:过滤后 0 个桥)。 */ function findPluginsDir(start) { let dir = start; for (let i = 0; i < 6; i++) { if (existsSync(join(dir, 'pi-mail-bridge')) && existsSync(join(dir, 'opencode-mail-bridge'))) return dir; const up = dirname(dir); if (up === dir) break; dir = up; } return null; } const PLUGINS_DIR = findPluginsDir(HERE); /** * 各桥的入口文件与它自己的「拒绝」写法。 * * 三家的返回形状**各不相同**(pi 是 `{block:true, reason}`、opencode 是 * `output.status = "deny"`、dsh 是返回 DSH 的 ApprovalOutcome 字符串 `'rejected'`), * 所以这里逐个列出来 —— 我第一版给 dsh 写成了 opencode 的形状, * 而 `tsc` **没报错**(返回类型是宽联合),只有对着邻居读才发现。 */ const BRIDGES = [ { name: 'pi-mail-bridge', source: 'src/worker.mjs', // pi 在同一个分支里既判又拒 branch: /if\s*\(\s*isDuplicateRelay\(/, denyShape: /block:\s*true/, denyShapeDesc: 'block: true' }, { name: 'opencode-mail-bridge', source: 'index.js', branch: /if\s*\(\s*isDuplicateRelay\(/, denyShape: /output\.status\s*=\s*"deny"/, denyShapeDesc: 'output.status = "deny"' }, { name: 'dsh-mail-bridge', source: 'src/index.ts', // dsh 是**两段式**:.then 里把重复转成异常抛出去,catch 的 duplicateRelay // 分支里才写拒绝。所以「分支」要取 catch 那一段(第一版取到了 .then 那段, // 于是永远找不到 return 'rejected' —— 是判据错了,不是代码错了)。 branch: /if\s*\(\s*e\?\.duplicateRelay\s*\)/, denyShape: /return\s+'rejected'/, denyShapeDesc: "return 'rejected'", // 同时要求它确实认得这个回包(.then 那段) alsoNeeds: /isDuplicateRelay\(/ } ].filter(b => PLUGINS_DIR && existsSync(join(PLUGINS_DIR, b.name))); /** 取出「重复回包 → 拒绝」那个分支的源码片段(从 if 到下一个同级收尾)。 */ export function duplicateBranchOf(source, branch) { const m = source.match(branch || /if\s*\(\s*isDuplicateRelay\(/); if (!m) return null; const start = m.index; let depth = 0; let i = source.indexOf('{', start); if (i < 0) return null; for (let j = i; j < source.length; j++) { if (source[j] === '{') depth++; else if (source[j] === '}') { depth--; if (depth === 0) return source.slice(start, j + 1); } } return null; } /** 权限转发必须**先**判重复:判据要出现在等决策之前。 */ export function handlerChecksDuplicateFirst(source, denyShape, branchPattern) { const branch = duplicateBranchOf(source, branchPattern); if (!branch) return { ok: false, why: '源码里找不到处理重复回包的分支' }; if (!denyShape.test(branch)) { return { ok: false, why: `重复分支里没有出现本桥的拒绝形状(期望 ${denyShape})` }; } return { ok: true, why: '' }; } test('★ 判据自检:拿没有该判断的源码喂进来必须判红', () => { const PI = /if\s*\(\s*isDuplicateRelay\(/; // ① 找不到分支 → 红 assert.equal(handlerChecksDuplicateFirst('const x = 1;', /block:\s*true/, PI).ok, false); // ② 有分支但没有拒绝形状 → 红 assert.equal( handlerChecksDuplicateFirst('if (isDuplicateRelay(accepted)) {\n console.error("重复");\n}', /block:\s*true/, PI).ok, false ); // 反向对照:形状写对时判绿(否则上面两条可能只是因为断言恒假) assert.equal( handlerChecksDuplicateFirst( 'if (isDuplicateRelay(accepted)) {\n return { block: true, reason: "x" };\n}', /block:\s*true/, PI ).ok, true ); // dsh 那种两段式形态:branch 正则必须能取到 catch 那一段 const DSH = /if\s*\(\s*e\?\.duplicateRelay\s*\)/; assert.equal( handlerChecksDuplicateFirst( "if (e?.duplicateRelay) {\n noteDenial(a, b, 'x');\n return 'rejected';\n}", /return\s+'rejected'/, DSH ).ok, true ); // 同一段源码,用错 branch 正则(取到 .then 那段)应当判红 —— 这正是第一版 assert.equal( handlerChecksDuplicateFirst( "if (isDuplicateRelay(accepted)) {\n throw new Error('dup');\n}", /return\s+'rejected'/, DSH ).ok, false ); }); test('★ 判据自检:源码位置读取本身是真的(不是空跑)', () => { // 若过滤逻辑把所有桥都滤掉了,下面的循环会一条都不跑而「全绿」—— // 这条自检第一次跑就是这么逮住我自己的(路径写错 → 0 个桥)。 assert.ok(PLUGINS_DIR, `找不到装着各桥的目录(从 ${HERE} 向上找了 6 层)`); assert.ok(BRIDGES.length >= 3, `应当至少覆盖三个桥,实际 ${BRIDGES.length}`); }); for (const b of BRIDGES) { test(`★ ${b.name}:权限转发遇到 duplicate_relay 当场拒绝(而不是等一个不会来的决策)`, () => { const file = join(PLUGINS_DIR, b.name, b.source); assert.ok(existsSync(file), `找不到入口:${file}`); const src = readFileSync(file, 'utf8'); // ① 必须认得这个回包(导入并在权限路径上用) assert.match( src, /isDuplicateRelay/, `${b.name} 没有认 duplicate_relay —— 网关对重复键回 HTTP 200 并提前返回,` + '不认它就会一直等 permission_decision,表现为静默挂死' ); // ② 该判断必须落在权限转发这条路上,并且给出本桥形状的拒绝 const r = handlerChecksDuplicateFirst(src, b.denyShape, b.branch); assert.ok(r.ok, `${b.name}:${r.why}(期望 ${b.denyShapeDesc})`); // ③ 两段式桥(dsh)还要认得这个回包 if (b.alsoNeeds) { assert.match(src, b.alsoNeeds, `${b.name}:没有认 duplicate_relay 回包`); } // ④ 顺序:判据必须在**发出请求之后**。 // 第一版写成「在文件里最早的 await 之前」,量到的是别处的等待, // 于是三个桥全红 —— 判据量错了对象,不是代码错了。 const postAt = src.indexOf('/permission/request'); const dupAt = src.search(b.branch); assert.ok(postAt > 0, `${b.name}:找不到权限上报调用`); assert.ok(dupAt > postAt, `${b.name}:重复判据出现在上报之前(${dupAt} < ${postAt})`); }); }