问题:只同步 v2/config.json 时,只在 v2/provider_config.json 里存在的 provider 分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。 实测:9 个分组里有 4 个(GINKA API / zxcbug / zxcbug-p / d1)纯规则型, 密钥只存在 provider_config.json 的 config.access.apiKey 里,旧实现完全没收集。 修复: - sync-core:新增 V2_PROVIDER_CONFIG、extract/sanitize/collectProviderConfigSecrets、 providerConfigKeyManifest;密钥命名空间 PROVIDER_CONFIG/<providerId>/<key> - export:导出 v2.provider-config.json(脱敏),密钥并入加密束,manifest 记摘要与哈希 - import:按 providerId 合并规则、回填密钥空位、按 (providerId,modelId) 合并模型级配置、 providerOrder 以快照为准并保留本机特有分组;重复导入幂等 - import:provider_config 路径补「护住本机已有密钥 N 个」提示(原来只在 config.json 侧有) - 测试:单元 6 项 + E2E 2 项(分组完整同步/强制覆盖),全量 72 项通过
790 lines
43 KiB
JavaScript
790 lines
43 KiB
JavaScript
/**
|
|
* zcode-env-sync 端到端沙箱测试。
|
|
*
|
|
* 用 USERPROFILE 把 HOME 重定向到临时目录,造两台互不相干的"假机器" A/B,
|
|
* 用一个本地裸仓冒充 Gitea,跑完整链路:
|
|
*
|
|
* A: 造环境 → export → commit → push
|
|
* B: clone → import → 校验路径已被还原成 B 自己的、密钥已解密落地、备份已生成
|
|
*
|
|
* 全程不碰真实 HOME,不碰网络。
|
|
* 跑法: node --test tests/
|
|
*/
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { hashDir } from "../scripts/sync-core.mjs";
|
|
import { readSnapshotManifest } from "../scripts/sync-core.mjs";
|
|
|
|
const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts");
|
|
const PASS = "team-passphrase-for-tests";
|
|
const GIT_ENV = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" };
|
|
|
|
/* ---------- 工具 ---------- */
|
|
|
|
function sandboxRoot() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), "zes-e2e-"));
|
|
}
|
|
|
|
/** 造一台假机器的 home 骨架 */
|
|
function makeMachine(root, name) {
|
|
const home = path.join(root, name);
|
|
const dirs = [
|
|
".zcode/cli/plugins/data",
|
|
".zcode/cli/plugins/marketplaces/zcode-plugins-official",
|
|
".zcode/plugins",
|
|
".dsh/secrets",
|
|
".ssh",
|
|
];
|
|
for (const d of dirs) fs.mkdirSync(path.join(home, d), { recursive: true });
|
|
return home;
|
|
}
|
|
|
|
/** 造这台机器的 ZCode 环境(插件/MCP/密钥),路径全部指向本机 home */
|
|
function seedMachine(home, { user, mcpExtra = {} } = {}) {
|
|
const z = path.join(home, ".zcode"), d = path.join(home, ".dsh");
|
|
|
|
// 官方插件清单(市场远端的)
|
|
fs.writeFileSync(path.join(z, "cli/plugins/marketplaces/zcode-plugins-official/marketplace.json"),
|
|
JSON.stringify({ plugins: [
|
|
{ name: "browser-use", version: "0.4.2" },
|
|
{ name: "document-skills", version: "0.1.4" },
|
|
{ name: "computer-use", version: "0.5.14" },
|
|
] }, null, 2));
|
|
|
|
// 启用态 = data/ 目录
|
|
for (const n of ["browser-use@zcode-plugins-official", "document-skills@zcode-plugins-official", "zcode-tps@inline"]) {
|
|
fs.mkdirSync(path.join(z, "cli/plugins/data", n), { recursive: true });
|
|
}
|
|
|
|
// 自研插件(带 .zcode-plugin/plugin.json);含 CRLF 文件,用于验证 git 不改写换行符
|
|
for (const [nm, ver, files] of [["zcode-tps", "0.4.1", { "scripts/rate.mjs": "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n" }],
|
|
["zcode-usage", "1.0.0", { "scripts/u.mjs": "export const v=2;\n" }]]) {
|
|
const dir = path.join(z, "plugins", nm);
|
|
fs.mkdirSync(path.join(dir, ".zcode-plugin"), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, ".zcode-plugin/plugin.json"), JSON.stringify({ name: nm, version: ver }));
|
|
for (const [rel, body] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, rel), body);
|
|
}
|
|
}
|
|
|
|
// cli/config.json —— MCP 全用本机绝对路径,含反斜杠与正斜杠两种写法
|
|
const nf = (p) => p.replace(/\\/g, "/"); // 正斜杠风格(本机真实风格)
|
|
const nb = (p) => p.replace(/\//g, "\\"); // 反斜杠风格(WM 上常见)
|
|
const cfg = {
|
|
mcp: { servers: {
|
|
context7: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ctx7", "dist", "index.js"))] },
|
|
gitea: { type: "stdio", command: nb(path.join(d, "bin", "gitea-mcp.exe")), args: ["-H", "https://gitea.example.invalid"],
|
|
env: { GITEA_ACCESS_TOKEN_FILE: nf(path.join(d, "secrets", "gitea-token.txt")) } },
|
|
mysql: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(d, "secrets", "mysql-mcp-launch.mjs"))] },
|
|
ssh: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ssh-mcp", "index.js"))],
|
|
env: { SSH_PROFILES_FILE: nf(path.join(home, ".ssh", "ssh-profiles.json")) } },
|
|
...mcpExtra,
|
|
} },
|
|
plugins: { enabled: true, dirs: [
|
|
nf(path.join(z, "plugins", "zcode-tps")),
|
|
nf(path.join(z, "plugins", "zcode-usage")),
|
|
] },
|
|
};
|
|
fs.writeFileSync(path.join(z, "cli", "config.json"), JSON.stringify(cfg, null, 2));
|
|
|
|
// 密钥
|
|
fs.writeFileSync(path.join(d, "secrets", "gitea-token.txt"), `tok-${user}-${"x".repeat(28)}\n`);
|
|
fs.writeFileSync(path.join(d, "secrets", "mysql-root-password.txt"), `pw-${user}-mysql`);
|
|
fs.writeFileSync(path.join(d, "secrets", "mysql-mcp-launch.mjs"), `// launcher for ${user}\nexport default 1;\n`);
|
|
fs.writeFileSync(path.join(home, ".ssh", "ssh-profiles.json"),
|
|
JSON.stringify({ profiles: { box1: { host: `${user}.example.invalid`, port: 22, user } } }, null, 2));
|
|
|
|
return cfg;
|
|
}
|
|
|
|
/** 子进程环境:把 HOME 指向假机器。extra 后应用,可覆盖任何继承值 */
|
|
function envFor(home, extra = {}) {
|
|
const env = { ...process.env, ...GIT_ENV, USERPROFILE: home, HOME: home };
|
|
delete env.HOMEDRIVE;
|
|
delete env.HOMEPATH;
|
|
delete env.ZCODE_SYNC_ALLOW_SECRETS;
|
|
return { ...env, ...extra };
|
|
}
|
|
|
|
function runScript(script, { home, args = [], env = {} }) {
|
|
const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], {
|
|
env: envFor(home, env), encoding: "utf8", timeout: 120000,
|
|
});
|
|
return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
}
|
|
|
|
function git(args, opts = {}) {
|
|
return execFileSync("git", args, { encoding: "utf8", ...GIT_ENV, ...opts });
|
|
}
|
|
function readJson(f) { return JSON.parse(fs.readFileSync(f, "utf8")); }
|
|
function sha256File(f) { return crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); }
|
|
/** 比较路径时归一化分隔符:还原保留各字段原本的正/反斜杠风格,不该强求统一 */
|
|
function norm(p) { return String(p).replace(/\\/g, "/").replace(/\/+$/, ""); }
|
|
|
|
/* ---------- 主流程(一次搭台,多个断言) ---------- */
|
|
|
|
test("E2E:A 机导出 → 裸仓 → B 机导入,跨机器路径正确还原", { timeout: 180000 }, () => {
|
|
const root = sandboxRoot();
|
|
const bare = path.join(root, "gitea.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
|
|
const homeA = makeMachine(root, "machineA"), homeB = makeMachine(root, "machineB");
|
|
seedMachine(homeA, { user: "alice" });
|
|
seedMachine(homeB, { user: "bob" });
|
|
|
|
const NSYNC = path.join(root, "nsync");
|
|
const commonEnv = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(NSYNC, "work") };
|
|
|
|
/* ---- A 机导出并推送 ---- */
|
|
const ex = runScript("export.mjs", { home: homeA, env: commonEnv });
|
|
assert.equal(ex.code, 0, `导出应成功\nstdout=${ex.out}\nstderr=${ex.err}`);
|
|
assert.match(ex.out, /# 导出成功/);
|
|
assert.match(ex.out, /已推送/, `应真的推到裸仓\n${ex.out}`);
|
|
|
|
const repoA = path.join(NSYNC, "work", "repo");
|
|
const latest = readJson(path.join(repoA, "latest.json"));
|
|
const snap = path.join(repoA, latest.snapshot);
|
|
|
|
// 快照结构齐全
|
|
for (const f of ["manifest.json", "mcp.servers.json", "plugins-dirs.json", "secrets.enc", "secrets.manifest.json"]) {
|
|
assert.ok(fs.existsSync(path.join(snap, f)), `快照缺 ${f}`);
|
|
}
|
|
assert.ok(fs.existsSync(path.join(snap, "custom-plugins", "zcode-tps", ".zcode-plugin", "plugin.json")), "自研插件应被带进快照");
|
|
|
|
/* ---- 关键断言:明文区不含 A 机路径,且占位符是活的 ---- */
|
|
const mcpText = fs.readFileSync(path.join(snap, "mcp.servers.json"), "utf8");
|
|
const dirsText = fs.readFileSync(path.join(snap, "plugins-dirs.json"), "utf8");
|
|
assert.doesNotMatch(mcpText, /machineA/, "MCP 明文不得残留 A 机路径(含反斜杠写法)");
|
|
assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留 A 机路径");
|
|
assert.match(mcpText, /\$\{USER_HOME_WIN\}|\$\{USER_HOME\}/, "应使用主目录占位符");
|
|
assert.match(mcpText, /\$\{DSH_HOME_WIN\}|\$\{DSH_HOME\}/, "应使用 DSH 占位符");
|
|
assert.doesNotMatch(mcpText, /AppData|machineA/i);
|
|
|
|
// 明文区无密钥值(只有 *_FILE 引用)
|
|
const mcpObj = JSON.parse(mcpText);
|
|
assert.ok(mcpObj.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("gitea-token.txt"), "密钥应以文件引用形式出现");
|
|
|
|
// secrets.manifest 只有路径+hash,没有值
|
|
const secMan = readJson(path.join(snap, "secrets.manifest.json"));
|
|
const secText = JSON.stringify(secMan);
|
|
assert.doesNotMatch(secText, /tok-alice|pw-alice/, "清单里不得出现密钥明文");
|
|
assert.ok(secMan.some((f) => f.rel === "DSH_SECRETS/gitea-token.txt"));
|
|
|
|
// manifest 全文(明文进仓)不得带出本机绝对路径/用户名
|
|
const manText = fs.readFileSync(path.join(snap, "manifest.json"), "utf8");
|
|
assert.doesNotMatch(manText, /machineA/, "manifest 不得残留本机 home 路径");
|
|
assert.match(JSON.parse(manText).zcodeHomeTemplate, /\$\{ZCODE_HOME\}/, "应存占位符模板而非绝对路径");
|
|
|
|
// 配置区(MCP/dirs/manifest)不得残留本机路径;插件源码里的硬编码只警告不中断
|
|
assert.doesNotMatch(mcpText, /machineA/, "MCP 配置区不得残留");
|
|
assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留");
|
|
assert.match(ex.out, /明文区自检:无密钥泄漏/, "应报告自检通过");
|
|
|
|
// 密文里不得出现明文密钥
|
|
const encText = fs.readFileSync(path.join(snap, "secrets.enc"), "utf8");
|
|
assert.doesNotMatch(encText, /tok-alice|pw-alice/, "密钥束必须是密文");
|
|
|
|
/* ---- git:origin 已补上、commit 已产生、追踪引用健康 ---- */
|
|
const remoteUrl = git(["-C", repoA, "remote", "get-url", "origin"]).trim();
|
|
assert.ok(remoteUrl.length > 0, "origin 必须存在(旧实现克隆失败后不补 origin)");
|
|
assert.doesNotMatch(remoteUrl, /@/, "origin 明文里不得内嵌 token(凭据只在内存里用)");
|
|
const cfgText = fs.readFileSync(path.join(repoA, ".git", "config"), "utf8");
|
|
assert.doesNotMatch(cfgText, /tok-alice|ghp_|@gitea|@.*\.git/, ".git/config 不得落 token");
|
|
assert.match(git(["-C", repoA, "log", "--oneline"]), /sync-export/, "应有导出 commit");
|
|
assert.match(git(["--git-dir", bare, "log", "--oneline", "main"]), /sync-export/, "裸仓应收到提交");
|
|
// 追踪引用必须可用,否则 git status/log origin/main 全是残废状态
|
|
const headA = git(["-C", repoA, "rev-parse", "HEAD"]).trim();
|
|
assert.equal(git(["-C", repoA, "rev-parse", "origin/main"]).trim(), headA, "origin/main 应指向已推送的提交");
|
|
assert.equal(git(["-C", repoA, "rev-parse", "main@{upstream}"]).trim(), headA, "main 的 upstream 应已绑定");
|
|
|
|
/* ---- 字节完整性:克隆一份远端,校验内容 hash 与 manifest 一致 ---- */
|
|
// Windows 上 core.autocrlf 默认可能是 true,会改写换行符让 hash 永久漂移
|
|
assert.equal(git(["-C", repoA, "config", "--local", "core.autocrlf"]).trim(), "false", "本仓应禁用 autocrlf");
|
|
assert.equal(fs.readFileSync(path.join(repoA, ".gitattributes"), "utf8").includes("* -text"), true, "应有 .gitattributes 锁死换行符转换");
|
|
|
|
const cloneDir = path.join(root, "verify-clone");
|
|
git(["clone", "-q", bare, cloneDir]);
|
|
const manifestA = readSnapshotManifest(path.join(cloneDir, latest.snapshot));
|
|
for (const c of manifestA.custom) {
|
|
const got = hashDir(path.join(cloneDir, latest.snapshot, "custom-plugins", c.name));
|
|
assert.equal(got, c.hash, `克隆回来的 ${c.name} 内容 hash 必须与 manifest 一致(实际 ${got} vs ${c.hash})`);
|
|
}
|
|
// CRLF 文件必须原样穿过 git
|
|
const crlfFile = path.join(cloneDir, latest.snapshot, "custom-plugins", "zcode-tps", "scripts", "rate.mjs");
|
|
assert.match(fs.readFileSync(crlfFile, "utf8"), /\r\n/, "CRLF 不得被 git 改写成 LF");
|
|
assert.equal(fs.readFileSync(crlfFile, "utf8"), "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n", "内容必须逐字节一致");
|
|
|
|
/* ---- B 机导入 ---- */
|
|
const im = runScript("import.mjs", { home: homeB, env: commonEnv });
|
|
assert.equal(im.code, 0, `导入应成功\nstdout=${im.out}\nstderr=${im.err}`);
|
|
assert.match(im.out, /# 导入快照/);
|
|
assert.match(im.out, /自研插件还原:2 项/);
|
|
assert.match(im.out, /密钥束还原:4 个文件/);
|
|
|
|
/* ---- 核心:路径已还原成 B 机的 ---- */
|
|
const cfgB = readJson(path.join(homeB, ".zcode", "cli", "config.json"));
|
|
const s = cfgB.mcp.servers;
|
|
const hB = norm(homeB);
|
|
assert.ok(s.context7.command.endsWith("node.exe"));
|
|
assert.ok(norm(s.context7.command).startsWith(hB), `command 应指向 B 机主目录,实际=${s.context7.command}`);
|
|
assert.ok(norm(s.mysql.args[0]).startsWith(hB), `mysql 启动脚本应指向 B 机,实际=${s.mysql.args[0]}`);
|
|
assert.ok(norm(s.ssh.env.SSH_PROFILES_FILE).startsWith(hB), "SSH_PROFILES_FILE 应指向 B 机");
|
|
assert.ok(norm(s.gitea.env.GITEA_ACCESS_TOKEN_FILE).startsWith(hB), "token 文件应指向 B 机");
|
|
assert.equal(norm(s.context7.args[0]), `${hB}/tools/ctx7/dist/index.js`,
|
|
"嵌套 args 路径应精确还原");
|
|
assert.doesNotMatch(JSON.stringify(cfgB), /machineA/, "B 机配置里不得残留 A 机路径");
|
|
assert.doesNotMatch(JSON.stringify(cfgB), /\$\{/, "B 机配置里不得残留未还原的占位符");
|
|
// 密钥文件引用字段:风格保持原样(反斜杠写法不应被强行转成正斜杠)
|
|
assert.ok(s.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("/"), "正斜杠风格应保留");
|
|
|
|
// plugins.dirs:B 机自己的 + 快照里的(A 机的已还原成 B 机),且去重
|
|
const dirsB = cfgB.plugins.dirs;
|
|
assert.deepEqual(new Set(dirsB).size, dirsB.length, "dirs 不得重复");
|
|
for (const d of dirsB) assert.match(d, /machineB/, `dirs 应全部指向 B 机:${d}`);
|
|
|
|
// 自研插件已落地
|
|
assert.ok(fs.existsSync(path.join(homeB, ".zcode/plugins/zcode-tps/scripts/rate.mjs")), "自研插件文件应还原");
|
|
|
|
/* ---- 密钥已解密落地,且 hash 与清单一致 ---- */
|
|
const giteaTok = path.join(homeB, ".dsh", "secrets", "gitea-token.txt");
|
|
assert.ok(fs.existsSync(giteaTok), "密钥文件应还原");
|
|
const want = new Map(secMan.map((f) => [f.rel, f.sha256]));
|
|
assert.equal(sha256File(giteaTok), want.get("DSH_SECRETS/gitea-token.txt"), "还原内容应与 A 机快照一致");
|
|
assert.match(fs.readFileSync(giteaTok, "utf8"), /tok-alice/, "密钥内容应原样带过来(跨机器共享凭据)");
|
|
assert.equal(sha256File(path.join(homeB, ".ssh", "ssh-profiles.json")), want.get("SSH/ssh-profiles.json"));
|
|
|
|
/* ---- 覆盖前已备份 ---- */
|
|
const bkRoot = path.join(NSYNC, "work", "backup");
|
|
assert.ok(fs.existsSync(bkRoot), "应产生备份目录");
|
|
const stamps = fs.readdirSync(bkRoot);
|
|
assert.ok(stamps.length >= 1, "应至少有一个备份批次");
|
|
const bFiles = stamps.flatMap((st) => {
|
|
const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
|
|
e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
|
|
return walk(path.join(bkRoot, st));
|
|
});
|
|
assert.ok(bFiles.some((f) => f.endsWith("config.json")), "被覆盖的 config.json 应有备份");
|
|
assert.ok(bFiles.some((f) => f.endsWith("gitea-token.txt")), "被覆盖的密钥应有备份");
|
|
const bkCfg = bFiles.find((f) => f.endsWith("config.json"));
|
|
assert.match(fs.readFileSync(bkCfg, "utf8"), /machineB/, "备份的应是 B 机导入前的旧文件");
|
|
|
|
/* ---- B 机再跑一次 status,应报告与本机一致 ---- */
|
|
const st = runScript("status.mjs", { home: homeB, env: commonEnv });
|
|
assert.equal(st.code, 0, `status 应成功\n${st.err}`);
|
|
assert.match(st.out, /自研插件:一致/);
|
|
assert.match(st.out, /密钥束:清单一致/);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:plugins/ 下的隐藏备份目录不得被当插件(旧实现会虚报并互相覆盖)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineF");
|
|
const cfg = seedMachine(home, { user: "frank" });
|
|
const z = path.join(home, ".zcode");
|
|
|
|
// 伪造一个插件备份目录:内容与 zcode-tps 同名,但版本不同
|
|
const bk = path.join(z, "plugins", ".backup-zcode-tps-20260911");
|
|
fs.mkdirSync(path.join(bk, ".zcode-plugin"), { recursive: true });
|
|
fs.writeFileSync(path.join(bk, ".zcode-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "zcode-tps", version: "0.0.0-old" }));
|
|
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
|
|
assert.equal(r.code, 0, `导出应成功(隐藏目录被跳过)\n${r.err}`);
|
|
assert.doesNotMatch(r.out, /0\.0\.0-old/, "备份目录版本不得出现在报告里");
|
|
|
|
const latest = readJson(path.join(w, "repo", "latest.json"));
|
|
const m = readJson(path.join(w, "repo", latest.snapshot, "manifest.json"));
|
|
const names = m.custom.map((c) => c.name);
|
|
assert.deepEqual(new Set(names).size, names.length, `manifest 不得有重名:${names.join(",")}`);
|
|
assert.equal(names.filter((n) => n === "zcode-tps").length, 1, "zcode-tps 只应出现一次");
|
|
assert.equal(m.custom.find((c) => c.name === "zcode-tps").version, "0.4.1", "应是真插件版本");
|
|
|
|
// 快照里的实际目录数应与 manifest 一致
|
|
const cpDir = path.join(w, "repo", latest.snapshot, "custom-plugins");
|
|
assert.deepEqual(fs.readdirSync(cpDir).sort(), names.sort(), "快照目录必须与 manifest 一致");
|
|
|
|
// 真插件内容被复制,而不是备份版本
|
|
const rj = readJson(path.join(cpDir, "zcode-tps", ".zcode-plugin", "plugin.json"));
|
|
assert.equal(rj.version, "0.4.1");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:两个插件 manifest 同名 → 导出明确报错,不静默丢一个", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineG");
|
|
seedMachine(home, { user: "grace" });
|
|
const z = path.join(home, ".zcode");
|
|
|
|
// dir 名不同,但 plugin.json 里的 name 相同
|
|
const clash = path.join(z, "plugins", "zcode-tps-fork");
|
|
fs.mkdirSync(path.join(clash, ".zcode-plugin"), { recursive: true });
|
|
fs.writeFileSync(path.join(clash, ".zcode-plugin", "plugin.json"), JSON.stringify({ name: "zcode-tps", version: "9.9.9" }));
|
|
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
|
|
assert.notEqual(r.code, 0, "重名必须中断导出");
|
|
assert.match(r.err, /名字冲突|冲突/);
|
|
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:开启模型同步 → provider 脱敏进快照,真密钥一个字节都不许留", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineH");
|
|
seedMachine(home, { user: "henry" });
|
|
|
|
// 造一份带真密钥的 v2/config.json
|
|
const SECRET_A = "sk-providerAAAAsecret9999", SECRET_B = "sk-providerBBBBsecret8888";
|
|
const v2 = path.join(home, ".zcode", "v2");
|
|
fs.mkdirSync(v2, { recursive: true });
|
|
fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: {
|
|
"builtin:x": { name: "X", kind: "anthropic", models: { m1: {}, m2: {} }, options: { apiKey: SECRET_A } },
|
|
"custom:y": { name: "Y", kind: "openai-compatible", models: { m3: {} }, options: { apiKey: SECRET_B, baseURL: "https://api.example.invalid/v1" } },
|
|
} }, null, 2));
|
|
fs.writeFileSync(path.join(v2, "setting.json"), JSON.stringify({
|
|
modelProviderFamilyModes: { a: "auto" },
|
|
modelProviderFamilySelectedKeys: { a: "builtin:x" },
|
|
providerFamilyDomain: "example.invalid",
|
|
windowState: { should: "not-be-synced" },
|
|
}, null, 2));
|
|
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1",
|
|
} });
|
|
assert.equal(r.code, 0, `导出应成功\n${r.err}`);
|
|
assert.match(r.out, /模型 provider:2 个/, `应报告 2 个 provider → ${(r.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
|
|
assert.match(r.out, /明文区自检:无密钥泄漏/);
|
|
|
|
const latest = readJson(path.join(w, "repo", "latest.json"));
|
|
const snap = path.join(w, "repo", latest.snapshot);
|
|
const m = readJson(path.join(snap, "manifest.json"));
|
|
|
|
// manifest 只记结构
|
|
assert.equal(m.modelProviders.length, 2);
|
|
assert.deepEqual(m.modelProvidersWithSecrets.sort(), ["builtin:x", "custom:y"], "应标记哪些 provider 带密钥");
|
|
assert.doesNotMatch(JSON.stringify(m), /sk-provider/, "manifest 不得含密钥值");
|
|
assert.deepEqual(m.modelProviders.find((p) => p.id === "builtin:x").models, ["m1", "m2"], "应记录模型清单");
|
|
assert.equal(m.modelSelection.providerFamilyDomain, "example.invalid");
|
|
assert.ok(!("windowState" in (m.modelSelection ?? {})), "窗口状态之类不得进 manifest");
|
|
|
|
// 脱敏配置本体:结构在、密钥没了
|
|
const provs = readJson(path.join(snap, "v2.providers.json"));
|
|
assert.equal(provs["builtin:x"].kind, "anthropic", "非密钥字段保留");
|
|
assert.deepEqual(Object.keys(provs["builtin:x"].models), ["m1", "m2"]);
|
|
assert.equal(provs["builtin:x"].options.apiKey, "${MODEL_SECRET_REF}", "密钥位置应为占位符");
|
|
assert.equal(provs["custom:y"].options.baseURL, "https://api.example.invalid/v1", "非密钥选项保留");
|
|
|
|
// 全快照明文扫描:真密钥一个都不许出现(secrets.enc 是密文,单独放行)
|
|
const walkFiles = (d, acc = []) => {
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
const p = path.join(d, e.name);
|
|
if (e.isDirectory()) walkFiles(p, acc); else acc.push(p);
|
|
}
|
|
return acc;
|
|
};
|
|
for (const f of walkFiles(snap)) {
|
|
if (path.basename(f) === "secrets.enc") continue;
|
|
const body = fs.readFileSync(f, "utf8");
|
|
assert.doesNotMatch(body, /sk-providerAAAAsecret9999|sk-providerBBBBsecret8888/, `密钥泄漏进 ${path.relative(snap, f)}`);
|
|
}
|
|
|
|
// 导入侧:报告 provider,但默认不覆盖本机 v2/config.json
|
|
const im = runScript("import.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
|
|
assert.equal(im.code, 0, `导入应成功\n${im.err}`);
|
|
assert.match(im.out, /模型 provider:快照含 2 个/, `→ ${(im.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
|
|
const localV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8"));
|
|
assert.equal(localV2.provider["builtin:x"].options.apiKey, SECRET_A, "默认不得改动本机 v2 配置");
|
|
|
|
// 显式开启后:密钥应从加密束回填(同一台机已有真值 → 受覆盖保护,不覆盖)
|
|
const im2 = runScript("import.mjs", { home, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1",
|
|
} });
|
|
assert.equal(im2.code, 0, `开启模型同步的导入应成功\n${im2.err}`);
|
|
assert.match(im2.out, /模型 provider:新增 \d+ 个,密钥回填 \d+ 个/, `→ ${(im2.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
|
|
const afterV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8"));
|
|
assert.equal(afterV2.provider["builtin:x"].options.apiKey, SECRET_A, "本机已有真值应受覆盖保护");
|
|
assert.ok(/备份 v2\/config\.json/.test(im2.out), "改 v2 配置前应先备份");
|
|
assert.ok(afterV2.provider["builtin:x"].options.apiKey.length >= 8, "密钥必须仍是可用值");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:空密钥的本机 → 模型密钥从加密束回填成功", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const homeA = makeMachine(root, "machineK1"), homeB = makeMachine(root, "machineK2");
|
|
seedMachine(homeA, { user: "kim" });
|
|
seedMachine(homeB, { user: "leo" });
|
|
|
|
const SECRET = "sk-roundtripsecret777777";
|
|
const v2A = path.join(homeA, ".zcode", "v2");
|
|
fs.mkdirSync(v2A, { recursive: true });
|
|
fs.writeFileSync(path.join(v2A, "config.json"), JSON.stringify({ provider: {
|
|
"builtin:q": { name: "Q", kind: "anthropic", models: { m1: {} }, options: { apiKey: SECRET } },
|
|
} }, null, 2));
|
|
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
const W = path.join(root, "work");
|
|
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W };
|
|
|
|
// A 机导出(带模型)
|
|
const ex = runScript("export.mjs", { home: homeA, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } });
|
|
assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`);
|
|
// 快照明文里不得有密钥
|
|
const latest = readJson(path.join(W, "repo", "latest.json"));
|
|
const snap = path.join(W, "repo", latest.snapshot);
|
|
for (const f of fs.readdirSync(snap)) {
|
|
if (f === "secrets.enc") continue;
|
|
const p = path.join(snap, f);
|
|
if (fs.statSync(p).isFile()) {
|
|
assert.doesNotMatch(fs.readFileSync(p, "utf8"), /sk-roundtripsecret/, `密钥泄漏进 ${f}`);
|
|
}
|
|
}
|
|
|
|
// B 机导入:B 机 v2 里没有这个 provider → 应新建并回填密钥
|
|
const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } });
|
|
assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`);
|
|
const v2B = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode", "v2", "config.json"), "utf8"));
|
|
assert.ok(v2B.provider?.["builtin:q"], "B 机应新建该 provider");
|
|
assert.equal(v2B.provider["builtin:q"].options.apiKey, SECRET, "密钥应跨机回填成功");
|
|
assert.equal(v2B.provider["builtin:q"].kind, "anthropic", "结构字段应一并还原");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:未开启模型同步时,provider 密钥绝不进快照", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineI");
|
|
seedMachine(home, { user: "ivan" });
|
|
const v2 = path.join(home, ".zcode", "v2");
|
|
fs.mkdirSync(v2, { recursive: true });
|
|
fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: {
|
|
"builtin:z": { name: "Z", kind: "anthropic", models: { m: {} }, options: { apiKey: "sk-must-never-appear123" } },
|
|
} }, null, 2));
|
|
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
|
|
assert.equal(r.code, 0, `导出应成功\n${r.err}`);
|
|
assert.doesNotMatch(r.out, /模型 provider/, "默认不应报告 provider");
|
|
const latest = readJson(path.join(w, "repo", "latest.json"));
|
|
const snap = path.join(w, "repo", latest.snapshot);
|
|
assert.ok(!fs.existsSync(path.join(snap, "v2.providers.json")), "默认不得写 provider 文件");
|
|
assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /sk-must-never/, "manifest 不得含密钥");
|
|
assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /modelProviders/, "默认不应记录 provider 摘要");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:ZCODE_SYNC_SKIP_SECRETS 跳过指定密钥(连接统一、身份各用各的)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const homeA = makeMachine(root, "machineS1"), homeB = makeMachine(root, "machineS2");
|
|
seedMachine(homeA, { user: "sam" });
|
|
seedMachine(homeB, { user: "tina" });
|
|
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
const W = path.join(root, "work");
|
|
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W };
|
|
|
|
const ex = runScript("export.mjs", { home: homeA, env });
|
|
assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`);
|
|
|
|
// B 机导入时跳过 gitea-token:本机 token 必须原样保留
|
|
const beforeTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8");
|
|
const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_SKIP_SECRETS: "DSH_SECRETS/gitea-token.txt" } });
|
|
assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`);
|
|
assert.match(im.out, /跳过 1 个\(ZCODE_SYNC_SKIP_SECRETS\):DSH_SECRETS\/gitea-token.txt/);
|
|
const afterTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8");
|
|
assert.equal(afterTok, beforeTok, "被跳过的 token 不得被快照覆盖");
|
|
// 没被跳过的照常还原
|
|
const latest = readJson(path.join(W, "repo", "latest.json"));
|
|
assert.ok(latest.snapshot, "快照应存在");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:配置区残留本机路径 → 导出中断(旧实现会静默推出去)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineJ");
|
|
seedMachine(home, { user: "judy" });
|
|
const w = path.join(root, "w");
|
|
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w };
|
|
|
|
// 塞一条「别的用户名」的绝对路径:占位符规则只认本机用户名,这条必然漏网,
|
|
// 但到了别人机器上就是错的 —— 正是自检要拦的东西
|
|
const cfgPath = path.join(home, ".zcode", "cli", "config.json");
|
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
cfg.mcp.servers.weird = { type: "stdio", command: "C:\\Users\\someoneelse\\custom\\dir\\tool.exe", args: ["--x"] };
|
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
|
|
const r = runScript("export.mjs", { home, env });
|
|
assert.notEqual(r.code, 0, "配置区含未占位符化的绝对路径必须中断");
|
|
assert.match(r.err, /未占位符化的绝对路径|残留本机路径|占位符化不完整/);
|
|
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
|
|
|
|
// 显式放行后应能导出
|
|
const r2 = runScript("export.mjs", { home, env: { ...env, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_ALLOW_PATH_LEAK: "1" } });
|
|
assert.equal(r2.code, 0, `放行后应成功\n${r2.err}`);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
/* ---------- 失败路径 ---------- */
|
|
|
|
test("E2E:口令错 → 导出直接失败,且不产生半截快照", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineC");
|
|
seedMachine(home, { user: "carol" });
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
|
|
// 口令缺失
|
|
const noPass = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w1"), ZCODE_SYNC_PASSPHRASE: "" } });
|
|
assert.notEqual(noPass.code, 0, "无口令必须失败");
|
|
assert.match(noPass.err, /未设置|ZCODE_SYNC_PASSPHRASE/);
|
|
|
|
// 口令过短
|
|
const short = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_PASSPHRASE: "abc" } });
|
|
assert.notEqual(short.code, 0, "口令过短必须失败");
|
|
assert.match(short.err, /太短/);
|
|
|
|
// 导入方口令错 → 解密失败
|
|
const w3 = path.join(root, "w3");
|
|
const env3 = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w3 };
|
|
const ok = runScript("export.mjs", { home, env: env3 });
|
|
assert.equal(ok.code, 0, `导出应成功\n${ok.out}\n${ok.err}`);
|
|
const bad = runScript("import.mjs", { home, env: { ...env3, ZCODE_SYNC_PASSPHRASE: "totally-wrong-pass" } });
|
|
assert.notEqual(bad.code, 0, "错误口令导入必须失败");
|
|
assert.match(bad.err, /解密失败/);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:明文区出现真密钥 → 导出中断(不落盘)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineD");
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
// 故意把一个真密钥写进 MCP env(而不是 *_FILE 引用)
|
|
seedMachine(home, { user: "dave", mcpExtra: {
|
|
leaky: { type: "stdio", command: "node", env: { GITEA_TOKEN: "ghp_realtokenvalue12345" } },
|
|
} });
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w } });
|
|
assert.notEqual(r.code, 0, "含明文密钥必须中断导出");
|
|
assert.match(r.err, /疑似含密钥/);
|
|
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
|
|
|
|
// 白名单放行
|
|
const r2 = runScript("export.mjs", { home, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"),
|
|
ZCODE_SYNC_ALLOW_SECRETS: "$.mcpServers.leaky.env.GITEA_TOKEN",
|
|
} });
|
|
assert.equal(r2.code, 0, `白名单应放行\n${r2.err}`);
|
|
assert.match(r2.out, /# 导出成功/);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:自研插件源码里写死同步口令 → 导出中断(钥匙不许跟保险箱同仓)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineK");
|
|
seedMachine(home, { user: "kim" });
|
|
// 模拟 live-roundtrip.mjs 曾经的样子:测试文件回退到写死口令,
|
|
// 而它作为自研插件源码会被整体归档进快照 —— 加密就形同虚设
|
|
fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"),
|
|
`const PASS = process.env.ZCODE_SYNC_PASSPHRASE || "${PASS}";\nexport default PASS;\n`);
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
|
|
assert.notEqual(r.code, 0, "源码含口令字面量必须中断导出");
|
|
assert.match(r.err, /同步口令本体/);
|
|
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
|
|
|
|
// 改成从环境变量读取后,同一个插件应当能正常导出
|
|
fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"),
|
|
"const PASS = process.env.ZCODE_SYNC_PASSPHRASE;\nexport default PASS;\n");
|
|
const r2 = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: path.join(root, "w2") } });
|
|
assert.equal(r2.code, 0, `去掉字面量后应成功\n${r2.err}`);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:无推送模式只落本地,不碰远端", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const home = makeMachine(root, "machineE");
|
|
seedMachine(home, { user: "erin" });
|
|
const w = path.join(root, "w");
|
|
const r = runScript("export.mjs", { home, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w,
|
|
ZCODE_SYNC_GIT_REMOTE: path.join(root, "nonexistent.git"),
|
|
} });
|
|
assert.equal(r.code, 0, `无推送应成功\n${r.err}`);
|
|
assert.match(r.out, /跳过推送/);
|
|
assert.ok(fs.existsSync(path.join(w, "repo", "latest.json")), "本地快照应落盘");
|
|
|
|
// status 在无远端快照时给友好提示而非报错
|
|
const st = runScript("status.mjs", { home, env: { ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_GIT_REMOTE: path.join(root, "none2.git") } });
|
|
assert.equal(st.code, 0, `status 应优雅退出\n${st.err}`);
|
|
assert.match(st.out, /远端尚无快照/);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:UI 分组(provider_config)完整同步 —— 旧实现会丢只在其中的分组", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const homeA = makeMachine(root, "machineL");
|
|
seedMachine(homeA, { user: "lena" });
|
|
fs.mkdirSync(path.join(homeA, ".zcode/v2"), { recursive: true });
|
|
const SHARED = "11111111-1111-1111-1111-111111111111";
|
|
const ONLY_PC = "new-provider";
|
|
const rule = (id, name, key) => ({ providerId: id, providerName: name, config: {
|
|
group: "standard-personal",
|
|
access: { type: "api-key", apiKey: key },
|
|
api: { type: "openai-chat-completions", baseUrl: "http://example.invalid/v1" },
|
|
personalModelIds: [`${name}-m1`], modelOrder: [`${name}-m1`],
|
|
} });
|
|
// config.json 只有 SHARED;provider_config.json 有两个(ONLY_PC 只存在于这里)
|
|
fs.writeFileSync(path.join(homeA, ".zcode/v2/config.json"), JSON.stringify({
|
|
provider: { [SHARED]: { name: "shared", kind: "openai-compatible", models: { "shared-m1": {} },
|
|
options: { apiKey: "sk-e2e-shared-1234567890" } } },
|
|
}, null, 2));
|
|
fs.writeFileSync(path.join(homeA, ".zcode/v2/provider_config.json"), JSON.stringify({
|
|
schemaVersion: 1,
|
|
config: {
|
|
providerOrder: [SHARED, ONLY_PC],
|
|
providerConfigRules: { providerRules: [rule(SHARED, "shared", "sk-e2e-shared-1234567890"),
|
|
rule(ONLY_PC, "only-here", "sk-e2e-onlyhere-0987654321")] },
|
|
modelConfigRules: { providerModelRules: [
|
|
{ modelId: "shared-m1", config: { properties: { contextWindow: 1000000 } }, providerId: SHARED }],
|
|
manualProviderModelRules: [] },
|
|
},
|
|
}, null, 2));
|
|
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
const w1 = path.join(root, "w1");
|
|
const ex = runScript("export.mjs", { home: homeA, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w1 } });
|
|
assert.equal(ex.code, 0, `导出差错:\n${ex.err}`);
|
|
assert.match(ex.out, /UI 分组:2 个/, `导出报告应含分组数 → ${(ex.out.match(/UI 分组:[^\n]*/) || ["—"])[0]}`);
|
|
|
|
// 快照明文区必须脱敏
|
|
const snapName = JSON.parse(fs.readFileSync(path.join(w1, "repo", "latest.json"), "utf8")).snapshot;
|
|
const pcSnap = fs.readFileSync(path.join(w1, "repo", snapName, "v2.provider-config.json"), "utf8");
|
|
assert.ok(!pcSnap.includes("sk-e2e-shared-"), "明文区不得出现真密钥(共有分组)");
|
|
assert.ok(!pcSnap.includes("sk-e2e-onlyhere-"), "明文区不得出现真密钥(独有分组)");
|
|
assert.match(pcSnap, /\$\{MODEL_SECRET_REF\}/, "密钥位置应为占位符");
|
|
const manSnap = fs.readFileSync(path.join(w1, "repo", snapName, "manifest.json"), "utf8");
|
|
assert.ok(!manSnap.includes("sk-e2e-"), "manifest 不得出现密钥值");
|
|
|
|
// 队友机器:B 完全没有任何 provider_config.json
|
|
const homeB = makeMachine(root, "machineM");
|
|
seedMachine(homeB, { user: "mike" });
|
|
fs.mkdirSync(path.join(homeB, ".zcode/v2"), { recursive: true });
|
|
fs.writeFileSync(path.join(homeB, ".zcode/v2/config.json"), JSON.stringify({ provider: {}, setting: {} }, null, 2));
|
|
const w2 = path.join(root, "w2");
|
|
const im = runScript("import.mjs", { home: homeB, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w2 } });
|
|
assert.equal(im.code, 0, `导入差错:\n${im.err}`);
|
|
assert.match(im.out, /UI 分组:2 个分组/, `导入报告应含分组 → ${(im.out.match(/UI 分组:[^\n]*/) || ["—"])[0]}`);
|
|
|
|
const got = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode/v2/provider_config.json"), "utf8")).config;
|
|
assert.deepEqual(got.providerOrder, [SHARED, ONLY_PC], "分组顺序必须原样还原");
|
|
const byId = new Map(got.providerConfigRules.providerRules.map((r) => [r.providerId, r]));
|
|
assert.equal(byId.size, 2, "两个分组都要在(旧实现会丢掉只存在于 provider_config 的那个)");
|
|
assert.equal(byId.get(SHARED).config.access.apiKey, "sk-e2e-shared-1234567890", "共有分组密钥回填");
|
|
assert.equal(byId.get(ONLY_PC).config.access.apiKey, "sk-e2e-onlyhere-0987654321",
|
|
"只在 provider_config 里存在的分组,密钥也必须回填");
|
|
assert.equal(byId.get(ONLY_PC).config.group, "standard-personal", "group 字段要还原");
|
|
assert.deepEqual(byId.get(ONLY_PC).config.personalModelIds, ["only-here-m1"], "模型清单要还原");
|
|
assert.equal(got.modelConfigRules.providerModelRules.length, 1, "模型级配置(如 contextWindow)要带过来");
|
|
assert.equal(got.modelConfigRules.providerModelRules[0].config.properties.contextWindow, 1000000);
|
|
|
|
// 幂等:再导一次不应重复追加
|
|
const w3 = path.join(root, "w3");
|
|
const im2 = runScript("import.mjs", { home: homeB, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w3 } });
|
|
assert.equal(im2.code, 0, `二次导入应成功\n${im2.err}`);
|
|
const got2 = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode/v2/provider_config.json"), "utf8")).config;
|
|
assert.equal(got2.providerConfigRules.providerRules.length, 2, "重复导入不得追加重复分组");
|
|
assert.equal(got2.modelConfigRules.providerModelRules.length, 1, "重复导入不得追加重复模型级配置");
|
|
assert.equal(got2.providerOrder.length, 2, "重复导入不得把 providerOrder 撑大");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("E2E:OVERWRITE_MODEL_SECRETS —— 默认护住本机密钥,显式开启才强制覆盖(两份文件)", { timeout: 120000 }, () => {
|
|
const root = sandboxRoot();
|
|
const PID = "aaaaaaaa-1111-2222-3333-444444444444";
|
|
const A_KEY = "sk-snapshot-source-1111111111"; // 快照里的值
|
|
const B_KEY = "sk-stale-local-9999999999"; // 队友机器上已有的陈旧值
|
|
const writeV2 = (home, key) => {
|
|
fs.mkdirSync(path.join(home, ".zcode/v2"), { recursive: true });
|
|
fs.writeFileSync(path.join(home, ".zcode/v2/config.json"), JSON.stringify({
|
|
provider: { [PID]: { name: "p", kind: "openai-compatible", models: { m: {} }, options: { apiKey: key } } },
|
|
}, null, 2));
|
|
fs.writeFileSync(path.join(home, ".zcode/v2/provider_config.json"), JSON.stringify({
|
|
schemaVersion: 1,
|
|
config: {
|
|
providerOrder: [PID],
|
|
providerConfigRules: { providerRules: [{ providerId: PID, providerName: "p",
|
|
config: { group: "standard-personal", access: { type: "api-key", apiKey: key } } }] },
|
|
modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
|
|
},
|
|
}, null, 2));
|
|
};
|
|
const readKeys = (home) => ({
|
|
cfg: JSON.parse(fs.readFileSync(path.join(home, ".zcode/v2/config.json"), "utf8")).provider[PID].options.apiKey,
|
|
pc: JSON.parse(fs.readFileSync(path.join(home, ".zcode/v2/provider_config.json"), "utf8"))
|
|
.config.providerConfigRules.providerRules[0].config.access.apiKey,
|
|
});
|
|
|
|
const homeA = makeMachine(root, "machineN");
|
|
seedMachine(homeA, { user: "nina" });
|
|
writeV2(homeA, A_KEY);
|
|
|
|
const bare = path.join(root, "bare.git");
|
|
git(["init", "--bare", "-b", "main", bare]);
|
|
const ex = runScript("export.mjs", { home: homeA, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare,
|
|
ZCODE_SYNC_WORK: path.join(root, "w1") } });
|
|
assert.equal(ex.code, 0, `导出差错:\n${ex.err}`);
|
|
|
|
// 队友机器:两份文件里都已有一个"陈旧的、非空的"密钥
|
|
const homeB = makeMachine(root, "machineO");
|
|
seedMachine(homeB, { user: "omar" });
|
|
const importInto = (work, extra = {}) => runScript("import.mjs", { home: homeB, env: {
|
|
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare,
|
|
ZCODE_SYNC_WORK: path.join(root, work), ...extra } });
|
|
|
|
// --- 默认行为:护住本机密钥,不覆盖 ---
|
|
writeV2(homeB, B_KEY);
|
|
const r1 = importInto("w2");
|
|
assert.equal(r1.code, 0, `导入应成功\n${r1.err}`);
|
|
assert.match(r1.out, /跳过覆盖本机已有密钥|护住本机已有密钥/, `默认应报告被护住的密钥 → ${(r1.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
|
|
let got = readKeys(homeB);
|
|
assert.equal(got.cfg, B_KEY, "默认不得覆盖 config.json 里的本机密钥");
|
|
assert.equal(got.pc, B_KEY, "默认不得覆盖 provider_config.json 里的本机密钥");
|
|
|
|
// --- 显式开启:强制覆盖为快照值(两份文件都要换) ---
|
|
writeV2(homeB, B_KEY);
|
|
const r2 = importInto("w3", { ZCODE_SYNC_OVERWRITE_MODEL_SECRETS: "1" });
|
|
assert.equal(r2.code, 0, `强制覆盖导入应成功\n${r2.err}`);
|
|
got = readKeys(homeB);
|
|
assert.equal(got.cfg, A_KEY, "强制覆盖后 config.json 应等于快照值");
|
|
assert.equal(got.pc, A_KEY, "强制覆盖后 provider_config.json 应等于快照值");
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|