/** * zcode-env-sync 单元测试(node:test,零第三方依赖) * 跑法: node --test tests/ */ import { test } from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import crypto from "node:crypto"; import { HOME, USER, ZCODE_HOME, DSH_HOME, fwd, encodePaths, decodePaths, encodeJsonPaths, decodeJsonPaths, scanForSecrets, encryptSecrets, decryptSecrets, requirePassphrase, deepMerge, restoreRelPath, hashDir, sha256File, nowTag, isHiddenEntry, copyDirFiltered, collectModelSecrets, sanitizeProviders, summarizeProviders, extractModelSecrets, isModelEntry, parseModelRel, } from "../scripts/sync-core.mjs"; /* ---------------- 路径占位符 ---------------- */ test("encodePaths/decodePaths:正斜杠路径往返一致", () => { const p = fwd(path.join(ZCODE_HOME, "cli", "config.json")); const enc = encodePaths(p); assert.match(enc, /\$\{ZCODE_HOME\}/, "应命中 ZCODE_HOME 占位符"); assert.equal(decodePaths(enc), p, "还原后应与原值完全一致"); }); test("encodePaths/decodePaths:反斜杠路径往返一致", () => { const p = ZCODE_HOME.replace(/\//g, "\\") + "\\cli\\config.json"; const enc = encodePaths(p); assert.match(enc, /\$\{ZCODE_HOME_WIN\}/, "反斜杠路径应命中 WIN 占位符"); assert.equal(decodePaths(enc), p); }); test("encodePaths:DSH_HOME 优先于通配的 Users 规则", () => { const enc = encodePaths(fwd(path.join(DSH_HOME, "secrets", "gitea-token.txt"))); assert.match(enc, /\$\{DSH_HOME\}/); assert.doesNotMatch(enc, /\$\{USERS_/, "不应退化成 USERS_* 通配"); }); test("encodeJsonPaths:反斜杠 JSON 值是活的占位符(旧实现此处全失效)", () => { const cfg = { mcp: { servers: { s: { command: "C:\\Users\\12914\\AppData\\node.exe", args: ["C:\\Users\\12914\\.dsh\\secrets\\x.mjs"] } } } }; const enc = encodeJsonPaths(cfg); const text = JSON.stringify(enc); assert.match(text, /\$\{USER_HOME_WIN\}|\$\{USERS_WIN\}|\$\{DSH_HOME_WIN\}/, "反斜杠路径必须被占位符化"); assert.doesNotMatch(text, /AppData\\node\.exe/, "原始主目录片段不得残留"); }); test("decodeJsonPaths:WIN 占位符还原后仍是合法 JSON(旧实现在文本上还原会拼出非法转义)", () => { const cfg = { s: { command: "C:\\Users\\12914\\AppData\\node.exe" } }; const round = decodeJsonPaths(encodeJsonPaths(cfg)); assert.doesNotThrow(() => JSON.parse(JSON.stringify(round))); assert.equal(round.s.command, cfg.s.command, "还原值应与原值一致"); assert.equal(round.s.command.split("\\").length, cfg.s.command.split("\\").length); }); test("encodeJsonPaths/decodeJsonPaths:嵌套数组与键名一并处理,且完全往返", () => { const cfg = { "C:/Users/12914/.dsh/secrets/redis-mcp-launch.mjs": { env: { SSH_PROFILES_FILE: "C:/Users/12914/.ssh/ssh-profiles.json" }, args: ["--db", "C:/Users/12914/.dsh/x.db", 8080, true, null], }, }; const enc = encodeJsonPaths(cfg); const k0 = Object.keys(cfg)[0]; assert.equal(Object.keys(enc).length, 1, "键数量不变"); assert.match(Object.keys(enc)[0], /\$\{DSH_HOME\}/, "键名里的路径也要占位符化"); assert.equal(enc[Object.keys(enc)[0]].args.length, cfg[k0].args.length, "数组长度不变"); assert.match(JSON.stringify(enc), /\$\{/, "产生了占位符"); assert.deepEqual(decodeJsonPaths(enc), cfg, "往返必须完全相等"); }); test("encodeJsonPaths:非字符串原样保留", () => { const cfg = { n: 1, b: true, z: null, o: { a: [1, "C:/Users/12914/.zcode/x"] } }; const enc = encodeJsonPaths(cfg); assert.equal(enc.n, 1); assert.equal(enc.b, true); assert.equal(enc.z, null); assert.match(enc.o.a[1], /\$\{ZCODE_HOME\}/); }); /* ---------------- 脱敏扫描 ---------------- */ test("scanForSecrets:抓到明文密钥", () => { assert.deepEqual(scanForSecrets({ apiKey: "supersecret123" }), ["$.apiKey"]); assert.deepEqual(scanForSecrets({ env: { PASSWORD: "hunter2hunter2" } }), ["$.env.PASSWORD"]); }); test("scanForSecrets:含 / \\ $ 的密钥不得漏检(旧实现最大漏洞)", () => { for (const v of ["sk-proj/abcdefghijklmnop", "eyJhbGciOi/JIUzI1NiJ9.payload", "ghp_AbC$1234567890", "tok\\with\\slash1234"]) { assert.equal(scanForSecrets({ apiKey: v }).length, 1, `必须抓到:${v}`); } }); test("scanForSecrets:跳过 _FILE 引用、占位符、短值", () => { assert.deepEqual(scanForSecrets({ GITEA_ACCESS_TOKEN_FILE: "C:/Users/x/.dsh/secrets/gitea-token.txt" }), []); assert.deepEqual(scanForSecrets({ apiKey: "${SOME_PLACEHOLDER}" }), []); assert.deepEqual(scanForSecrets({ token: "short" }), []); }); test("scanForSecrets:ZCODE_SYNC_ALLOW_SECRETS 白名单生效", () => { const obj = { mcp: { servers: { x: { env: { apiKey: "realsecretvalue" } } } } }; assert.equal(scanForSecrets(obj).length, 1); process.env.ZCODE_SYNC_ALLOW_SECRETS = "$.mcp.servers.x.env.apiKey"; try { assert.deepEqual(scanForSecrets(obj), []); } finally { delete process.env.ZCODE_SYNC_ALLOW_SECRETS; } }); /* ---------------- 密钥束加解密 ---------------- */ test("encryptSecrets/decryptSecrets:往返一致", () => { const bundle = { version: 1, files: [{ rel: "SSH/ssh-profiles.json", contentB64: Buffer.from("hello").toString("base64") }] }; const enc = encryptSecrets(bundle, "test-passphrase-123"); assert.deepEqual(decryptSecrets(enc, "test-passphrase-123"), bundle); }); test("decryptSecrets:错误口令必定失败(GCM 认证)", () => { const enc = encryptSecrets({ a: 1 }, "right-passphrase"); assert.throws(() => decryptSecrets(enc, "wrong-passphrase")); }); test("decryptSecrets:篡改密文必定失败", () => { const e = JSON.parse(encryptSecrets({ a: 1 }, "passphrase-abc")); const buf = Buffer.from(e.data, "base64"); buf[0] ^= 0xff; e.data = buf.toString("base64"); assert.throws(() => decryptSecrets(JSON.stringify(e), "passphrase-abc")); }); test("encryptSecrets:同一明文两次加密密文不同(随机 salt/iv)", () => { const a = encryptSecrets({ x: 1 }, "same-passphrase"); const b = encryptSecrets({ x: 1 }, "same-passphrase"); assert.notEqual(a, b); }); test("requirePassphrase:未设置/过短都要拒绝", () => { const old = process.env.ZCODE_SYNC_PASSPHRASE; try { delete process.env.ZCODE_SYNC_PASSPHRASE; assert.throws(() => requirePassphrase(), /未设置/); process.env.ZCODE_SYNC_PASSPHRASE = "short"; assert.throws(() => requirePassphrase(), /太短/); process.env.ZCODE_SYNC_PASSPHRASE = "long-enough-pass"; assert.equal(requirePassphrase(), "long-enough-pass"); } finally { if (old === undefined) delete process.env.ZCODE_SYNC_PASSPHRASE; else process.env.ZCODE_SYNC_PASSPHRASE = old; } }); /* ---------------- 路径还原安全 ---------------- */ test("restoreRelPath:正常路径映射到 HOME 下", () => { const p = restoreRelPath("DSH_SECRETS/gitea-token.txt"); assert.equal(p, path.join(HOME, ".dsh", "secrets", "gitea-token.txt")); const s = restoreRelPath("SSH/ssh-profiles.json"); assert.equal(s, path.join(HOME, ".ssh", "ssh-profiles.json")); }); test("restoreRelPath:拒绝目录穿越与绝对路径", () => { for (const bad of ["DSH_SECRETS/../../../evil.txt", "SSH/../../x", "DSH_SECRETS/C:/Windows/evil.txt", "DSH_SECRETS//etc/passwd"]) { assert.throws(() => restoreRelPath(bad), /非法密钥路径/, `必须拒绝:${bad}`); } }); test("restoreRelPath:拒绝未知前缀", () => { assert.throws(() => restoreRelPath("OTHER/x.txt"), /未知密钥前缀/); }); /* ---------------- 结构合并 ---------------- */ test("deepMerge:深度合并 + 数组整体替换 + 不污染源对象", () => { const base = { mcp: { servers: { a: { command: "x", args: [1, 2] } } }, plugins: { dirs: ["p1"] } }; const over = { mcp: { servers: { a: { args: ["local"] }, b: { command: "y" } } } }; const out = deepMerge(base, over); assert.equal(out.mcp.servers.a.command, "x", "未覆盖字段保留"); assert.deepEqual(out.mcp.servers.a.args, ["local"], "数组整体替换"); assert.equal(out.mcp.servers.b.command, "y", "新键并入"); assert.deepEqual(base.mcp.servers.a.args, [1, 2], "源对象不得被改"); }); test("deepMerge:undefined 不覆盖已有值", () => { assert.equal(deepMerge({ a: 1 }, { a: undefined }).a, 1); }); /* ---------------- 目录哈希 ---------------- */ test("hashDir:内容变则哈希变,且确定性可复现", () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), "hashdir-")); try { fs.writeFileSync(path.join(d, "a.txt"), "1"); const h1 = hashDir(d), h2 = hashDir(d); assert.equal(h1, h2, "同内容必须同哈希"); fs.writeFileSync(path.join(d, "a.txt"), "2"); assert.notEqual(hashDir(d), h1, "内容变哈希必须变"); } finally { fs.rmSync(d, { recursive: true, force: true }); } }); test("hashDir:排除 node_modules/.git", () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), "hashdir2-")); try { fs.writeFileSync(path.join(d, "a.txt"), "1"); const before = hashDir(d); fs.mkdirSync(path.join(d, "node_modules", "x"), { recursive: true }); fs.writeFileSync(path.join(d, "node_modules", "x", "big.js"), "noise"); fs.mkdirSync(path.join(d, ".git"), { recursive: true }); fs.writeFileSync(path.join(d, ".git", "HEAD"), "ref"); assert.equal(hashDir(d), before, "排除目录不得影响哈希"); } finally { fs.rmSync(d, { recursive: true, force: true }); } }); /* ---------------- 自研插件扫描 ---------------- */ test("isHiddenEntry:备份/隐藏目录不算插件", () => { assert.equal(isHiddenEntry(".backup-env-sync-20260911"), true); assert.equal(isHiddenEntry(".git"), true); assert.equal(isHiddenEntry("zcode-tps"), false); }); test("copyDirFiltered:拒绝把目录复制进自身(防无限递归)", () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), "copyself-")); try { fs.writeFileSync(path.join(d, "a.txt"), "1"); assert.throws(() => copyDirFiltered(d, path.join(d, "sub")), /无限递归/); assert.throws(() => copyDirFiltered(d, d), /无限递归/); // 同级目录仍可正常复制 const dst = path.join(path.dirname(d), `copyself-out-${Date.now()}`); copyDirFiltered(d, dst); assert.ok(fs.existsSync(path.join(dst, "a.txt"))); fs.rmSync(dst, { recursive: true, force: true }); } finally { fs.rmSync(d, { recursive: true, force: true }); } }); /* ---------------- 模型 provider 脱敏 ---------------- */ test("collectModelSecrets:收集到全部 provider 密钥值", () => { const cfg = { provider: { a: { options: { apiKey: "sk-aaaaaaaaaaaaaaaa" } }, b: { options: { apiKey: "sk-bbbbbbbbbbbbbbbb", apiKeyRequired: true } }, c: { options: {} }, } }; const got = collectModelSecrets(cfg); assert.equal(got.length, 2, `应收集 2 个密钥,实际 ${got.length}`); assert.ok(got.includes("sk-aaaaaaaaaaaaaaaa")); assert.ok(got.includes("sk-bbbbbbbbbbbbbbbb")); }); test("collectModelSecrets:短值和无关字段不误收", () => { const cfg = { provider: { a: { options: { apiKey: "short", baseURL: "https://example.invalid/v1" } } } }; assert.deepEqual(collectModelSecrets(cfg), [], "短值/非密钥字段不得收集"); }); test("sanitizeProviders:密钥被替换,结构保留,且名单只记 id", () => { const cfg = { provider: { a: { name: "A", kind: "anthropic", models: { m1: {} }, options: { apiKey: "sk-realvalue123456" } }, b: { name: "B", kind: "openai-compatible", models: { m2: {}, m3: {} }, options: { baseURL: "https://x.invalid" } }, } }; const { providers, withSecrets } = sanitizeProviders(cfg); assert.deepEqual(withSecrets, ["a"], "只有 a 带密钥"); assert.equal(providers.a.options.apiKey, "${MODEL_SECRET_REF}", "应替换为占位符"); assert.equal(providers.a.kind, "anthropic", "非密钥字段保留"); assert.deepEqual(Object.keys(providers.a.models), ["m1"], "模型结构保留"); assert.equal(providers.b.options.baseURL, "https://x.invalid", "无密钥者不受影响"); // 脱敏后不得再有真密钥 assert.doesNotMatch(JSON.stringify(providers), /sk-realvalue123456/); assert.doesNotMatch(JSON.stringify(providers), /sk-/); // 源对象不得被改 assert.equal(cfg.provider.a.options.apiKey, "sk-realvalue123456", "源配置不得被修改"); }); test("summarizeProviders:只输出结构摘要,不含任何密钥值", () => { const cfg = { provider: { p1: { name: "P1", kind: "anthropic", models: { m1: {}, m2: {} }, options: { apiKey: "sk-secret123456789" } }, } }; const sum = summarizeProviders(cfg); assert.equal(sum.length, 1); assert.equal(sum[0].id, "p1"); assert.equal(sum[0].kind, "anthropic"); assert.deepEqual(sum[0].models, ["m1", "m2"]); assert.equal(sum[0].hasSecret, true, "应标记带密钥"); assert.doesNotMatch(JSON.stringify(sum), /sk-secret123456789/, "摘要里绝不能出现密钥值"); }); test("extractModelSecrets/parseModelRel:MODELS 条目可往返定位", () => { const cfg = { provider: { "builtin:x": { options: { apiKey: "sk-abcdefgh12345678" } } } }; const ex = extractModelSecrets(cfg); assert.equal(ex.length, 1); assert.equal(ex[0].provider, "builtin:x"); assert.equal(ex[0].key, "apiKey"); const rel = `MODELS/${ex[0].provider}/${ex[0].key}`; assert.equal(isModelEntry(rel), true); assert.deepEqual(parseModelRel(rel), { provider: "builtin:x", key: "apiKey" }, "定位符应能还原出 provider/key"); }); test("isModelEntry:普通密钥文件路径不得被误判为模型条目", () => { for (const rel of ["DSH_SECRETS/gitea-token.txt", "SSH/ssh-profiles.json"]) { assert.equal(isModelEntry(rel), false, `${rel} 不是模型条目`); } }); test("parseModelRel:非法路径要报错而不是静默产出错 provider", () => { for (const bad of ["MODELS/", "MODELS/onlyprovider", "MODELS//key", "MODELS/provider/"]) { assert.throws(() => parseModelRel(bad), /非法模型密钥路径/, `必须拒绝:${bad}`); } }); /* ---------------- 杂项 ---------------- */ test("nowTag:格式 yyyyMMdd-HHmmss", () => { assert.match(nowTag(), /^\d{8}-\d{6}$/); }); test("sha256File:与 crypto 直接计算结果一致", () => { const f = path.join(os.tmpdir(), `sha-${Date.now()}.txt`); fs.writeFileSync(f, "content"); try { const want = crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); assert.equal(sha256File(f), want); } finally { fs.rmSync(f, { force: true }); } }); test("HOME/USER 常量与 os 一致", () => { assert.equal(HOME, os.homedir()); assert.equal(USER, path.basename(os.homedir())); });