Files
谢宇宁andClaude Opus 5.5 f65d10e998 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:07:59 +07:00

918 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
mkdtemp,
mkdir,
writeFile,
readFile,
cp,
symlink,
rm,
stat,
lstat,
realpath,
} from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import { fileURLToPath } from "node:url";
import { packageStandalone } from "../scripts/package-standalone.mjs";
import {
LOCKED_RUNTIME,
packageLockVersions,
} from "../scripts/check-lock-sync.mjs";
const projectRoot = new URL("../", import.meta.url);
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), "91-release-"));
t.after(() => rm(root, { recursive: true, force: true }));
async function put(path, text = "fixture") {
await mkdir(join(root, path, ".."), { recursive: true });
await writeFile(join(root, path), text);
}
for (const [env, build] of [
["test", ".next-test"],
["production", ".next"],
]) {
await put(`${build}/standalone/server.js`, "module.exports=true;");
await put(`${build}/standalone/${build}/BUILD_ID`, "build-" + env);
await put(`${build}/standalone/${build}/cache/seo/private.json`);
await put(`${build}/standalone/.env.local`, "SENSITIVE=do-not-ship");
await put(`${build}/standalone/config/secrets/private-key`);
await put(
`${build}/standalone/dist/production/config/production.local.env`,
);
await put(`${build}/standalone/tests/fixtures/private.json`);
await put(
`${build}/standalone/${build === ".next" ? ".next-test" : ".next"}/cache/seo/old.json`,
);
await put(`${build}/static/chunks/app.js`);
await put(
`config/${env}.env`,
`API_BASE_URL=https://api.test\nPORT=21030\n`,
);
await put(`config/${env}.local.env`, "SENSITIVE=do-not-ship");
}
await put("public/assets/logo.svg");
await put("public/._logo.svg");
// Mirror runtime lock versions so deploy check_local accepts npm or pnpm-style installs.
const versions = packageLockVersions(fileURLToPath(projectRoot));
await put(
"package.json",
JSON.stringify(
{
name: "jha-1050-jgdj-web",
private: true,
dependencies: Object.fromEntries(
LOCKED_RUNTIME.map((name) => [name, versions[name]]),
),
},
null,
2,
),
);
await put(
"package-lock.json",
JSON.stringify(
{
lockfileVersion: 3,
packages: Object.fromEntries(
LOCKED_RUNTIME.map((name) => [
`node_modules/${name}`,
{ version: versions[name] },
]),
),
},
null,
2,
),
);
for (const name of LOCKED_RUNTIME) {
await put(
`node_modules/${name}/package.json`,
JSON.stringify({ name, version: versions[name] }),
);
}
await cp(new URL("scripts", projectRoot), join(root, "scripts"), {
recursive: true,
});
// pnpm links dotenv; copy the real package so the fixture mirrors a concrete release tree.
await cp(
await realpath(new URL("node_modules/dotenv", projectRoot)),
join(root, "node_modules/dotenv"),
{ recursive: true, dereference: true },
);
return { root, put };
}
test("standalone releases include only their profile and assets, exclude local secrets/cache, and preserve other releases", async (t) => {
const { root, put } = await fixture(t);
for (const [env, build] of [
["test", ".next-test"],
["production", ".next"],
]) {
const release = await packageStandalone(env, { root });
for (const file of [
"start.mjs",
"server.js",
"package-lock.json",
"node_modules/dotenv/package.json",
"scripts/check-health.mjs",
build + "/BUILD_ID",
build + "/static/chunks/app.js",
"public/assets/logo.svg",
`config/${env}.env`,
])
assert.ok((await stat(join(release, file))).isFile(), file);
for (const file of [
".env.local",
"public/._logo.svg",
`config/${env}.local.env`,
"config/secrets",
"dist",
"tests",
build + "/cache",
build === ".next" ? ".next-test" : ".next",
])
await assert.rejects(stat(join(release, file)), { code: "ENOENT" });
const launch = spawnSync(process.execPath, [join(release, "start.mjs")], {
encoding: "utf8",
env: { PATH: process.env.PATH },
});
assert.equal(launch.status, 0, launch.stderr);
}
await put("dist/production/keep", "previous production");
await put("dist/test/stale", "remove stale file");
await packageStandalone("test", { root });
assert.equal(
await readFile(join(root, "dist/production/keep"), "utf8"),
"previous production",
);
await assert.rejects(stat(join(root, "dist/test/stale")), { code: "ENOENT" });
});
test("invalid environments and symlink destinations fail without deleting unrelated data", async (t) => {
const { root, put } = await fixture(t);
await put("outside/keep", "do not delete");
await mkdir(join(root, "dist"));
await symlink(join(root, "outside"), join(root, "dist/test"));
await assert.rejects(
packageStandalone("test", { root }),
/Unsafe release directory/,
);
await assert.rejects(packageStandalone("../outside", { root }), /Usage/);
assert.equal(
await readFile(join(root, "outside/keep"), "utf8"),
"do not delete",
);
await rm(join(root, "dist"), { recursive: true });
await symlink(join(root, "outside"), join(root, "dist"));
await assert.rejects(
packageStandalone("production", { root }),
/Unsafe release directory/,
);
assert.equal(
await readFile(join(root, "outside/keep"), "utf8"),
"do not delete",
);
});
test("in-tree pnpm-style module links are preserved while escaping links are rejected", async (t) => {
const { root, put } = await fixture(t);
const modules = join(root, ".next-test/standalone/node_modules");
await put(
".next-test/standalone/node_modules/.pnpm/pkg@1/node_modules/pkg/index.js",
"module.exports=1;",
);
await mkdir(join(modules, ".pnpm/node_modules"), { recursive: true });
await symlink(
"../pkg@1/node_modules/pkg",
join(modules, ".pnpm/node_modules/pkg"),
);
await symlink(".pnpm/pkg@1/node_modules/pkg", join(modules, "pkg"));
const release = await packageStandalone("test", { root });
const link = join(release, "node_modules/pkg");
assert.ok((await lstat(link)).isSymbolicLink());
assert.equal(
await readFile(join(link, "index.js"), "utf8"),
"module.exports=1;",
);
const outside = await mkdtemp(join(tmpdir(), "91-escape-"));
t.after(() => rm(outside, { recursive: true, force: true }));
await writeFile(join(outside, "secret"), "escape");
await mkdir(join(root, ".next/standalone/node_modules"), {
recursive: true,
});
await symlink(
join(outside, "secret"),
join(root, ".next/standalone/node_modules/escape-link"),
);
await assert.rejects(
packageStandalone("production", { root }),
/Release source contains a symbolic link/,
);
});
test("dry runs reflect current configuration, allow overrides and never build/upload; invalid deployment targets fail early", async (t) => {
const dir = await mkdtemp(join(tmpdir(), "91-deploy-stubs-"));
t.after(() => rm(dir, { recursive: true, force: true }));
const marker = join(dir, "executed");
for (const command of ["npm", "ssh", "scp"])
await writeFile(
join(dir, command),
`#!/bin/bash\nprintf '%s\\n' '${command}' >> "$MARKER"\nexit 99\n`,
{ mode: 0o755 },
);
const env = {
...process.env,
PATH: dir + ":" + process.env.PATH,
MARKER: marker,
DEPLOY_DRY_RUN: "0",
};
for (const key of [
"DEPLOY_HOST",
"DEPLOY_DIR",
"DEPLOY_RUN_DIR",
"DEPLOY_JUMP_HOST",
"DEPLOY_JUMP_DIR",
"DEPLOY_SSH_PORT",
"DEPLOY_SCP_PORT",
"DEPLOY_CHECK_URL",
"DEPLOY_PROGRAM",
"DEPLOY_PACKAGE_NAME",
"DEPLOY_TARGET_ARCH",
"DEPLOY_TARGET_LIBC",
])
delete env[key];
// Deployment addresses/ports are operator configuration, not application invariants.
// Verify reporting consistency and explicit overrides rather than pinning a server.
for (const script of ["deploy_test.sh", "deploy_prod.sh"]) {
for (const args of [["--dry-run"], ["--package-only", "--dry-run"]]) {
const result = spawnSync("bash", [script, ...args], {
encoding: "utf8",
env,
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /API=(?:current|https?:\/\/\S+)\s+PORT=\d+/);
assert.doesNotMatch(result.stdout, /undefined/);
if (!args.includes("--package-only")) {
const help = spawnSync("bash", [script, "--help"], {
encoding: "utf8",
env,
});
assert.equal(help.status, 0, help.stderr);
if (script === "deploy_test.sh") {
assert.match(
result.stdout,
/上传 \S+\.tar\.gz、\S+\.tar\.gz\.sha256 和 up_next_h5\.sh/,
);
assert.doesNotMatch(result.stdout, /dist\.zip/);
const target = result.stdout.match(
/发布目标:(\S+):(\/[^(\n]+)(SSH (\d+))。/,
);
assert.ok(target, `${script} 未报告有效上传目标:${result.stdout}`);
const [, host, directory, port] = target;
assert.ok(Number(port) > 0 && Number(port) <= 65535);
assert.ok(
help.stdout.includes(
`${host}:${port} ${directory.replace(/\/$/, "")}/`,
),
help.stdout,
);
const execute = result.stdout.match(
/上传成功后:ssh -p (\d+) (\S+) "cd (\/\S+) && bash run\.sh"/,
);
assert.ok(execute, result.stdout);
assert.equal(execute[1], port);
assert.equal(execute[2], host);
assert.ok(
help.stdout.includes(`DEPLOY_RUN_DIR(${execute[3]})`),
help.stdout,
);
} else {
const upload = result.stdout.match(
/^\s+scp -P (\d+) (\S+) (\S+) ([^:\s]+):(\/\S*)$/m,
);
const execute = result.stdout.match(
/^\s+ssh -p (\d+) (\S+) "cd (\/\S+) && bash rsupdate\.sh"$/m,
);
assert.ok(upload, `${script} 未报告有效上传命令:${result.stdout}`);
assert.ok(execute, `${script} 未报告有效执行命令:${result.stdout}`);
const [, scpPort, archive, checksum, host, directory] = upload;
const [, sshPort, sshHost, runDirectory] = execute;
assert.equal(sshHost, host);
assert.equal(runDirectory, directory.replace(/\/$/, ""));
assert.equal(checksum, archive.replace(/\.tar\.gz$/, ".md5"));
assert.ok(help.stdout.includes(`${host}:${directory}`), help.stdout);
for (const [key, value] of [
["DEPLOY_SCP_PORT", scpPort],
["DEPLOY_SSH_PORT", sshPort],
["DEPLOY_PACKAGE_NAME", archive],
])
assert.ok(help.stdout.includes(`${key}(${value})`), help.stdout);
}
}
}
const customEnv = {
...env,
DEPLOY_HOST: "user@custom-test",
DEPLOY_DIR: "/srv/custom-test",
DEPLOY_RUN_DIR: "/srv/custom-run",
DEPLOY_JUMP_HOST: "user@custom-prod",
DEPLOY_JUMP_DIR: "/srv/custom-prod/",
DEPLOY_SSH_PORT: "2202",
DEPLOY_SCP_PORT: "2203",
DEPLOY_PACKAGE_NAME: "custom_package.tar.gz",
};
const custom = spawnSync("bash", [script, "--dry-run"], {
encoding: "utf8",
env: customEnv,
});
assert.equal(custom.status, 0, custom.stderr);
assert.ok(
custom.stdout.includes(
script === "deploy_test.sh"
? "user@custom-test:/srv/custom-test"
: "user@custom-prod:/srv/custom-prod/",
),
);
assert.ok(custom.stdout.includes("2202"));
if (script === "deploy_prod.sh") assert.ok(custom.stdout.includes("2203"));
assert.ok(custom.stdout.includes("custom_package.tar.gz"));
if (script === "deploy_test.sh")
assert.ok(custom.stdout.includes("custom_package.tar.gz.sha256"));
const customHelp = spawnSync("bash", [script, "--help"], {
encoding: "utf8",
env: customEnv,
});
assert.equal(customHelp.status, 0, customHelp.stderr);
assert.ok(customHelp.stdout.includes("2202"));
assert.ok(
customHelp.stdout.includes(
script === "deploy_test.sh"
? "/srv/custom-run"
: "custom_package.tar.gz",
),
);
if (script === "deploy_test.sh")
assert.match(
custom.stdout,
/ssh -p 2202 user@custom-test "cd \/srv\/custom-run && bash run\.sh"/,
);
else
assert.match(
custom.stdout,
/ssh -p 2202 user@custom-prod "cd \/srv\/custom-prod && bash rsupdate\.sh"/,
);
assert.ok(customHelp.stdout.includes("custom_package.tar.gz"));
const invalidArchive = spawnSync("bash", [script, "--package-only"], {
encoding: "utf8",
env: { ...env, DEPLOY_PACKAGE_NAME: "../invalid.zip" },
});
assert.notEqual(invalidArchive.status, 0);
assert.match(invalidArchive.stderr, /\.tar\.gz 文件名/);
const invalidHost = spawnSync("bash", [script], {
encoding: "utf8",
env: { ...env, DEPLOY_HOST: "bad;host", DEPLOY_JUMP_HOST: "bad;host" },
});
assert.notEqual(invalidHost.status, 0);
assert.match(invalidHost.stderr, /格式无效/);
const invalid = spawnSync("bash", [script], {
encoding: "utf8",
env: {
...env,
DEPLOY_HOST: "user@server",
DEPLOY_DIR: "/srv/../other",
DEPLOY_JUMP_HOST: "user@server",
DEPLOY_JUMP_DIR: "/srv/../other",
},
});
assert.notEqual(invalid.status, 0);
assert.match(invalid.stderr, /绝对路径/);
const invalidPort = spawnSync("bash", [script], {
encoding: "utf8",
env: { ...env, DEPLOY_SSH_PORT: "0" },
});
assert.notEqual(invalidPort.status, 0);
assert.match(invalidPort.stderr, /1–65535/);
if (script === "deploy_test.sh") {
const invalidRun = spawnSync("bash", [script], {
encoding: "utf8",
env: { ...env, DEPLOY_RUN_DIR: "/srv/../other" },
});
assert.notEqual(invalidRun.status, 0);
assert.match(invalidRun.stderr, /DEPLOY_RUN_DIR 必须是普通绝对路径/);
}
}
await assert.rejects(stat(marker), { code: "ENOENT" });
});
test("test deployment uploads tar.gz and checksum before run.sh, preserves its execution directory, and stops on failure", async (t) => {
const { root } = await fixture(t),
bin = join(root, "bin"),
marker = join(root, "commands.jsonl");
await mkdir(bin);
await mkdir(join(root, "deploy"));
await cp(
new URL("deploy_test.sh", projectRoot),
join(root, "deploy_test.sh"),
);
await cp(
new URL("deploy/up_next_h5.sh", projectRoot),
join(root, "deploy/up_next_h5.sh"),
);
for (const name of ["npm", "ssh", "scp"]) {
const code =
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args,testEnvironment:process.env.TEST_APP_ENV})+'\\n');` +
(name === "npm"
? `if(args.join(' ')==='run bundle:test'){const r=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','test'],{stdio:'inherit'});process.exit(r.status??1);}`
: name === "scp"
? `process.exit(Number(process.env.SCP_EXIT||0));`
: `if(args.at(-1).includes('bash run.sh'))process.exit(Number(process.env.RUN_EXIT||0));`);
await writeFile(
join(bin, name),
"#!" + process.execPath + "\n" + code + "\n",
{ mode: 0o755 },
);
}
const archive = "fixture_test.tar.gz";
const env = {
...process.env,
PATH: bin + ":" + process.env.PATH,
MARKER: marker,
DEPLOY_DRY_RUN: "0",
DEPLOY_HOST: "fixture-host",
DEPLOY_DIR: "/srv/fixture",
DEPLOY_SSH_PORT: "2202",
DEPLOY_PACKAGE_NAME: archive,
DEPLOY_CHECK_URL: "",
SCP_EXIT: "0",
RUN_EXIT: "0",
};
delete env.DEPLOY_RUN_DIR;
const run = (options = {}, args = []) =>
spawnSync("bash", ["deploy_test.sh", ...args], {
cwd: root,
encoding: "utf8",
env: { ...env, ...options },
});
const commands = async () =>
(await readFile(marker, "utf8"))
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
// Server directories are operator configuration. Compare actual execution with
// the script's advertised default rather than pinning a historical path.
const defaultRunDirectory = () => {
const help = run({}, ["--help"]);
assert.equal(help.status, 0, help.stderr);
const configured = help.stdout.match(/DEPLOY_RUN_DIR(([^)]+))/);
assert.ok(configured, help.stdout);
return configured[1];
};
const runDirectory = defaultRunDirectory();
const result = run();
assert.equal(result.status, 0, result.stdout + result.stderr);
const calls = await commands();
const npm = calls.filter((call) => call.command === "npm");
assert.deepEqual(
npm.map((call) => call.args),
[["run", "build:test"], ["test"], ["run", "bundle:test"]],
);
assert.equal(npm[1].testEnvironment, "test");
assert.deepEqual(
calls.filter((call) => call.command === "scp").map((call) => call.args),
[
[
"-P",
"2202",
archive,
archive + ".sha256",
"deploy/up_next_h5.sh",
"fixture-host:/srv/fixture/",
],
],
);
assert.deepEqual(
calls.filter((call) => call.command === "ssh").map((call) => call.args),
[
["-p", "2202", "fixture-host", "mkdir -p '/srv/fixture'"],
["-p", "2202", "fixture-host", `cd '${runDirectory}' && bash run.sh`],
],
);
assert.deepEqual(
calls.map((call) => call.command),
["npm", "npm", "npm", "ssh", "scp", "ssh"],
);
assert.match(result.stdout, /测试服 run\.sh 执行完成/);
const bytes = await readFile(join(root, archive));
assert.equal(bytes.subarray(0, 2).toString("hex"), "1f8b");
assert.equal(
await readFile(join(root, archive + ".sha256"), "utf8"),
createHash("sha256").update(bytes).digest("hex") + " " + archive + "\n",
);
assert.equal(
await readFile(join(root, archive.replace(/\.tar\.gz$/, ".md5")), "utf8"),
createHash("md5").update(bytes).digest("hex") + "\n",
);
const contents = spawnSync("tar", ["-tzf", join(root, archive)], {
encoding: "utf8",
});
assert.equal(contents.status, 0, contents.stderr);
assert.match(contents.stdout, /\.next-test\/BUILD_ID/);
assert.match(contents.stdout, /config\/test\.env/);
assert.doesNotMatch(contents.stdout, /node_modules|\.local\.env|\/cache\//);
for (const [name, options, args, status] of [
["custom-run-directory", { DEPLOY_RUN_DIR: "/srv/custom-run" }, [], 0],
["upload-failed", { SCP_EXIT: "23" }, [], 23],
["run-failed", { RUN_EXIT: "42" }, [], 42],
["package-only", {}, ["--package-only"], 0],
])
await t.test(name, async () => {
await writeFile(marker, "");
const result = run(options, args);
assert.equal(result.status, status, result.stdout + result.stderr);
const calls = await commands(),
ssh = calls.filter((call) => call.command === "ssh");
if (name === "package-only")
assert.ok(!calls.some((call) => ["ssh", "scp"].includes(call.command)));
else if (name === "upload-failed") {
assert.equal(ssh.length, 1);
assert.doesNotMatch(ssh[0].args.at(-1), /run\.sh/);
assert.doesNotMatch(result.stdout, /上传完成|执行完成/);
} else {
assert.equal(ssh.length, 2);
assert.equal(calls.at(-1).command, "ssh");
assert.equal(
ssh[1].args.at(-1),
`cd '${options.DEPLOY_RUN_DIR || runDirectory}' && bash run.sh`,
);
if (name === "run-failed")
assert.doesNotMatch(result.stdout, /执行完成/);
}
});
await t.test(
"changing the configured default directory does not require updating path assertions",
async () => {
const script = join(root, "deploy_test.sh"),
original = await readFile(script, "utf8");
const changed = original.replace(
/^RUN_DIR=.*$/m,
'RUN_DIR="${DEPLOY_RUN_DIR:-/srv/operator-default}"',
);
assert.notEqual(changed, original);
try {
await writeFile(script, changed);
await writeFile(marker, "");
const directory = defaultRunDirectory();
assert.equal(directory, "/srv/operator-default");
const result = run();
assert.equal(result.status, 0, result.stdout + result.stderr);
const calls = await commands();
assert.equal(calls.at(-1).command, "ssh");
assert.equal(
calls.at(-1).args.at(-1),
`cd '${directory}' && bash run.sh`,
);
} finally {
await writeFile(script, original);
}
},
);
});
test("production uploads before running the exact allowed SSH command, and stops on failure", async (t) => {
const { root } = await fixture(t),
bin = join(root, "bin"),
marker = join(root, "production-commands.jsonl");
await mkdir(bin);
await cp(
new URL("deploy_prod.sh", projectRoot),
join(root, "deploy_prod.sh"),
);
for (const name of ["npm", "ssh", "scp"]) {
const code =
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args})+'\\n');` +
(name === "npm"
? `if(args.join(' ')==='run bundle'){const result=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','production'],{stdio:'inherit'});process.exit(result.status??1);}if(args[1]==='bundle:linux'){const destination='dist/production-linux-x64-glibc';fs.rmSync(destination,{recursive:true,force:true});fs.cpSync('dist/production',destination,{recursive:true});fs.writeFileSync(destination+'/runtime-target.json',JSON.stringify({platform:'linux',arch:'x64',libc:'glibc'}));}`
: name === "scp"
? `process.exit(Number(process.env.SCP_EXIT||0));`
: `if(args.at(-1)!==process.env.ALLOWED_REMOTE_COMMAND){console.error('权限不足');process.exit(1);}process.stdout.write(process.env.RUN_OUTPUT||'');process.exit(Number(process.env.RUN_EXIT||0));`);
await writeFile(
join(bin, name),
"#!" + process.execPath + "\n" + code + "\n",
{ mode: 0o755 },
);
}
const archive = "fixture_production.tar.gz";
const env = {
...process.env,
PATH: bin + ":" + process.env.PATH,
MARKER: marker,
DEPLOY_DRY_RUN: "0",
DEPLOY_JUMP_HOST: "fixture-host",
DEPLOY_JUMP_DIR: "/srv/production/",
DEPLOY_SCP_PORT: "2203",
DEPLOY_SSH_PORT: "2202",
DEPLOY_TARGET_ARCH: "x64",
DEPLOY_TARGET_LIBC: "glibc",
DEPLOY_PACKAGE_NAME: archive,
ALLOWED_REMOTE_COMMAND: "cd /srv/production && bash rsupdate.sh",
SCP_EXIT: "0",
RUN_EXIT: "0",
RUN_OUTPUT: "",
};
const run = (options = {}, args = []) =>
spawnSync("bash", ["deploy_prod.sh", ...args], {
cwd: root,
encoding: "utf8",
env: { ...env, ...options },
});
const commands = async () =>
(await readFile(marker, "utf8"))
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const result = run();
assert.equal(result.status, 0, result.stdout + result.stderr);
const calls = await commands();
assert.deepEqual(
calls.map((call) => call.command),
["npm", "npm", "npm", "scp", "ssh"],
);
assert.deepEqual(
calls.filter((call) => call.command === "npm").map((call) => call.args),
[
["run", "check"],
["run", "bundle"],
["run", "bundle:linux", "--", "x64", "glibc"],
],
);
assert.deepEqual(calls.find((call) => call.command === "scp").args, [
"-P",
"2203",
archive,
"fixture_production.md5",
"fixture-host:/srv/production/",
]);
assert.deepEqual(calls.find((call) => call.command === "ssh").args, [
"-p",
"2202",
"fixture-host",
env.ALLOWED_REMOTE_COMMAND,
]);
assert.ok(result.stdout.includes("远端命令:" + env.ALLOWED_REMOTE_COMMAND));
assert.match(result.stdout, /正式服 rsupdate\.sh 执行完成/);
assert.equal(
await readFile(join(root, "fixture_production.md5"), "utf8"),
createHash("md5")
.update(await readFile(join(root, archive)))
.digest("hex") + "\n",
);
for (const [name, options, args, status] of [
["upload-failed", { SCP_EXIT: "23" }, [], 23],
["remote-failed", { RUN_EXIT: "42" }, [], 1],
[
"restricted-ssh-denied",
{ ALLOWED_REMOTE_COMMAND: "a different command" },
[],
1,
],
[
"remote-error-output",
{ RUN_OUTPUT: "FATAL: service did not start\n" },
[],
1,
],
[
"remote-curl-401",
{
RUN_OUTPUT:
"curl: (22) The requested URL returned error: 401\n发布成功\n",
},
[],
1,
],
[
"custom-directory",
{
DEPLOY_JUMP_DIR: "/srv/custom-production/",
ALLOWED_REMOTE_COMMAND: "cd /srv/custom-production && bash rsupdate.sh",
},
[],
0,
],
["package-only", {}, ["--package-only"], 0],
])
await t.test(name, async () => {
await writeFile(marker, "");
const result = run(options, args);
assert.equal(result.status, status, result.stdout + result.stderr);
const calls = await commands();
if (name === "package-only")
assert.ok(!calls.some((call) => ["scp", "ssh"].includes(call.command)));
else if (name === "upload-failed")
assert.ok(!calls.some((call) => call.command === "ssh"));
else assert.equal(calls.at(-1).command, "ssh");
if (name === "restricted-ssh-denied")
assert.match(result.stdout, /权限不足/);
if (name === "custom-directory") {
assert.equal(calls.at(-1).args.at(-1), options.ALLOWED_REMOTE_COMMAND);
assert.equal(
calls.find((call) => call.command === "scp").args.at(-1),
"fixture-host:/srv/custom-production/",
);
}
if (status) assert.doesNotMatch(result.stdout, /执行完成/);
});
});
test("tar.gz server updates reject invalid archives before stopping services and roll back unhealthy replacements", async (t) => {
for (const scenario of [
"checksum",
"archive",
"link",
"traversal",
"install",
"health",
"success",
"custom-name",
])
await t.test(scenario, async (t) => {
const root = await mkdtemp(join(tmpdir(), "91-server-update-"));
t.after(() => rm(root, { recursive: true, force: true }));
const bin = join(root, "bin"),
upload = join(root, "package");
await mkdir(bin);
await mkdir(upload);
async function put(base, path, text = "fixture") {
await mkdir(join(base, path, ".."), { recursive: true });
await writeFile(join(base, path), text);
}
await cp(
new URL("deploy/up_next_h5.sh", projectRoot),
join(root, "up_next_h5.sh"),
);
await put(root, "next/version", "previous");
await put(root, "next/config/test.local.env", "PORT=21022\n");
await put(
root,
"next/.next-test/cache/seo/keep.json",
"previous public SEO",
);
for (const file of [
"start.mjs",
"server.js",
".next-test/BUILD_ID",
".next-test/static/app.js",
"public/logo.svg",
"package-lock.json",
])
await put(upload, file);
await put(upload, "package.json", "{}");
await put(upload, "version", "replacement");
await put(
upload,
"config/test.env",
"API_BASE_URL=https://fixture.test\nPORT=21030\n",
);
await cp(
new URL("scripts/next-env.mjs", projectRoot),
join(upload, "scripts/next-env.mjs"),
);
await put(
upload,
"scripts/check-health.mjs",
"process.exit(Number(process.env.HEALTH_EXIT||0));",
);
const packageName =
scenario === "custom-name"
? "custom_release.tar.gz"
: "web_jgdj_h5.tar.gz";
if (scenario === "link")
await symlink(
join(root, "untouched"),
join(upload, "public/unsafe-link"),
);
const packed = spawnSync(
"tar",
["--no-xattrs", "-czf", join(root, packageName), "-C", upload, "."],
{ encoding: "utf8", env: { ...process.env, COPYFILE_DISABLE: "1" } },
);
assert.equal(packed.status, 0, packed.stderr);
if (scenario === "archive")
await writeFile(join(root, packageName), "not a gzip archive");
if (scenario === "traversal") {
const header = Buffer.alloc(512);
for (const [offset, value] of [
[0, "../escaped-file"],
[100, "0000644\0"],
[108, "0000000\0"],
[116, "0000000\0"],
[124, "00000000000\0"],
[136, "00000000000\0"],
[257, "ustar\0"],
[263, "00"],
])
header.write(value, offset);
header.fill(32, 148, 156);
header[156] = 48;
header.write(
[...header]
.reduce((total, byte) => total + byte, 0)
.toString(8)
.padStart(6, "0") + "\0 ",
148,
);
await writeFile(
join(root, packageName),
gzipSync(Buffer.concat([header, Buffer.alloc(1024)])),
);
}
const hash = createHash("sha256")
.update(await readFile(join(root, packageName)))
.digest("hex");
await writeFile(
join(root, packageName + ".sha256"),
(scenario === "checksum" ? "0".repeat(64) : hash) +
" " +
packageName +
"\n",
);
const stubs = {
npm: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,'npm\\n');if(process.env.INSTALL_FAIL==='1')process.exit(1);fs.mkdirSync('node_modules',{recursive:true});fs.cpSync(process.env.DOTENV_SOURCE,'node_modules/dotenv',{recursive:true});`,
supervisorctl: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,process.argv.slice(2).join(' ')+'\\n');if(process.argv[2]==='status')console.log('jgdj_next_h5 RUNNING pid 100');`,
sha256sum: `const fs=require('node:fs'),crypto=require('node:crypto');const expected=fs.readFileSync(process.argv[3],'utf8').split(/\\s/)[0],actual=crypto.createHash('sha256').update(fs.readFileSync(process.argv[3].replace(/\\.sha256$/,''))).digest('hex');process.exit(expected===actual?0:1);`,
};
for (const [name, code] of Object.entries(stubs))
await writeFile(
join(bin, name),
"#!" + process.execPath + "\n" + code + "\n",
{ mode: 0o755 },
);
const marker = join(root, "commands");
// The fake Supervisor has its own service; caller deployment overrides must not select a real service here.
const result = spawnSync("bash", [join(root, "up_next_h5.sh")], {
encoding: "utf8",
env: {
...process.env,
DEPLOY_PROGRAM: "jgdj_next_h5",
DEPLOY_PACKAGE_NAME: packageName,
PATH: bin + ":" + process.env.PATH,
MARKER: marker,
DOTENV_SOURCE: new URL("node_modules/dotenv", projectRoot).pathname,
INSTALL_FAIL: scenario === "install" ? "1" : "0",
HEALTH_EXIT: scenario === "health" ? "1" : "0",
},
});
const commands = await readFile(marker, "utf8");
if (["success", "custom-name"].includes(scenario)) {
assert.equal(result.status, 0, result.stderr);
assert.equal(
await readFile(join(root, "next/version"), "utf8"),
"replacement",
);
assert.equal(
await readFile(join(root, "next/config/test.local.env"), "utf8"),
"PORT=21022\n",
);
assert.equal(
await readFile(
join(root, "next/.next-test/cache/seo/keep.json"),
"utf8",
),
"previous public SEO",
);
} else {
assert.notEqual(result.status, 0);
assert.equal(
await readFile(join(root, "next/version"), "utf8"),
"previous",
);
if (scenario === "health")
assert.equal(
commands.match(/start jgdj_next_h5/g)?.length,
2,
commands,
);
else assert.doesNotMatch(commands, /stop |start /);
if (["checksum", "archive", "link", "traversal"].includes(scenario))
assert.doesNotMatch(commands, /npm/);
if (scenario === "archive")
assert.match(result.stderr, /tar.gz 压缩包损坏/);
if (scenario === "link") assert.match(result.stderr, /不允许链接/);
if (scenario === "traversal")
assert.match(result.stderr, /非法 tar.gz 路径/);
}
await assert.rejects(stat(join(root, "escaped-file")), {
code: "ENOENT",
});
await assert.rejects(stat(join(root, ".jgdj_next_h5.lock")), {
code: "ENOENT",
});
});
});