Files
zcode-tooling/zcode-env-sync/tests/live-roundtrip.mjs
T

149 lines
9.1 KiB
JavaScript

/**
* 真机往返验证:用假 home 走真实 Gitea 私仓(不发任何本地配置改动)。
* - 从 token 文件读凭据(验证 readToken 路径)
* - 真 clone 私仓
* - 解密密钥束
* - 把路径还原到"假机器"上
* 跑法: node tests/live-roundtrip.mjs
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts");
// 不回退到写死口令:本文件会作为自研插件源码被归档进快照,字面量一旦落在这里,
// 就等于把 secrets.enc 的钥匙连同快照一起推进仓库(export 的自检现在也会拦)。
const PASS = process.env.ZCODE_SYNC_PASSPHRASE;
if (!PASS) {
console.error("未设置 ZCODE_SYNC_PASSPHRASE:本测试要用真实口令走真实私仓,拒绝使用写死的默认口令。");
console.error("用法: ZCODE_SYNC_PASSPHRASE='团队口令' node tests/live-roundtrip.mjs");
process.exit(1);
}
const REAL_HOME = os.homedir();
const TOKEN_SRC = path.join(REAL_HOME, ".dsh", "secrets", "gitea-token.txt");
// 假机器建在 C:/ 根下,而不是真实用户目录里:
// 1) 路径里不含导出机的用户名,才能真正验证「跨机器不残留」
// 2) C:/Users/<你> 这类字段会按 ${USERS_*} 还原成 C:/Users/<假机器名>,与真实跨机行为一致
const root = fs.mkdtempSync("C:/zes-live-");
const fake = path.join(root, "fakemachine");
const FAKE_USER = path.basename(fake);
/** 跨机器还原后路径应有的两种形态 */
const HOME_CLASS = fake.replace(/\\/g, "/"); // ${ZCODE_HOME} ${DSH_HOME} ${USER_HOME}
const USER_CLASS = `C:/Users/${FAKE_USER}`; // ${USERS_POSIX} ${USERS_WIN}
const log = [];
const ok = (c, m) => { log.push(`${c ? " ok " : " FAIL "} ${m}`); if (!c) process.exitCode = 1; };
function run(script, args = []) {
const env = { ...process.env, USERPROFILE: fake, HOME: fake, ZCODE_SYNC_PASSPHRASE: PASS,
ZCODE_SYNC_WORK: path.join(root, "work") };
delete env.HOMEDRIVE; delete env.HOMEPATH; delete env.ZCODE_SYNC_GIT_REMOTE;
delete env.ZCODE_SYNC_NO_PUSH; delete env.ZCODE_SYNC_ALLOW_SECRETS;
const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], { env, encoding: "utf8", timeout: 120000 });
return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" };
}
// 造假机器骨架 + 真实 token(让脚本走 readToken 分支)
fs.mkdirSync(path.join(fake, ".dsh", "secrets"), { recursive: true });
fs.mkdirSync(path.join(fake, ".ssh"), { recursive: true });
fs.mkdirSync(path.join(fake, ".zcode", "cli", "plugins", "data"), { recursive: true });
fs.mkdirSync(path.join(fake, ".zcode", "plugins"), { recursive: true });
fs.copyFileSync(TOKEN_SRC, path.join(fake, ".dsh", "secrets", "gitea-token.txt"));
ok(true, `假机器:${fake}`);
// 放一份"本机将被覆盖"的旧配置,验证备份
fs.writeFileSync(path.join(fake, ".dsh", "secrets", "gitea-token.txt"), fs.readFileSync(TOKEN_SRC));
fs.writeFileSync(path.join(fake, ".dsh", "secrets", "mysql-root-password.txt"), "OLD-FAKE-PASSWORD");
fs.writeFileSync(path.join(fake, ".ssh", "ssh-profiles.json"), JSON.stringify({ profiles: { old: { host: "old.invalid" } } }));
fs.writeFileSync(path.join(fake, ".zcode", "cli", "config.json"), JSON.stringify({ mcp: { servers: { stale: { command: "stale" } } }, plugins: { dirs: [] } }, null, 2));
/* ---- 1. 真 clone + 还原 ---- */
const im = run("import.mjs");
ok(im.code === 0, `import 退出码 0(实际 ${im.code})`);
if (im.code !== 0) console.log(im.err);
ok(/# 导入快照/.test(im.out), "输出含「# 导入快照」");
ok(/自研插件还原:3 项/.test(im.out), `自研插件还原 3 项 → ${(im.out.match(/自研插件还原:[^\n]*/) || ["—"])[0]}`);
ok(/密钥束还原:8 个文件/.test(im.out), `密钥束还原 8 个文件 → ${(im.out.match(/密钥束还原:[^\n]*/) || ["—"])[0]}`);
ok(/本机备份:/.test(im.out), "报告了备份位置");
const repo = path.join(root, "work", "repo");
ok(fs.existsSync(path.join(repo, ".git")), "从真 Gitea clone 成功");
ok(fs.existsSync(path.join(repo, "latest.json")), "拿到 latest.json");
const cfgText = fs.readFileSync(path.join(repo, ".git", "config"), "utf8");
ok(!/@/.test(cfgText.match(/url = .*/)?.[0] ?? ""), `.git/config 里 origin 不含 token(${(cfgText.match(/url = .*/) || ["?"])[0]})`);
/* ---- 2. 核心:路径还原到假机器 ---- */
const cfg = JSON.parse(fs.readFileSync(path.join(fake, ".zcode", "cli", "config.json"), "utf8"));
const servers = cfg.mcp.servers;
ok(!("stale" in servers), "旧 MCP server 已被快照内容替换");
ok(Object.keys(servers).length === 14, `MCP server 共 14 个(实际 ${Object.keys(servers).length})`);
const all = JSON.stringify(cfg);
ok(!/12914/.test(all), "还原后配置里不得残留导出机的用户名 12914");
ok(!/\$\{/.test(all), "还原后不得残留未替换的占位符");
const pathFields = [];
/** 只挑真正像文件系统路径的值:URL、开关、端口之类不参与重定向断言 */
const looksLikePath = (v) => typeof v === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(v) && /(^[a-zA-Z]:[\\/]|^\/)/.test(v);
for (const [nm, s] of Object.entries(servers)) {
if (looksLikePath(s.command)) pathFields.push([`${nm}.command`, s.command]);
(s.args ?? []).forEach((a, i) => { if (looksLikePath(a)) pathFields.push([`${nm}.args[${i}]`, a]); });
for (const [k, v] of Object.entries(s.env ?? {})) if (looksLikePath(v)) pathFields.push([`${nm}.env.${k}`, v]);
}
const wrong = pathFields.filter(([, v]) => {
const n = v.replace(/\\/g, "/");
return !n.startsWith(HOME_CLASS) && !n.startsWith(USER_CLASS);
});
ok(wrong.length === 0, `全部 ${pathFields.length} 条路径字段都已重定向(本机 home 或 ${USER_CLASS})${wrong.length ? " 例外:" + JSON.stringify(wrong.slice(0, 3)) : ""}`);
const mysqlLaunch = servers.mysql.args[0].replace(/\\/g, "/");
ok(mysqlLaunch === `${HOME_CLASS}/.dsh/secrets/mysql-mcp-launch.mjs`, `mysql 启动脚本精确还原 → ${mysqlLaunch}`);
// C:/Users/<你>/... 这类字段按 ${USERS_*} 还原成 C:/Users/<本机用户名>,与真实跨机行为一致
ok(servers.ssh.env.SSH_PROFILES_FILE.replace(/\\/g, "/") === `${USER_CLASS}/.ssh/ssh-profiles.json`,
`SSH_PROFILES_FILE 精确还原 → ${servers.ssh.env.SSH_PROFILES_FILE}`);
/* ---- 3. 密钥:解密 + 内容 + 备份 ---- */const tok = fs.readFileSync(path.join(fake, ".dsh", "secrets", "gitea-token.txt"), "utf8").trim();
ok(tok.length === 40, `gitea token 已解密(长度 ${tok.length})`);
const realTok = fs.readFileSync(TOKEN_SRC, "utf8").trim();
ok(tok === realTok, "token 与导出机一致");
const pw = fs.readFileSync(path.join(fake, ".dsh", "secrets", "mysql-root-password.txt"), "utf8");
ok(pw !== "OLD-FAKE-PASSWORD", "旧密码已被快照版本覆盖");
const sp = JSON.parse(fs.readFileSync(path.join(fake, ".ssh", "ssh-profiles.json"), "utf8"));
ok(!sp.profiles?.old, "旧 ssh-profiles 已被覆盖");
const bkRoot = path.join(root, "work", "backup");
const stamps = fs.readdirSync(bkRoot);
ok(stamps.length >= 1, `产生了备份批次(${stamps.join(",")})`);
const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
const bFiles = stamps.flatMap((s) => walk(path.join(bkRoot, s)));
ok(bFiles.some((f) => f.endsWith("mysql-root-password.txt")), "被覆盖的密码有备份");
const bPw = bFiles.find((f) => f.endsWith("mysql-root-password.txt"));
ok(fs.readFileSync(bPw, "utf8") === "OLD-FAKE-PASSWORD", "备份内容确是覆盖前的旧值");
/* ---- 4. status 复查 ---- */
const st = run("status.mjs");
ok(st.code === 0, `status 退出码 0(实际 ${st.code})`);
ok(/自研插件:一致/.test(st.out), `自研插件与快照一致 → ${(st.out.match(/自研插件:[^\n]*/) || ["—"])[0]}`);
ok(/密钥束:清单一致/.test(st.out), `密钥清单与快照一致 → ${(st.out.match(/密钥束:[^\n]*/) || ["—"])[0]}`);
if (/差异/.test(st.out)) { console.log("\n--- status 原始输出 ---\n" + st.out + "\n-----------------------"); }
// 逐插件哈希对照,漂移时能直接看出是哪个
if (/自研插件:差异/.test(st.out)) {
const { hashDir } = await import(pathToFileURL(path.join(SCRIPTS, "sync-core.mjs")).href);
const latest = JSON.parse(fs.readFileSync(path.join(repo, "latest.json"), "utf8"));
const m = JSON.parse(fs.readFileSync(path.join(repo, latest.snapshot, "manifest.json"), "utf8"));
for (const c of m.custom) {
const snapH = hashDir(path.join(repo, latest.snapshot, "custom-plugins", c.name));
const locH = (() => { try { return hashDir(path.join(fake, ".zcode", "plugins", c.name)); } catch { return "ERR"; } })();
log.push(` · ${c.name}: manifest=${c.hash} 快照=${snapH} 本机=${locH}${c.hash === locH ? "" : " <-- 漂移"}`);
}
}
console.log(`\n=== 真机往返验证 (${log.filter((l) => l.startsWith(" ok")).length}/${log.length}) ===`);
console.log(log.join("\n"));
fs.rmSync(root, { recursive: true, force: true });
console.log(process.exitCode ? "\n❌ 有失败项" : "\n✅ 全部通过");