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", }); }); });