CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
666 lines
36 KiB
TypeScript
666 lines
36 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
|
|
import {
|
|
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync,
|
|
symlinkSync, utimesSync, writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId,
|
|
downloadWithCurl, nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
|
|
type ClientReleaseManifest,
|
|
} from "../src/client-installer.js";
|
|
|
|
test("client paths stay in stable per-user locations on all supported platforms", () => {
|
|
assert.equal(clientPaths({}, "linux", "/users/alice").root, "/users/alice/.local/share/dingtalk-approval");
|
|
assert.equal(clientPaths({ XDG_DATA_HOME: "/data/alice" }, "linux", "/users/alice").root, "/data/alice/dingtalk-approval");
|
|
assert.equal(clientPaths({}, "darwin", "/Users/alice").root, "/Users/alice/Library/Application Support/dingtalk-approval");
|
|
assert.equal(clientPaths({ LOCALAPPDATA: "C:\\Users\\alice\\AppData\\Local" }, "win32", "C:\\Users\\alice").root, "C:\\Users\\alice\\AppData\\Local\\DingTalkApproval");
|
|
assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge");
|
|
});
|
|
|
|
test("client launchers pin the selected Node executable on Windows and POSIX", () => {
|
|
const windows = clientLauncherBodies("win32", "C:\\Users\\100% ready\\node.exe");
|
|
assert.match(windows.cli, /^@echo off\r\n"C:\\Users\\100%% ready\\node\.exe"/);
|
|
assert.match(windows.bridge, /launcher\.mjs" bridge %\*/);
|
|
assert.doesNotMatch(windows.bridge, /\r\nnode /);
|
|
|
|
const posix = clientLauncherBodies("linux", "/Users/alice's runtime/node");
|
|
assert.match(posix.cli, /exec '\/Users\/alice'"'"'s runtime\/node'/);
|
|
assert.match(posix.bridge, /launcher\.mjs" bridge "\$@"/);
|
|
assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/);
|
|
});
|
|
|
|
test("client installer lock rejects concurrency and recovers a dead owner", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-lock-"));
|
|
const appRoot = join(root, "dingtalk-approval");
|
|
mkdirSync(appRoot, { recursive: true });
|
|
const release = acquireClientInstallLock(appRoot);
|
|
assert.throws(() => acquireClientInstallLock(appRoot), /另一个安装或更新进程仍在运行/);
|
|
release();
|
|
|
|
const lockDir = join(appRoot, ".install-lock");
|
|
mkdirSync(lockDir, { recursive: true });
|
|
writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: 2_147_483_647, token: "dead" }));
|
|
const recovered = acquireClientInstallLock(appRoot);
|
|
assert.equal(existsSync(join(lockDir, "owner.json")), true);
|
|
recovered();
|
|
assert.equal(existsSync(lockDir), false);
|
|
|
|
mkdirSync(lockDir, { recursive: true });
|
|
writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, token: "expired" }));
|
|
const expiredAt = new Date(Date.now() - 16 * 60_000);
|
|
utimesSync(lockDir, expiredAt, expiredAt);
|
|
const expired = acquireClientInstallLock(appRoot);
|
|
expired();
|
|
assert.equal(existsSync(lockDir), false, "an implausibly old lock cannot block installation forever after PID reuse");
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("direct JSON entrypoint never silently handles a controlled failure", () => {
|
|
const result = spawnSync(process.execPath, ["dist/src/client-installer.js", "unsupported", "--json"], { encoding: "utf8" });
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /^\[dingtalk-approval\] 失败 .*unsupported/);
|
|
const error = JSON.parse(result.stdout);
|
|
assert.equal(error.ok, false);
|
|
assert.match(error.error, /unsupported/);
|
|
});
|
|
|
|
test("npm subprocess timeout is bounded and returns an actionable diagnostic", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-npm-timeout-"));
|
|
try {
|
|
const sleepyNpm = join(root, "sleepy-npm.mjs");
|
|
writeFileSync(sleepyNpm, "setTimeout(() => {}, 10_000);\n");
|
|
const result = runNpm(
|
|
{ env: { npm_execpath: sleepyNpm }, platform: process.platform },
|
|
["--version"],
|
|
{ timeoutMs: 50 },
|
|
);
|
|
assert.equal((result.error as NodeJS.ErrnoException | undefined)?.code, "ETIMEDOUT");
|
|
assert.match(npmFailureMessage(result, 50), /npm 超过 1 秒仍未完成.*宿主工具超时设置/);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("npm subprocess removes Node fetch proxy flags without dropping npm proxy settings", () => {
|
|
const input = {
|
|
NODE_OPTIONS: "--trace-warnings --use-env-proxy --max-old-space-size=512",
|
|
NODE_USE_ENV_PROXY: "1",
|
|
HTTPS_PROXY: "http://proxy.example.test:8080",
|
|
NO_PROXY: "localhost,127.0.0.1",
|
|
};
|
|
const output = npmChildEnvironment(input);
|
|
assert.equal(output.NODE_OPTIONS, "--trace-warnings --max-old-space-size=512");
|
|
assert.equal(output.NODE_USE_ENV_PROXY, undefined);
|
|
assert.equal(output.HTTPS_PROXY, input.HTTPS_PROXY);
|
|
assert.equal(output.NO_PROXY, input.NO_PROXY);
|
|
assert.equal(input.NODE_OPTIONS.includes("--use-env-proxy"), true, "the caller environment is not mutated");
|
|
|
|
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set(["--use-env-proxy"])), ["--use-env-proxy"]);
|
|
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY, NODE_OPTIONS: "--use-env-proxy" }, new Set(["--use-env-proxy"])), []);
|
|
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set()), []);
|
|
assert.deepEqual(nodeProxyArgs({}, new Set(["--use-env-proxy"])), []);
|
|
});
|
|
|
|
test("runNpm passes the isolated proxy environment to the real child process", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-npm-env-"));
|
|
try {
|
|
const inspectNpm = join(root, "inspect-npm.mjs");
|
|
writeFileSync(inspectNpm, "console.log(JSON.stringify({nodeOptions:process.env.NODE_OPTIONS,nodeProxy:process.env.NODE_USE_ENV_PROXY,httpsProxy:process.env.HTTPS_PROXY,noProxy:process.env.NO_PROXY}));\n");
|
|
const result = runNpm({
|
|
env: {
|
|
npm_execpath: inspectNpm,
|
|
NODE_OPTIONS: "--trace-warnings --use-env-proxy",
|
|
NODE_USE_ENV_PROXY: "true",
|
|
HTTPS_PROXY: "http://proxy.example.test:8080",
|
|
NO_PROXY: "localhost",
|
|
},
|
|
platform: process.platform,
|
|
}, ["inspect"], { timeoutMs: 2_000 });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepEqual(JSON.parse(result.stdout), {
|
|
nodeOptions: "--trace-warnings", httpsProxy: "http://proxy.example.test:8080", noProxy: "localhost",
|
|
});
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("native curl downloader honors the proxy environment and never fails silently", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-curl-"));
|
|
try {
|
|
const fakeBin = join(root, "bin");
|
|
const curl = join(fakeBin, "curl.exe");
|
|
const log = join(root, "curl.log");
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
writeFileSync(curl, `#!/bin/sh
|
|
target=''
|
|
url=''
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--output) target="$2"; shift 2 ;;
|
|
https://*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG"
|
|
case "$url" in
|
|
*/ok) printf 'native-payload' > "$target"; printf '200'; exit 0 ;;
|
|
*/missing) printf '404'; exit 22 ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
`);
|
|
chmodSync(curl, 0o700);
|
|
const env = {
|
|
PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`,
|
|
HTTPS_PROXY: "http://192.168.199.62:7890",
|
|
DING_CURL_LOG: log,
|
|
};
|
|
assert.equal((await downloadWithCurl(new URL("https://release.example.test/ok"), env, "win32")).toString(), "native-payload");
|
|
assert.match(readFileSync(log, "utf8"), /https:\/\/release\.example\.test\/ok\|http:\/\/192\.168\.199\.62:7890/);
|
|
await assert.rejects(downloadWithCurl(new URL("https://release.example.test/missing"), env, "win32"), /curl 下载失败.*HTTP 404/);
|
|
await assert.rejects(downloadWithCurl(new URL("https://release.example.test/silent"), env, "win32"), /curl 下载失败.*curl 退出码 1/);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("installer uses native curl end to end in a proxied Windows-style environment", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-curl-install-"));
|
|
try {
|
|
const appRoot = join(root, "dingtalk-approval");
|
|
const fakeBin = join(root, "bin");
|
|
const fixtureDir = join(root, "downloads");
|
|
const curlLog = join(root, "curl.log");
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
mkdirSync(fixtureDir, { recursive: true });
|
|
createFakeNpm(fakeBin);
|
|
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
|
|
const keyId = releaseKeyId(publicPem);
|
|
const bodies = {
|
|
installer: Buffer.from("installer"), publicKey: publicPem, client: Buffer.from("fake-client-tgz"),
|
|
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n"),
|
|
};
|
|
const manifest = manifestFixture(keyId, publicPem, bodies);
|
|
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
|
|
writeFileSync(join(fixtureDir, "manifest.json"), manifestBytes);
|
|
writeFileSync(join(fixtureDir, "manifest.sig"), signature);
|
|
for (const [name, body] of Object.entries(bodies)) writeFileSync(join(fixtureDir, name), body);
|
|
|
|
const curlBody = `#!/bin/sh
|
|
target=''
|
|
url=''
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--output) target="$2"; shift 2 ;;
|
|
https://*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG"
|
|
name="${'${url##*/}'}"
|
|
if [ -f "$DING_CURL_FIXTURE_DIR/$name" ]; then
|
|
cp "$DING_CURL_FIXTURE_DIR/$name" "$target"
|
|
printf '200'
|
|
exit 0
|
|
fi
|
|
printf '404'
|
|
exit 22
|
|
`;
|
|
for (const name of ["curl", "curl.exe"]) {
|
|
writeFileSync(join(fakeBin, name), curlBody);
|
|
chmodSync(join(fakeBin, name), 0o700);
|
|
}
|
|
const publicPath = join(root, "release-public.pem");
|
|
writeFileSync(publicPath, publicPem, { mode: 0o600 });
|
|
const env = {
|
|
PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`,
|
|
DINGTALK_APPROVAL_CLIENT_HOME: appRoot,
|
|
npm_execpath: process.platform === "win32"
|
|
? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js") : join(fakeBin, "fake-npm.mjs"),
|
|
HTTPS_PROXY: "http://192.168.199.62:7890",
|
|
NODE_OPTIONS: "--trace-warnings --use-env-proxy",
|
|
DING_CURL_FIXTURE_DIR: fixtureDir,
|
|
DING_CURL_LOG: curlLog,
|
|
};
|
|
const output: string[] = [];
|
|
const progress: string[] = [];
|
|
const code = await runClientInstaller([
|
|
"install", "--manifest-url", "https://release.example.test/manifest.json",
|
|
"--public-key", publicPath, "--app-root", appRoot, "--json",
|
|
], {
|
|
env, platform: process.platform, home: join(root, "home"), fetchFn: fetch,
|
|
stdout: (value) => { output.push(value); }, stderr: (value) => { progress.push(value); },
|
|
});
|
|
assert.equal(code, 0, progress.join(""));
|
|
assert.equal(JSON.parse(output.at(-1)!).status, "installed");
|
|
const calls = readFileSync(curlLog, "utf8");
|
|
assert.match(calls, /manifest\.json\|http:\/\/192\.168\.199\.62:7890/);
|
|
assert.match(calls, /client\|http:\/\/192\.168\.199\.62:7890/);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("signed manifest verification rejects tampering and a foreign key", () => {
|
|
const trusted = generateKeyPairSync("ed25519");
|
|
const foreign = generateKeyPairSync("ed25519");
|
|
const publicPem = trusted.publicKey.export({ type: "spki", format: "pem" });
|
|
const keyId = releaseKeyId(publicPem);
|
|
const manifest = manifestFixture(keyId, publicPem);
|
|
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
const signature = signatureEnvelope(keyId, sign(null, bytes, trusted.privateKey));
|
|
assert.equal(verifySignedManifest(bytes, signature, publicPem, "https://approval.example.test/downloads/manifest.json").version, "1.1.0-rc.5");
|
|
assert.throws(() => verifySignedManifest(Buffer.concat([bytes, Buffer.from(" ")]), signature, publicPem, "https://approval.example.test/downloads/manifest.json"), /签名无效/);
|
|
assert.throws(() => verifySignedManifest(bytes, signature, foreign.publicKey.export({ type: "spki", format: "pem" }), "https://approval.example.test/downloads/manifest.json"), /密钥编号不受信任/);
|
|
|
|
const stale = { ...manifest, createdAt: new Date(Date.now() - 181 * 24 * 60 * 60_000).toISOString() };
|
|
const staleBytes = Buffer.from(`${JSON.stringify(stale, null, 2)}\n`);
|
|
const staleSignature = signatureEnvelope(keyId, sign(null, staleBytes, trusted.privateKey));
|
|
assert.throws(() => verifySignedManifest(staleBytes, staleSignature, publicPem, "https://approval.example.test/downloads/manifest.json"), /超过 180 天/);
|
|
});
|
|
|
|
test("legacy cleanup removes only owned broken bins and exact old package", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-legacy-"));
|
|
const home = join(root, "home");
|
|
const bin = join(home, ".nvm", "versions", "node", "v22.22.2", "bin");
|
|
const packageRoot = join(home, ".nvm", "versions", "node", "v22.22.2", "lib", "node_modules", "dingtalk-approval-mcp");
|
|
mkdirSync(bin, { recursive: true });
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
|
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) {
|
|
symlinkSync(`../lib/node_modules/dingtalk-approval-mcp/dist/src/${name}.js`, join(bin, name));
|
|
}
|
|
symlinkSync("../lib/node_modules/some-other-package/cli.js", join(bin, "unrelated"));
|
|
const removed = cleanupLegacyInstallations({ PATH: bin }, "linux", home);
|
|
assert.equal(removed.filter((path) => path.includes("dingtalk-")).length, 5);
|
|
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) assert.equal(existsSync(join(bin, name)), false);
|
|
assert.equal(existsSync(packageRoot), false);
|
|
assert.equal(existsSync(join(bin, "unrelated")), false, "existsSync is false for dangling links");
|
|
assert.equal(lstatSync(join(bin, "unrelated")).isSymbolicLink(), true, "unrelated dangling link remains");
|
|
});
|
|
|
|
test("legacy cleanup removes owned Windows npm shims without touching unrelated commands", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-windows-legacy-"));
|
|
const bin = join(root, "npm");
|
|
const packageRoot = join(bin, "node_modules", "dingtalk-approval-mcp");
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
|
writeFileSync(join(bin, "dingtalk-approval-bridge.cmd"), "@node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\bridge.js %*\r\n");
|
|
writeFileSync(join(bin, "dingtalk-mcp.ps1"), "& node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\index.js $args\r\n");
|
|
writeFileSync(join(bin, "dingtalk-admin.cmd"), "@node C:\\npm\\node_modules\\another-package\\admin.js %*\r\n");
|
|
|
|
cleanupLegacyInstallations({ PATH: bin, APPDATA: bin }, "win32", join(root, "home"));
|
|
assert.equal(existsSync(join(bin, "dingtalk-approval-bridge.cmd")), false);
|
|
assert.equal(existsSync(join(bin, "dingtalk-mcp.ps1")), false);
|
|
assert.equal(existsSync(packageRoot), false);
|
|
assert.equal(existsSync(join(bin, "dingtalk-admin.cmd")), true);
|
|
});
|
|
|
|
test("generic installer preserves identity across safe updates, rollback, downgrade, and uninstall", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-install-"));
|
|
const appRoot = join(root, "dingtalk-approval");
|
|
const fakeBin = join(root, "fake-bin");
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
createFakeNpm(fakeBin);
|
|
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
|
|
const keyId = releaseKeyId(publicPem);
|
|
const bodies = {
|
|
installer: Buffer.from("installer"),
|
|
publicKey: publicPem,
|
|
client: Buffer.from("fake-client-tgz"),
|
|
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
|
|
};
|
|
const manifest = manifestFixture(keyId, publicPem, bodies);
|
|
const mirrorBase = "https://mirror.example.test/dingmcp/";
|
|
manifest.distribution = { manifestMirrors: [`${mirrorBase}manifest.json`] };
|
|
for (const artifact of Object.values(manifest.artifacts)) {
|
|
artifact.mirrors = [`${mirrorBase}${basename(new URL(artifact.url).pathname)}`];
|
|
}
|
|
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
|
|
const responses = new Map<string, Buffer>([
|
|
["https://approval.example.test/downloads/manifest.json", manifestBytes],
|
|
["https://approval.example.test/downloads/manifest.sig", signature],
|
|
[`${mirrorBase}manifest.json`, manifestBytes],
|
|
[`${mirrorBase}manifest.sig`, signature],
|
|
...Object.entries(manifest.artifacts).flatMap(([name, artifact]) =>
|
|
[artifact.url, ...(artifact.mirrors ?? [])].map((url) => [url, bodies[name as keyof typeof bodies]] as [string, Buffer])),
|
|
]);
|
|
let downloadCalls = 0;
|
|
const unavailableUrls = new Set<string>();
|
|
const fetchFn = async (input: string | URL | Request) => {
|
|
downloadCalls += 1;
|
|
const url = String(input);
|
|
if (unavailableUrls.has(url)) return new Response("missing", { status: 404 });
|
|
const body = responses.get(url);
|
|
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
|
|
};
|
|
const publicPath = join(root, "release-public.pem");
|
|
writeFileSync(publicPath, publicPem, { mode: 0o600 });
|
|
const env: NodeJS.ProcessEnv = {
|
|
PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
|
|
DINGTALK_APPROVAL_CLIENT_HOME: appRoot,
|
|
npm_execpath: process.platform === "win32"
|
|
? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js")
|
|
: join(fakeBin, "fake-npm.mjs"),
|
|
};
|
|
const output: string[] = [];
|
|
const progress: string[] = [];
|
|
const commandContext = {
|
|
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
|
|
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { progress.push(value); },
|
|
};
|
|
const installed = await runClientInstaller([
|
|
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
|
|
"--manifest-mirror-url", `${mirrorBase}manifest.json`,
|
|
"--public-key", publicPath, "--app-root", appRoot, "--json",
|
|
], commandContext);
|
|
assert.equal(installed, 0);
|
|
const installOutput = JSON.parse(output.pop()!);
|
|
assert.match(progress.join(""), /启动 正在取得单实例安装锁[\s\S]*1\/6[\s\S]*6\/6[\s\S]*完成 客户端安装成功/);
|
|
assert.equal(installOutput.restartRequired, undefined);
|
|
assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/);
|
|
assert.match(installOutput.nextSteps.join(" "), /Windows.*\.cmd.*禁止把网关 URL 配成 MCP/s);
|
|
assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s);
|
|
assert.match(installOutput.nextSteps.join(" "), /双来源只读验证.*钉钉 OA 与薪福通均为 ok/s);
|
|
assert.equal(installOutput.postInstall.status, "authorization_required");
|
|
assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
|
|
assert.deepEqual(installOutput.loginCommand, {
|
|
command: join(appRoot, "bin", process.platform === "win32" ? "dingtalk-approval.cmd" : "dingtalk-approval"),
|
|
args: ["login", "--json"],
|
|
});
|
|
assert.deepEqual(installOutput.postInstall.loginCommand, installOutput.loginCommand);
|
|
assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} });
|
|
assert.deepEqual(installOutput.postInstall.verifyTool, {
|
|
name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 },
|
|
});
|
|
assert.equal(installOutput.mcpConfig.mcpServers["dingtalk-approval"].args.length, 0);
|
|
const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home"));
|
|
assert.equal(existsSync(paths.bridgeCommand), true);
|
|
assert.equal(existsSync(paths.skillFile), true);
|
|
assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true);
|
|
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(state.version, "1.1.0-rc.5");
|
|
assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]);
|
|
assert.equal(state.host, undefined);
|
|
assert.equal(state.nodeExecutable, realpathSync(process.execPath));
|
|
assert.equal(state.managedNode, false);
|
|
assert.equal(state.nodeVersion, process.versions.node);
|
|
assert.deepEqual(installOutput.nodeRuntime, {
|
|
version: process.versions.node, managed: false, executable: realpathSync(process.execPath),
|
|
});
|
|
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
|
|
assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
assert.match(readFileSync(paths.launcherFile, "utf8"), /allowedNodeEnvironmentFlags\.has\("--use-env-proxy"\)/);
|
|
const stableBridgePath = paths.bridgeCommand;
|
|
const initialRuntime = state.runtimeDir;
|
|
|
|
const callsBeforeStandaloneLogin = downloadCalls;
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const loginOutput = JSON.parse(output.pop()!);
|
|
assert.equal(loginOutput.operation, "login");
|
|
assert.equal(loginOutput.status, "authorization_required");
|
|
assert.equal(loginOutput.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
|
|
assert.deepEqual(loginOutput.loginCommand, installOutput.loginCommand);
|
|
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "standalone login must not download or reinstall artifacts");
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], {
|
|
...commandContext, env: { ...env, FAKE_BRIDGE_AUTHENTICATED: "1" },
|
|
}), 0);
|
|
const authenticatedLogin = JSON.parse(output.pop()!);
|
|
assert.equal(authenticatedLogin.status, "authenticated");
|
|
assert.equal(authenticatedLogin.authenticated, true);
|
|
assert.equal(authenticatedLogin.authChallenge, undefined);
|
|
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "authenticated login check must stay local except for the Bridge flow");
|
|
|
|
mkdirSync(dirname(paths.sessionFile), { recursive: true });
|
|
const sessionBody = Buffer.from(`${JSON.stringify({
|
|
authenticated: true,
|
|
marker: "preserve-across-updates",
|
|
onboarding: { schemaVersion: 1, status: "delivered", deliveredAt: "2030-01-01T00:00:00.000Z" },
|
|
})}\n`);
|
|
writeFileSync(paths.sessionFile, sessionBody, { mode: 0o600 });
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-path", "bridge", "--app-root", appRoot], commandContext), 0);
|
|
assert.equal(output.pop(), `${paths.bridgeCommand}\n`);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-config", "--format", "mcp-json", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const mcpConfig = JSON.parse(output.pop()!);
|
|
assert.deepEqual(mcpConfig, { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } });
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["doctor", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const health = JSON.parse(output.pop()!);
|
|
assert.equal(health.ok, true);
|
|
assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true);
|
|
assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true);
|
|
|
|
output.length = 0;
|
|
const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test");
|
|
mkdirSync(interruptedStaging, { recursive: true });
|
|
unavailableUrls.add("https://approval.example.test/downloads/manifest.json");
|
|
unavailableUrls.add("https://approval.example.test/downloads/manifest.sig");
|
|
for (const artifact of Object.values(manifest.artifacts)) unavailableUrls.add(artifact.url);
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
|
|
const sameVersionOutput = JSON.parse(output.pop()!);
|
|
assert.equal(sameVersionOutput.postInstall.status, "unavailable");
|
|
const sameVersionState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(sameVersionState.host, undefined);
|
|
assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
|
|
assert.equal(existsSync(interruptedStaging), false, "a dead installer staging directory is recovered on the next run");
|
|
assert.match(progress.join(""), /下载源不可用.*mirror\.example\.test/);
|
|
unavailableUrls.clear();
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /未知参数:--host/);
|
|
|
|
const upgradedBodies = {
|
|
...bodies,
|
|
client: Buffer.from("fake-client-tgz-v2"),
|
|
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test v2\n---\n\n# Test v2\n"),
|
|
};
|
|
const upgraded = manifestFixture(keyId, publicPem, upgradedBodies, "1.1.1");
|
|
const upgradedBytes = Buffer.from(`${JSON.stringify(upgraded, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", upgradedBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, upgradedBytes, keys.privateKey)));
|
|
for (const [name, artifact] of Object.entries(upgraded.artifacts)) {
|
|
responses.set(artifact.url, upgradedBodies[name as keyof typeof upgradedBodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], commandContext), 0, "update reuses the pinned manifest URL and public key");
|
|
const upgradedOutput = JSON.parse(output.pop()!);
|
|
const upgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(upgradedOutput.version, "1.1.1");
|
|
assert.equal(upgradedOutput.restartRequired, undefined);
|
|
assert.equal(upgradedState.host, undefined);
|
|
assert.notEqual(upgradedState.runtimeDir, initialRuntime);
|
|
assert.equal(existsSync(join(appRoot, initialRuntime)), false, "successful upgrade removes the obsolete runtime");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), [basename(upgradedState.runtimeDir)]);
|
|
assert.equal(readFileSync(paths.skillFile, "utf8"), upgradedBodies.skill.toString("utf8"));
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "cross-version update preserves the OAuth session");
|
|
assert.equal(paths.bridgeCommand, stableBridgePath, "the MCP command path remains stable across versions");
|
|
|
|
const beforeFailedState = readFileSync(paths.stateFile);
|
|
const beforeFailedSkill = readFileSync(paths.skillFile);
|
|
const beforeFailedVersions = readdirSync(paths.versionsDir);
|
|
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
|
|
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
|
failedUpgrade.artifacts.client.mirrors = [`${mirrorBase}client-v3`];
|
|
const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey)));
|
|
responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz"));
|
|
responses.set(failedUpgrade.artifacts.client.mirrors[0]!, failedBodies.client);
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
|
|
assert.deepEqual(readFileSync(paths.stateFile), beforeFailedState, "failed update keeps the active install state");
|
|
assert.deepEqual(readFileSync(paths.skillFile), beforeFailedSkill, "failed update keeps the active Skill");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), beforeFailedVersions, "failed update keeps the active runtime");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "failed update keeps the OAuth session");
|
|
|
|
const nextRelease = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
|
const nextBytes = Buffer.from(`${JSON.stringify(nextRelease, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", nextBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, nextBytes, keys.privateKey)));
|
|
for (const [name, artifact] of Object.entries(nextRelease.artifacts)) {
|
|
responses.set(artifact.url, failedBodies[name as keyof typeof failedBodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
assert.equal(JSON.parse(output.pop()!).version, "1.1.2");
|
|
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).host, undefined);
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
|
|
const downgraded = manifestFixture(keyId, publicPem, bodies, "1.0.0");
|
|
const downgradedBytes = Buffer.from(`${JSON.stringify(downgraded, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", downgradedBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, downgradedBytes, keys.privateKey)));
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /拒绝.*降级/);
|
|
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).version, "1.1.2");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
|
|
for (const [name, artifact] of Object.entries(downgraded.artifacts)) {
|
|
responses.set(artifact.url, bodies[name as keyof typeof bodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--allow-downgrade", "--app-root", appRoot, "--json",
|
|
], commandContext), 0, "an explicitly authorized downgrade remains possible");
|
|
const downgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(downgradedState.version, "1.0.0");
|
|
assert.equal(downgradedState.host, undefined);
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
assert.equal(readdirSync(paths.versionsDir).length, 1);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-config", "--bogus", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /未知参数/);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["uninstall", "--yes", "--app-root", root, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /专用.*用户数据目录/);
|
|
|
|
output.length = 0;
|
|
const removed = await runClientInstaller(["uninstall", "--yes", "--app-root", appRoot, "--json"], {
|
|
...commandContext,
|
|
});
|
|
assert.equal(removed, 0);
|
|
assert.equal(existsSync(appRoot), false);
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /尚未安装/);
|
|
});
|
|
|
|
function manifestFixture(
|
|
keyId: string,
|
|
publicKey: string | Buffer,
|
|
bodies = {
|
|
installer: Buffer.from("installer"), publicKey: Buffer.from(publicKey), client: Buffer.from("client"),
|
|
skill: Buffer.from("skill"),
|
|
},
|
|
version = "1.1.0-rc.5",
|
|
): ClientReleaseManifest {
|
|
const base = "https://approval.example.test/downloads/";
|
|
const artifact = (name: keyof typeof bodies) => ({ url: `${base}${name}`, sha256: digest(bodies[name]) });
|
|
return {
|
|
schemaVersion: 3,
|
|
version,
|
|
createdAt: new Date().toISOString(),
|
|
gatewayUrl: "https://approval.example.test/mcp",
|
|
signing: { algorithm: "Ed25519", keyId },
|
|
requirements: { node: ">=22.13.0" },
|
|
artifacts: {
|
|
installer: artifact("installer"),
|
|
publicKey: { ...artifact("publicKey"), keyId },
|
|
client: artifact("client"),
|
|
skill: artifact("skill"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function signatureEnvelope(keyId: string, value: Buffer): Buffer {
|
|
return Buffer.from(JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature: value.toString("base64") }));
|
|
}
|
|
|
|
function digest(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
|
|
|
function createFakeNpm(bin: string): void {
|
|
const fakeBridge = `#!/usr/bin/env node
|
|
if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2);
|
|
if (process.argv.includes("--login") || process.argv.includes("--post-install-login")) {
|
|
if (process.env.FAKE_BRIDGE_AUTHENTICATED === "1") {
|
|
console.log(JSON.stringify({ authenticated: true, message: "已登录,可以读取审批。" }));
|
|
process.exit(0);
|
|
}
|
|
console.log(JSON.stringify({
|
|
authenticated: false,
|
|
message: "需要完成钉钉授权登录。",
|
|
authChallenge: {
|
|
provider: "dingtalk",
|
|
type: "link",
|
|
directUrl: "https://approval.example.test/oauth/device?user_code=INSTALL-1234",
|
|
userCode: "INSTALL-1234",
|
|
expiresAt: "2030-01-01T00:00:00.000Z",
|
|
pollAfterSeconds: 5
|
|
}
|
|
}));
|
|
}
|
|
`;
|
|
const implementation = process.platform === "win32"
|
|
? join(bin, "node_modules", "npm", "bin", "npm-cli.js")
|
|
: join(bin, "fake-npm.mjs");
|
|
mkdirSync(join(implementation, ".."), { recursive: true });
|
|
writeFileSync(implementation, `#!/usr/bin/env node
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); }
|
|
if (!process.env.npm_config_cache || !process.env.npm_config_cache.includes("dingtalk-approval-install-")) {
|
|
console.error("installer did not isolate the npm cache"); process.exit(3);
|
|
}
|
|
const index = process.argv.indexOf("--prefix");
|
|
if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); }
|
|
const prefix = process.argv[index + 1];
|
|
const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist", "src");
|
|
mkdirSync(target, { recursive: true });
|
|
writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)});
|
|
writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n");
|
|
const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval");
|
|
mkdirSync(join(skill, "references"), { recursive: true });
|
|
writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n");
|
|
writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n");
|
|
`);
|
|
if (process.platform === "win32") {
|
|
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
|
|
} else {
|
|
const executable = join(bin, "npm");
|
|
writeFileSync(executable, `#!/usr/bin/env sh\nexec "${process.execPath}" "$(dirname "$0")/fake-npm.mjs" "$@"\n`, { mode: 0o700 });
|
|
chmodSync(executable, 0o700);
|
|
}
|
|
}
|