Commit 20fed0a5673f8ee29fc431c5f5c82fc82e768afe
1 parent
c3a5d462
fix(test-isolation): 测试不再往生产图库写,并加运行期闸门堵住这一类
## 病症 agent-chat-tenant-isolation.test.js 每跑一次,往共享生产库 data/graph.sqlite 落 2 条 goal + 2 条 task。实测 29→31。库里 31 条 goal 里 **22 条是它的残留**, 测试期间一直全绿。 根因是批 5 的存储迁移留下的半截清理:/task/dispatch 的元数据从 system/tasklog/*.json 换进了 tasks 表,而这个用例的 afterAll 只会清那个目录 —— 清理代码还在、清的东西已经不在那儿了。 ## 这是第四次,但换了个方向进来 前三次(BENCHMARK_DB_PATH / USERS_DB_PATH / SKILLS_DIR,记在 test-isolation-env.test.js 文件头)都是「设了 env,生产代码不读」。 那条守卫查的正是这个形态。 这次是**一个字都没设** —— 没有可查的东西,守卫自然查不出来。 静态扫源码也判不出「这个测试会不会开库」(要跨 require 链推导)。 ## 修法 1. 用例自己开一次性库(fs.mkdtempSync + GRAPH_DB_PATH),afterAll 连目录删掉。 赋值必须在 require 路由**之前** —— graph-db.js 的 DB_PATH 是模块级 const。 2. graph-db.getDb() 加运行期闸门:VITEST 为真且没设 GRAPH_DB_PATH → 抛, 报错里点名 env、点明「要在 require 之前设」。 放在 getDb() 不放模块顶层:只为取常量而 require 是合法用法 (goal-task-model.test.js 就这么用),顶层抛会误伤。真正开库才是越界那一刻。 3. 守卫 graph-db-prod-guard.test.js,4 例:撞库要抛且报错可照做、设了 env 要能开、 没有 VITEST 时不生效(防止把生产一起锁死)、样本非空。 反向验过:注释掉闸门那一行,第 1 例立刻红。 ## 顺带修一条 flaky(我自己上一版留下的) agent-docs-port.test.js 先 walk 出文件清单再逐个读,中间别的用例把 zz-* 夹具的 运行态目录清掉 → ENOENT。**报错长得像端口违例,其实跟端口无关**,实测 8 次跑中 2 次红。 只放过 ENOENT 一种(文件没了),其它读错误照抛 —— 一律 try/catch 吞掉的话, 权限错、编码错会被当成「无违例」。 ## 存量清理 删掉生产库里 created_by LIKE 'zz-tenant-%' 的 22 goal + 22 task (备份 /tmp/graph.sqlite.bak-c3a5d46)。零 run 引用它们。 其余 9 条 goal 是真实数据,一条没动。 全量:136 文件 / 1276 测试,连跑 8 次全绿(改之前 8 次里红 2 次)。 跑完后生产库行数零增量。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
4 changed files
with
145 additions
and
13 deletions
server/services/graph-db.js
| ... | ... | @@ -216,8 +216,33 @@ function createEdgesTable(database) { |
| 216 | 216 | `); |
| 217 | 217 | } |
| 218 | 218 | |
| 219 | +/** | |
| 220 | + * 测试进程打开**共享生产库**时直接拦下来。 | |
| 221 | + * | |
| 222 | + * 挡的是「测试压根没设隔离 env」这个形态 —— `test-isolation-env.test.js` 挡不住它: | |
| 223 | + * 那条守卫查的是「设了 XXX_PATH、生产代码却不读」,一个字都没设的文件没有可查的东西。 | |
| 224 | + * 实际后果:`agent-chat-tenant-isolation.test.js` 每跑一次往 `data/graph.sqlite` 落 | |
| 225 | + * 2 条 goal + 2 条 task,攒到 25 条 `_global` goal 才被人翻库翻出来,期间测试一直全绿。 | |
| 226 | + * | |
| 227 | + * 为什么放在 getDb() 而不是模块顶层:`require('work-graph-db')` 只为取一个常量是合法的 | |
| 228 | + * (goal-task-model.test.js 就这么用),顶层抛会误伤。**真正开库**才是越界那一刻。 | |
| 229 | + * | |
| 230 | + * 需要读真库的测试显式写 `process.env.GRAPH_DB_PATH = <真库路径>` 表明意图即可。 | |
| 231 | + */ | |
| 232 | +function assertNotProdDbInTests() { | |
| 233 | + if (!process.env.VITEST) return; | |
| 234 | + if (process.env.GRAPH_DB_PATH) return; | |
| 235 | + throw new Error( | |
| 236 | + `[graph-db] 测试进程试图打开共享生产库 ${DB_PATH}。\n` + | |
| 237 | + `请在 require 任何路由/服务**之前**设 process.env.GRAPH_DB_PATH 指向一次性库` + | |
| 238 | + `(fs.mkdtempSync 造目录),afterAll 里删掉。\n` + | |
| 239 | + `DB_PATH 是模块级 const,晚一步赋值就已经绑到生产库上了。` | |
| 240 | + ); | |
| 241 | +} | |
| 242 | + | |
| 219 | 243 | function getDb() { |
| 220 | 244 | if (db) return db; |
| 245 | + assertNotProdDbInTests(); | |
| 221 | 246 | |
| 222 | 247 | if (!fs.existsSync(DATA_DIR)) { |
| 223 | 248 | fs.mkdirSync(DATA_DIR, { recursive: true }); | ... | ... |
server/tests/unit/agent-chat-tenant-isolation.test.js
| ... | ... | @@ -24,12 +24,23 @@ |
| 24 | 24 | * 顺带治了一个 flaky:本体被建了又删时,别的用例正在遍历整棵 `system/agents/`, |
| 25 | 25 | * 撞上「列到了名字、进去时已经没了」→ `ENOENT: scandir system/agents/zz-tenant-boss`。 |
| 26 | 26 | * 不用 `_` 前缀:那是 `_global` / `_base` 的保留段,会被 isReservedCompanyId 认成非公司项。 |
| 27 | + * | |
| 28 | + * **图库必须另开一份**(下面 GRAPH_DB_PATH)。批 5 把 `/task/dispatch` 的存储从 | |
| 29 | + * `system/tasklog/*.json` 换进了 `tasks` 表,而 afterAll 当时只会清那个目录 —— | |
| 30 | + * 于是本文件每跑一次就往**共享的生产库** `data/graph.sqlite` 落 2 条 goal + 2 条 task, | |
| 31 | + * 测试照样全绿。实测残留 25 条 `_global` goal 才被发现。 | |
| 32 | + * | |
| 33 | + * 这是本仓库第四次踩「测试污染共享库」(前三次 BENCHMARK_DB_PATH / USERS_DB_PATH / | |
| 34 | + * SKILLS_DIR,记在 test-isolation-env.test.js 的文件头),但换了个方向进来: | |
| 35 | + * 前三次是「设了 env、生产代码不读」,这次是**压根没设 env** —— 那条守卫查的是前者, | |
| 36 | + * 没有可查的东西就查不出来。现由 graph-db.getDb() 的运行期闸门兜底(见该函数)。 | |
| 27 | 37 | */ |
| 28 | 38 | process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须在 require 路由之前:acp-core 的 mock 开关是模块级常量 |
| 29 | 39 | |
| 30 | 40 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 31 | 41 | import fs from 'fs'; |
| 32 | 42 | import path from 'path'; |
| 43 | +import os from 'os'; | |
| 33 | 44 | import express from 'express'; |
| 34 | 45 | import { createRequire } from 'node:module'; |
| 35 | 46 | |
| ... | ... | @@ -37,11 +48,15 @@ const require_ = createRequire(import.meta.url); |
| 37 | 48 | const ROOT = path.resolve(__dirname, '../../..'); |
| 38 | 49 | const AGENTS_ROOT = path.join(ROOT, 'system', 'agents'); |
| 39 | 50 | |
| 40 | -// 任务**文本**带一次性 nonce(不是 id):/task/dispatch 有 30 分钟去重窗口,元数据落在 | |
| 41 | -// system/tasklog/ 且跨进程存活,文本固定的话第二次跑本文件会被 409 拦掉。 | |
| 51 | +// 一次性图库 —— 必须在 require 路由**之前**赋值:graph-db.js 的 DB_PATH 是模块级 const, | |
| 52 | +// 晚一步就已经绑到生产库上了。目录用 mkdtempSync,不用时间戳拼名(见 test-id-stability)。 | |
| 53 | +const TMP_DB_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-tenant-iso-')); | |
| 54 | +process.env.GRAPH_DB_PATH = path.join(TMP_DB_DIR, 'graph.sqlite'); | |
| 55 | + | |
| 56 | +// 任务**文本**带一次性 nonce(不是 id):/task/dispatch 有 30 分钟去重窗口,文本固定的话 | |
| 57 | +// 同一次跑里第二条会被 409 拦掉。(跨次跑已经不会撞了 —— 库是一次性的。) | |
| 42 | 58 | // 它拼的是自然语言,不进任何注册表,所以与「id 必须固定」那条规矩不冲突(见 test-id-stability)。 |
| 43 | 59 | const RUN = `r${Date.now().toString(36)}`; |
| 44 | -const TASKLOG_DIR = path.join(ROOT, 'system', 'tasklog'); | |
| 45 | 60 | |
| 46 | 61 | const CEO = 'zz-tenant-ceo'; // 被两家公司共用的那一份本体 |
| 47 | 62 | const BOSS = 'zz-tenant-boss'; // 派单方 |
| ... | ... | @@ -103,14 +118,9 @@ describe('agent-chat · 公司段不能丢(todo/plan 落点、文件写入根 |
| 103 | 118 | for (const cid of [ALPHA, BETA]) { |
| 104 | 119 | fs.rmSync(path.join(ROOT, 'data', 'companies', cid), { recursive: true, force: true }); |
| 105 | 120 | } |
| 106 | - // /task/dispatch 的元数据落在本体目录之外,得单独收:只删 to 指向本用例 agent 的那几条 | |
| 107 | - for (const f of fs.existsSync(TASKLOG_DIR) ? fs.readdirSync(TASKLOG_DIR) : []) { | |
| 108 | - if (!f.endsWith('.json')) continue; | |
| 109 | - try { | |
| 110 | - const tm = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, f), 'utf-8')); | |
| 111 | - if (String(tm.to || '').includes('zz-tenant-')) fs.rmSync(path.join(TASKLOG_DIR, f), { force: true }); | |
| 112 | - } catch {} | |
| 113 | - } | |
| 121 | + // /task/dispatch 的 goal/task 行落在图库里,整份库是一次性的 —— 连目录一起删掉即可, | |
| 122 | + // 不需要再按 to 字段挑行(那是 tasklog 时代的清理法,存储换进表之后就失效了)。 | |
| 123 | + fs.rmSync(TMP_DB_DIR, { recursive: true, force: true }); | |
| 114 | 124 | }); |
| 115 | 125 | |
| 116 | 126 | const dispatch = (cid, task) => | ... | ... |
server/tests/unit/agent-docs-port.test.js
| ... | ... | @@ -28,7 +28,15 @@ const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|coverage|venv|\.venv|site-packa |
| 28 | 28 | |
| 29 | 29 | function walk(dir, out = []) { |
| 30 | 30 | if (!fs.existsSync(dir) || SKIP_DIR.test(dir)) return out; |
| 31 | - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 31 | + // existsSync 与 readdirSync 之间同样有窗口(理由见下面读文件那处)——存在性检查不是锁。 | |
| 32 | + let entries; | |
| 33 | + try { | |
| 34 | + entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| 35 | + } catch (e) { | |
| 36 | + if (e.code === 'ENOENT') return out; | |
| 37 | + throw e; | |
| 38 | + } | |
| 39 | + for (const e of entries) { | |
| 32 | 40 | const full = path.join(dir, e.name); |
| 33 | 41 | if (e.isDirectory()) walk(full, out); |
| 34 | 42 | else if (/\.(md|json|txt)$/.test(e.name)) out.push(full); |
| ... | ... | @@ -48,7 +56,16 @@ describe('agent 提示词里的端口', () => { |
| 48 | 56 | it('不许再出现 localhost:3000(实际端口是 3010)', () => { |
| 49 | 57 | const offenders = []; |
| 50 | 58 | for (const f of files) { |
| 51 | - const src = fs.readFileSync(f, 'utf-8'); | |
| 59 | + // 扫的是活目录:walk 列出名字之后、读到它之前,别的用例可能正好把自己的运行态 | |
| 60 | + // 目录清掉(`zz-*` 那几个夹具就是这么干的)→ ENOENT。**只放过「文件没了」这一种**, | |
| 61 | + // 其它读错误照抛 —— 一律 try/catch 吞掉的话,权限错、编码错也会被当成「无违例」。 | |
| 62 | + let src; | |
| 63 | + try { | |
| 64 | + src = fs.readFileSync(f, 'utf-8'); | |
| 65 | + } catch (e) { | |
| 66 | + if (e.code === 'ENOENT') continue; | |
| 67 | + throw e; | |
| 68 | + } | |
| 52 | 69 | src.split('\n').forEach((line, i) => { |
| 53 | 70 | if (line.includes('localhost:3000') || line.includes('127.0.0.1:3000')) { |
| 54 | 71 | offenders.push(`${path.relative(ROOT, f)}:${i + 1} → ${line.trim().slice(0, 90)}`); | ... | ... |
server/tests/unit/graph-db-prod-guard.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — **测试进程不许打开共享生产库 `data/graph.sqlite`** | |
| 3 | + * | |
| 4 | + * 守的是 `graph-db.getDb()` 里的 `assertNotProdDbInTests()`。 | |
| 5 | + * | |
| 6 | + * ── 为什么再加一条 ───────────────────────────────────────────────────── | |
| 7 | + * `test-isolation-env.test.js` 已经守了同一族缺陷,但它的判据是 | |
| 8 | + * 「测试设了 XXX_PATH / XXX_DIR,生产代码必须真的读它」—— 那挡的是**设了但没接通**。 | |
| 9 | + * 这次漏进来的是另一个形态:`agent-chat-tenant-isolation.test.js` **一个字都没设**, | |
| 10 | + * 于是那条守卫没有可查的东西,而 `/task/dispatch` 每跑一次往生产库落 | |
| 11 | + * 2 条 goal + 2 条 task。攒到 25 条 `_global` goal 才被人翻库发现,期间测试一直全绿。 | |
| 12 | + * | |
| 13 | + * 静态扫源码判不出「这个测试会不会开库」(要跨 require 链推导),所以这条守卫改成 | |
| 14 | + * **运行期闸门 + 对它本身的黑盒验证**:起一个真子进程去撞,看撞不撞得动。 | |
| 15 | + * | |
| 16 | + * ── 判据 ─────────────────────────────────────────────────────────────── | |
| 17 | + * 只在 `process.env.VITEST` 为真时生效,只在**真正开库那一刻**判(不是 require 时)—— | |
| 18 | + * 只为取一个常量而 require 本模块是合法用法,顶层抛会误伤(goal-task-model.test.js 就这么用)。 | |
| 19 | + */ | |
| 20 | +import { describe, it, expect } from 'vitest'; | |
| 21 | +import { execFileSync } from 'child_process'; | |
| 22 | +import fs from 'fs'; | |
| 23 | +import os from 'os'; | |
| 24 | +import path from 'path'; | |
| 25 | + | |
| 26 | +const ROOT = path.resolve(__dirname, '../../..'); | |
| 27 | +const REQUIRE_DB = `require(${JSON.stringify(path.join(ROOT, 'server/services/graph-db.js'))})`; | |
| 28 | + | |
| 29 | +/** 在干净子进程里跑一小段,返回 {code, stderr}。不继承本进程的 VITEST / GRAPH_DB_PATH。 */ | |
| 30 | +function run(code, env) { | |
| 31 | + const base = { ...process.env }; | |
| 32 | + delete base.VITEST; | |
| 33 | + delete base.GRAPH_DB_PATH; | |
| 34 | + try { | |
| 35 | + execFileSync(process.execPath, ['-e', code], { | |
| 36 | + env: { ...base, ...env }, | |
| 37 | + stdio: ['ignore', 'pipe', 'pipe'], | |
| 38 | + cwd: ROOT, | |
| 39 | + }); | |
| 40 | + return { code: 0, stderr: '' }; | |
| 41 | + } catch (e) { | |
| 42 | + return { code: e.status, stderr: String(e.stderr || '') }; | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +describe('graph-db · 测试进程不许开生产库', () => { | |
| 47 | + it('VITEST=1 且没设 GRAPH_DB_PATH → getDb() 抛,且报错里给得出修法', () => { | |
| 48 | + const r = run(`${REQUIRE_DB}.getDb()`, { VITEST: '1' }); | |
| 49 | + expect(r.code).not.toBe(0); | |
| 50 | + expect(r.stderr).toContain('测试进程试图打开共享生产库'); | |
| 51 | + // 光抛不够 —— 撞上的人要能照着改,所以报错必须点名那个 env 和「要在 require 之前设」 | |
| 52 | + expect(r.stderr).toContain('GRAPH_DB_PATH'); | |
| 53 | + expect(r.stderr).toContain('require'); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it('VITEST=1 但设了 GRAPH_DB_PATH → 正常开一次性库(判据不是「测试里一律不许开库」)', () => { | |
| 57 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-graphdb-guard-')); | |
| 58 | + const dbPath = path.join(dir, 'graph.sqlite'); | |
| 59 | + try { | |
| 60 | + const r = run(`${REQUIRE_DB}.getDb()`, { VITEST: '1', GRAPH_DB_PATH: dbPath }); | |
| 61 | + expect(r.stderr).toBe(''); | |
| 62 | + expect(r.code).toBe(0); | |
| 63 | + expect(fs.existsSync(dbPath)).toBe(true); | |
| 64 | + } finally { | |
| 65 | + fs.rmSync(dir, { recursive: true, force: true }); | |
| 66 | + } | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it('不在测试进程里(无 VITEST)→ 闸门不生效,生产照常开库', () => { | |
| 70 | + // 这一条防的是把闸门写死成「永远不许开 data/graph.sqlite」——那会把生产也一起锁死。 | |
| 71 | + const r = run(`${REQUIRE_DB}.getDb(); console.log('opened')`, {}); | |
| 72 | + expect(r.code).toBe(0); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it('样本非空:这个仓库里真的存在会开库的路径', () => { | |
| 76 | + // 少了这条,上面三条在「模块路径写错、require 直接失败」时也会以「抛了异常」的形式变绿。 | |
| 77 | + const mod = require('../../services/graph-db.js'); | |
| 78 | + expect(typeof mod.getDb).toBe('function'); | |
| 79 | + }); | |
| 80 | +}); | ... | ... |