/** * Unit Tests — 防漂移闸:**测试用来隔离的路径 env,生产代码必须真的读它** * * 挡的是一类已经实际造成数据污染的缺陷,而且是最有欺骗性的那种形态: * 测试在 beforeAll 里 `process.env.XXX_DB_PATH = '/tmp/...'`,以为自己隔离了, * 而生产代码那一行压根不读这个变量 —— 于是数据全写进**共享的生产库**, * 测试照样全绿,afterAll 删的那个 /tmp 文件从来没被创建过、所以清理还“成功”了。 * * 已经发生过三次: * - `BENCHMARK_DB_PATH`:两个测试文件设它,benchmark-db.js 不读 → * 共享 benchmark 库攒了 342 行 skill_name=benchmark_test * - `USERS_DB_PATH`:根本没有这个覆盖口,user-db.test.js 只能直写共享用户库 → * 357 个用户里 356 个是测试夹具(178 次运行),真人只有 1 个 * - `SKILLS_DIR`:**变量名写错**,config.js 读的是 `SKILLS_CODE_DIR` → * skillsCodeDir 一直指向那个 2.7 万条的真实共享 skills 目录 * * 这就是本项目反复出现的「产生 → 传递 → 落库 → 读回」漏掉一环: * 测试把值**产生**出来了,没有人**读回**。而它的隐蔽性在于—— * 缺了读回那一环,测试不会红,只有共享数据在悄悄变脏。 * * ── 规则 ──────────────────────────────────────────────────────── * 只管**路径类**变量(名字以 _DIR / _PATH / _ROOT 结尾):那是会造成 * 静默数据污染的地方。`HOME`(由 os.homedir() 间接读)、`JWT_SECRET`、`PORT` * 这类非路径变量天然不在范围内,不需要维护白名单 —— 白名单会随时间腐烂, * 而后缀规则不会。 * * ponytail: 静态正则扫源码,不 require 任何模块(require 会拉起 sqlite 连接)。 * 上限:只认 `process.env.X` 这种字面写法,动态取(`process.env[name]`)查不出来; * 也不检查测试是否**在 require 之前**设 env(顺序错了同样静默失效,那一环靠各文件 * 自己的「写的是隔离库」断言兜——见 user-db.test.js)。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const PATH_ENV = /^[A-Z0-9_]*(_DIR|_PATH|_ROOT)$/; function walk(dir, out = []) { if (!fs.existsSync(dir)) return out; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.name === 'node_modules') continue; const p = path.join(dir, e.name); if (e.isDirectory()) walk(p, out); else if (p.endsWith('.js')) out.push(p); } return out; } /** * 剥掉注释再扫。 * * 不剥的话本文件的文档注释里那句举例(`process.env.XXX_DB_PATH = ...`)会被当成真的赋值, * guard 自己把自己判红 —— 实测踩过。凡是「静态扫源码」的检查都要先想清楚 * **该量的是代码还是文本**:这里该量的是会执行的代码。 */ function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, '') .split('\n') .map((l) => l.replace(/\/\/.*$/, '')) .join('\n'); } /** 测试里被**赋值**的 env 名 → 哪些文件在设它 */ function assignedInTests() { const found = new Map(); for (const f of walk(path.join(ROOT, 'server/tests'))) { const src = stripComments(fs.readFileSync(f, 'utf-8')); for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)\s*=(?!=)/g)) { if (!found.has(m[1])) found.set(m[1], new Set()); found.get(m[1]).add(path.relative(ROOT, f)); } } return found; } /** 生产代码里被**读取**的 env 名 */ function readInProduction() { const dirs = ['server/services', 'server/src', 'server/routes', 'server/middleware', 'packages']; const files = dirs.flatMap((d) => walk(path.join(ROOT, d))); files.push(path.join(ROOT, 'server/config.js'), path.join(ROOT, 'server/index.js')); const found = new Set(); for (const f of files) { if (!fs.existsSync(f)) continue; // 生产侧同样剥注释:注释里提到一个变量名不等于代码读了它 // (这一侧漏剥的方向更坏 —— 会把「其实没人读」误判成「有人读」,guard 变空跑) const src = stripComments(fs.readFileSync(f, 'utf-8')); for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)/g)) { // 生产代码里自己赋值不算「读」(几乎没有,但别让它自证) const after = src.slice(m.index + m[0].length, m.index + m[0].length + 3); if (/^\s*=(?!=)/.test(after)) continue; found.add(m[1]); } } return found; } describe('测试隔离:路径类 env 必须有生产读者', () => { const assigned = assignedInTests(); const read = readInProduction(); it('两侧都不是空的(防止 guard 因为路径/正则失效而静默空跑)', () => { expect(assigned.size).toBeGreaterThan(3); expect(read.size).toBeGreaterThan(3); // 已知一定存在的两个,任一消失说明扫描口径坏了 expect([...assigned.keys()]).toContain('GRAPH_DB_PATH'); expect(read.has('GRAPH_DB_PATH')).toBe(true); }); it('测试用来隔离的每个 _DIR/_PATH/_ROOT,生产代码里都有人读', () => { const orphans = [...assigned] .filter(([name]) => PATH_ENV.test(name)) .filter(([name]) => !read.has(name)) .map(([name, files]) => `${name} —— 设于 ${[...files].join(', ')},但生产代码零处读取`); // 报出全部而不是第一个:一次修完比来回跑三轮省事 expect(orphans).toEqual([]); }); it('共享数据目录在仓库外,这一点是有意的(顺带钉住,免得被“修”回仓库内)', () => { // user-db / skill-db / benchmark-db 用三级上跳落到 ~lemonAI/data(跨兄弟仓库共享); // graph-db / company-service 用两级落到 /data(本仓库租户数据)。 // 深度随文件所在目录变化,改动时容易改错方向,所以这里钉住两组的实际去向。 const shared = ['server/services/user-db.js', 'server/services/skill-db.js', 'server/services/benchmark-db.js']; const local = ['server/services/graph-db.js', 'server/services/company-service.js']; for (const rel of shared) { const resolved = path.resolve(path.dirname(path.join(ROOT, rel)), '../../../data'); expect(resolved).toBe(path.resolve(ROOT, '../data')); } for (const rel of local) { const resolved = path.resolve(path.dirname(path.join(ROOT, rel)), '../../data'); expect(resolved).toBe(path.join(ROOT, 'data')); } }); });