test-isolation-env.test.js
6.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/**
* 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'));
}
});
});