diff --git a/server/services/graph-db.js b/server/services/graph-db.js index 7c466e6..4ac65b7 100644 --- a/server/services/graph-db.js +++ b/server/services/graph-db.js @@ -216,8 +216,33 @@ function createEdgesTable(database) { `); } +/** + * 测试进程打开**共享生产库**时直接拦下来。 + * + * 挡的是「测试压根没设隔离 env」这个形态 —— `test-isolation-env.test.js` 挡不住它: + * 那条守卫查的是「设了 XXX_PATH、生产代码却不读」,一个字都没设的文件没有可查的东西。 + * 实际后果:`agent-chat-tenant-isolation.test.js` 每跑一次往 `data/graph.sqlite` 落 + * 2 条 goal + 2 条 task,攒到 25 条 `_global` goal 才被人翻库翻出来,期间测试一直全绿。 + * + * 为什么放在 getDb() 而不是模块顶层:`require('work-graph-db')` 只为取一个常量是合法的 + * (goal-task-model.test.js 就这么用),顶层抛会误伤。**真正开库**才是越界那一刻。 + * + * 需要读真库的测试显式写 `process.env.GRAPH_DB_PATH = <真库路径>` 表明意图即可。 + */ +function assertNotProdDbInTests() { + if (!process.env.VITEST) return; + if (process.env.GRAPH_DB_PATH) return; + throw new Error( + `[graph-db] 测试进程试图打开共享生产库 ${DB_PATH}。\n` + + `请在 require 任何路由/服务**之前**设 process.env.GRAPH_DB_PATH 指向一次性库` + + `(fs.mkdtempSync 造目录),afterAll 里删掉。\n` + + `DB_PATH 是模块级 const,晚一步赋值就已经绑到生产库上了。` + ); +} + function getDb() { if (db) return db; + assertNotProdDbInTests(); if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); diff --git a/server/tests/unit/agent-chat-tenant-isolation.test.js b/server/tests/unit/agent-chat-tenant-isolation.test.js index a906339..6315842 100644 --- a/server/tests/unit/agent-chat-tenant-isolation.test.js +++ b/server/tests/unit/agent-chat-tenant-isolation.test.js @@ -24,12 +24,23 @@ * 顺带治了一个 flaky:本体被建了又删时,别的用例正在遍历整棵 `system/agents/`, * 撞上「列到了名字、进去时已经没了」→ `ENOENT: scandir system/agents/zz-tenant-boss`。 * 不用 `_` 前缀:那是 `_global` / `_base` 的保留段,会被 isReservedCompanyId 认成非公司项。 + * + * **图库必须另开一份**(下面 GRAPH_DB_PATH)。批 5 把 `/task/dispatch` 的存储从 + * `system/tasklog/*.json` 换进了 `tasks` 表,而 afterAll 当时只会清那个目录 —— + * 于是本文件每跑一次就往**共享的生产库** `data/graph.sqlite` 落 2 条 goal + 2 条 task, + * 测试照样全绿。实测残留 25 条 `_global` goal 才被发现。 + * + * 这是本仓库第四次踩「测试污染共享库」(前三次 BENCHMARK_DB_PATH / USERS_DB_PATH / + * SKILLS_DIR,记在 test-isolation-env.test.js 的文件头),但换了个方向进来: + * 前三次是「设了 env、生产代码不读」,这次是**压根没设 env** —— 那条守卫查的是前者, + * 没有可查的东西就查不出来。现由 graph-db.getDb() 的运行期闸门兜底(见该函数)。 */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须在 require 路由之前:acp-core 的 mock 开关是模块级常量 import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; +import os from 'os'; import express from 'express'; import { createRequire } from 'node:module'; @@ -37,11 +48,15 @@ const require_ = createRequire(import.meta.url); const ROOT = path.resolve(__dirname, '../../..'); const AGENTS_ROOT = path.join(ROOT, 'system', 'agents'); -// 任务**文本**带一次性 nonce(不是 id):/task/dispatch 有 30 分钟去重窗口,元数据落在 -// system/tasklog/ 且跨进程存活,文本固定的话第二次跑本文件会被 409 拦掉。 +// 一次性图库 —— 必须在 require 路由**之前**赋值:graph-db.js 的 DB_PATH 是模块级 const, +// 晚一步就已经绑到生产库上了。目录用 mkdtempSync,不用时间戳拼名(见 test-id-stability)。 +const TMP_DB_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-tenant-iso-')); +process.env.GRAPH_DB_PATH = path.join(TMP_DB_DIR, 'graph.sqlite'); + +// 任务**文本**带一次性 nonce(不是 id):/task/dispatch 有 30 分钟去重窗口,文本固定的话 +// 同一次跑里第二条会被 409 拦掉。(跨次跑已经不会撞了 —— 库是一次性的。) // 它拼的是自然语言,不进任何注册表,所以与「id 必须固定」那条规矩不冲突(见 test-id-stability)。 const RUN = `r${Date.now().toString(36)}`; -const TASKLOG_DIR = path.join(ROOT, 'system', 'tasklog'); const CEO = 'zz-tenant-ceo'; // 被两家公司共用的那一份本体 const BOSS = 'zz-tenant-boss'; // 派单方 @@ -103,14 +118,9 @@ describe('agent-chat · 公司段不能丢(todo/plan 落点、文件写入根ã for (const cid of [ALPHA, BETA]) { fs.rmSync(path.join(ROOT, 'data', 'companies', cid), { recursive: true, force: true }); } - // /task/dispatch 的元数据落在本体目录之外,得单独收:只删 to 指向本用例 agent 的那几条 - for (const f of fs.existsSync(TASKLOG_DIR) ? fs.readdirSync(TASKLOG_DIR) : []) { - if (!f.endsWith('.json')) continue; - try { - const tm = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, f), 'utf-8')); - if (String(tm.to || '').includes('zz-tenant-')) fs.rmSync(path.join(TASKLOG_DIR, f), { force: true }); - } catch {} - } + // /task/dispatch 的 goal/task 行落在图库里,整份库是一次性的 —— 连目录一起删掉即可, + // 不需要再按 to 字段挑行(那是 tasklog 时代的清理法,存储换进表之后就失效了)。 + fs.rmSync(TMP_DB_DIR, { recursive: true, force: true }); }); const dispatch = (cid, task) => diff --git a/server/tests/unit/agent-docs-port.test.js b/server/tests/unit/agent-docs-port.test.js index f336fb9..519698d 100644 --- a/server/tests/unit/agent-docs-port.test.js +++ b/server/tests/unit/agent-docs-port.test.js @@ -28,7 +28,15 @@ const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|coverage|venv|\.venv|site-packa function walk(dir, out = []) { if (!fs.existsSync(dir) || SKIP_DIR.test(dir)) return out; - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + // existsSync 与 readdirSync 之间同样有窗口(理由见下面读文件那处)——存在性检查不是锁。 + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + if (e.code === 'ENOENT') return out; + throw e; + } + for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) walk(full, out); else if (/\.(md|json|txt)$/.test(e.name)) out.push(full); @@ -48,7 +56,16 @@ describe('agent 提示词里的端口', () => { it('不许再出现 localhost:3000(实际端口是 3010)', () => { const offenders = []; for (const f of files) { - const src = fs.readFileSync(f, 'utf-8'); + // 扫的是活目录:walk 列出名字之后、读到它之前,别的用例可能正好把自己的运行态 + // 目录清掉(`zz-*` 那几个夹具就是这么干的)→ ENOENT。**只放过「文件没了」这一种**, + // 其它读错误照抛 —— 一律 try/catch 吞掉的话,权限错、编码错也会被当成「无违例」。 + let src; + try { + src = fs.readFileSync(f, 'utf-8'); + } catch (e) { + if (e.code === 'ENOENT') continue; + throw e; + } src.split('\n').forEach((line, i) => { if (line.includes('localhost:3000') || line.includes('127.0.0.1:3000')) { offenders.push(`${path.relative(ROOT, f)}:${i + 1} → ${line.trim().slice(0, 90)}`); diff --git a/server/tests/unit/graph-db-prod-guard.test.js b/server/tests/unit/graph-db-prod-guard.test.js new file mode 100644 index 0000000..c44062c --- /dev/null +++ b/server/tests/unit/graph-db-prod-guard.test.js @@ -0,0 +1,80 @@ +/** + * Unit Tests — **测试进程不许打开共享生产库 `data/graph.sqlite`** + * + * 守的是 `graph-db.getDb()` 里的 `assertNotProdDbInTests()`。 + * + * ── 为什么再加一条 ───────────────────────────────────────────────────── + * `test-isolation-env.test.js` 已经守了同一族缺陷,但它的判据是 + * 「测试设了 XXX_PATH / XXX_DIR,生产代码必须真的读它」—— 那挡的是**设了但没接通**。 + * 这次漏进来的是另一个形态:`agent-chat-tenant-isolation.test.js` **一个字都没设**, + * 于是那条守卫没有可查的东西,而 `/task/dispatch` 每跑一次往生产库落 + * 2 条 goal + 2 条 task。攒到 25 条 `_global` goal 才被人翻库发现,期间测试一直全绿。 + * + * 静态扫源码判不出「这个测试会不会开库」(要跨 require 链推导),所以这条守卫改成 + * **运行期闸门 + 对它本身的黑盒验证**:起一个真子进程去撞,看撞不撞得动。 + * + * ── 判据 ─────────────────────────────────────────────────────────────── + * 只在 `process.env.VITEST` 为真时生效,只在**真正开库那一刻**判(不是 require 时)—— + * 只为取一个常量而 require 本模块是合法用法,顶层抛会误伤(goal-task-model.test.js 就这么用)。 + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../../..'); +const REQUIRE_DB = `require(${JSON.stringify(path.join(ROOT, 'server/services/graph-db.js'))})`; + +/** 在干净子进程里跑一小段,返回 {code, stderr}。不继承本进程的 VITEST / GRAPH_DB_PATH。 */ +function run(code, env) { + const base = { ...process.env }; + delete base.VITEST; + delete base.GRAPH_DB_PATH; + try { + execFileSync(process.execPath, ['-e', code], { + env: { ...base, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + cwd: ROOT, + }); + return { code: 0, stderr: '' }; + } catch (e) { + return { code: e.status, stderr: String(e.stderr || '') }; + } +} + +describe('graph-db · 测试进程不许开生产库', () => { + it('VITEST=1 且没设 GRAPH_DB_PATH → getDb() 抛,且报错里给得出修法', () => { + const r = run(`${REQUIRE_DB}.getDb()`, { VITEST: '1' }); + expect(r.code).not.toBe(0); + expect(r.stderr).toContain('测试进程试图打开共享生产库'); + // 光抛不够 —— 撞上的人要能照着改,所以报错必须点名那个 env 和「要在 require 之前设」 + expect(r.stderr).toContain('GRAPH_DB_PATH'); + expect(r.stderr).toContain('require'); + }); + + it('VITEST=1 但设了 GRAPH_DB_PATH → 正常开一次性库(判据不是「测试里一律不许开库」)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-graphdb-guard-')); + const dbPath = path.join(dir, 'graph.sqlite'); + try { + const r = run(`${REQUIRE_DB}.getDb()`, { VITEST: '1', GRAPH_DB_PATH: dbPath }); + expect(r.stderr).toBe(''); + expect(r.code).toBe(0); + expect(fs.existsSync(dbPath)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('不在测试进程里(无 VITEST)→ 闸门不生效,生产照常开库', () => { + // 这一条防的是把闸门写死成「永远不许开 data/graph.sqlite」——那会把生产也一起锁死。 + const r = run(`${REQUIRE_DB}.getDb(); console.log('opened')`, {}); + expect(r.code).toBe(0); + }); + + it('样本非空:这个仓库里真的存在会开库的路径', () => { + // 少了这条,上面三条在「模块路径写错、require 直接失败」时也会以「抛了异常」的形式变绿。 + const mod = require('../../services/graph-db.js'); + expect(typeof mod.getDb).toBe('function'); + }); +});