feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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()));
|
||||
});
|
||||
Reference in New Issue
Block a user