test-isolation-env.test.js 6.59 KB
/**
 * 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 用两级落到 <repo>/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'));
    }
  });
});