Files
pleno-vue/tests/unit/cpanel-deploy.spec.js
T
Jeppe BandJeppe Bundgaard fc67e7cf0b Fix idempotent FTPS directory setup (#217)
The production FTPS deploy now authenticates, but lftp exits when cPanel
returns 550 File exists for pre-created deployment directories. Use lftp
mkdir -p -f for the archive and activation directories so retries remain
idempotent while subsequent upload operations still surface real access
failures.\n\nVerification:\n- vitest tests/unit/cpanel-deploy.spec.js
(26 passed)\n- eslint scripts/release/cpanel-deploy-lib.mjs
tests/unit/cpanel-deploy.spec.js\n- git diff --check

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 00:00:48 +02:00

813 lines
32 KiB
JavaScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CpanelFilemanClient,
DeploymentError,
assertExpectedCommitCurrent,
assertReleaseTargetExists,
buildLftpScript,
capturePublishedReleaseTarget,
containedRemotePath,
createLftpTransport,
deriveCpanelRoot,
deployRelease,
pruneInactiveReleases,
readDeploymentConfig,
rollbackRelease,
targetRelativeToRoot,
validateReleaseTarget,
} from "../../scripts/release/cpanel-deploy-lib.mjs";
const temporaryDirectories = [];
const COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
const execFileAsync = promisify(execFile);
function validEnv(overrides = {}) {
return {
PRODUCTION_FTP_HOST: "ftp.example.test:21",
PRODUCTION_FTP_USER: "deploy-user",
PRODUCTION_FTP_PASSWORD: "password with ' quote",
PRODUCTION_FTP_PATH: "/",
PRODUCTION_ACTIVATION_KEY: "c".repeat(64),
PRODUCTION_CPANEL_USER: "cpanel-user",
PRODUCTION_CPANEL_API_TOKEN: "cpanel-token",
PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083/",
PRODUCTION_CPANEL_PATH: "/home/cpanel-user/public_html/frontend",
PRODUCTION_FRONTEND_URL: "https://app.example.test/",
RELEASE_ARCHIVE_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip",
RELEASE_ARCHIVE_SHA256_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip.sha256",
RELEASE_INVENTORY_PATH: "/tmp/pleno-vue-deadbeef-123-1.inventory.json",
RELEASE_EXPECTED_COMMIT: COMMIT_SHA,
RELEASE_EXPECTED_BUILD_ID: "123-1",
RELEASE_GITHUB_REPOSITORY: "pleno-dev/pleno-vue",
RELEASE_GITHUB_TOKEN: "github-token",
...overrides,
};
}
function config(overrides = {}) {
return {
...readDeploymentConfig(validEnv()),
...overrides,
};
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))
);
});
describe("cPanel deployment configuration", () => {
it("validates every credential without putting values in errors", () => {
const secret = "never-print-this-token";
expect(() => readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` }))).toThrowError(
"PRODUCTION_CPANEL_API_TOKEN contains unsupported control characters"
);
try {
readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` }));
} catch (error) {
expect(error.message).not.toContain(secret);
}
});
it("derives a safe immutable release ID and accepts the checksum compatibility alias", () => {
const env = validEnv();
delete env.RELEASE_ARCHIVE_SHA256_PATH;
env.RELEASE_CHECKSUM_PATH = "/tmp/archive.zip.sha256";
const result = readDeploymentConfig(env);
expect(result.releaseId).toBe(`${COMMIT_SHA}-123-1`);
expect(result.checksumPath).toBe("/tmp/archive.zip.sha256");
expect(result.ftp.root).toBe("/");
expect(result.cpanel.root).toBe("public_html/frontend");
});
it("rejects path traversal and targets outside immutable releases", () => {
expect(() => containedRemotePath("public_html/frontend", "..", "outside")).toThrow(DeploymentError);
expect(() => validateReleaseTarget("staging/release/dist")).toThrow(DeploymentError);
expect(targetRelativeToRoot("public_html/frontend", "/home/user/public_html/frontend/releases/r1/dist")).toBe(
"releases/r1/dist"
);
});
it("maps an absolute FTP path to the API2 account-home-relative path", () => {
expect(deriveCpanelRoot("/home/cpanel-user/public_html/frontend", "cpanel-user")).toBe("public_html/frontend");
expect(() => deriveCpanelRoot("/home/different-user/public_html/frontend", "cpanel-user")).toThrow(
"not inside the configured cPanel account home"
);
});
it("does not require deploy artifacts for rollback-only configuration", () => {
const env = validEnv();
for (const name of [
"PRODUCTION_FRONTEND_URL",
"RELEASE_ARCHIVE_PATH",
"RELEASE_ARCHIVE_SHA256_PATH",
"RELEASE_INVENTORY_PATH",
"RELEASE_EXPECTED_COMMIT",
"RELEASE_EXPECTED_BUILD_ID",
"RELEASE_GITHUB_REPOSITORY",
"RELEASE_GITHUB_TOKEN",
]) {
delete env[name];
}
const result = readDeploymentConfig(env, { rollbackOnly: true });
expect(result.ftp).toEqual({
host: "ftp.example.test:21",
user: "deploy-user",
password: "password with ' quote",
root: "/",
});
expect(result.cpanel.root).toBe("public_html/frontend");
});
it("does not require cPanel API access for the hosted FTPS release path", () => {
const env = validEnv();
for (const name of [
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
"PRODUCTION_CPANEL_PATH",
]) {
delete env[name];
}
const result = readDeploymentConfig(env);
expect(result.cpanel).toBeUndefined();
expect(result.ftp.root).toBe("/");
});
});
describe("secure FTPS archive upload", () => {
it("reports actionable lftp diagnostics without exposing deployment credentials", async () => {
const deploymentConfig = config();
const runnerError = Object.assign(new Error(`connection to ${deploymentConfig.ftp.host} failed`), {
stderr: `ftps://${deploymentConfig.ftp.user}:${deploymentConfig.ftp.password}@${deploymentConfig.ftp.host}: 530 Login incorrect`,
stdout: `cd ${deploymentConfig.ftp.root}: Access failed`,
});
const transport = createLftpTransport(deploymentConfig, {
runner: vi.fn(async () => {
throw runnerError;
}),
});
let error;
try {
await transport.removeRelease("old-release");
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(DeploymentError);
expect(error.message).toContain("530 Login incorrect");
expect(error.message).toContain("[redacted]");
expect(error.message).not.toContain(deploymentConfig.ftp.host);
expect(error.message).not.toContain(deploymentConfig.ftp.user);
expect(error.message).not.toContain(deploymentConfig.ftp.password);
});
it("redacts overlapping credentials longest-first and host names case-insensitively", async () => {
const deploymentConfig = readDeploymentConfig(
validEnv({
PRODUCTION_FTP_HOST: "Ftp.Example.Test:21",
PRODUCTION_FTP_USER: "prod",
PRODUCTION_FTP_PASSWORD: "prod-secret",
PRODUCTION_FTP_PATH: "/deploy/prod",
})
);
const runnerError = Object.assign(new Error("FTPS failed"), {
stderr: "authentication failed for prod-secret at FTP.EXAMPLE.TEST:21 under /deploy/prod",
});
const transport = createLftpTransport(deploymentConfig, {
runner: vi.fn(async () => {
throw runnerError;
}),
});
let error;
try {
await transport.removeRelease("old-release");
} catch (caught) {
error = caught;
}
expect(error.message).toContain("authentication failed");
expect(error.message).not.toContain("-secret");
expect(error.message).not.toContain("FTP.EXAMPLE.TEST:21");
expect(error.message).not.toContain("/deploy/prod");
});
it("forces verified TLS and keeps credentials out of command arguments", () => {
const deploymentConfig = config();
const script = buildLftpScript(deploymentConfig, ["bye"]);
expect(script).toContain("set ftp:ssl-force yes");
expect(script).toContain("set ftp:ssl-protect-data yes");
expect(script).toContain("set ftp:list-options -a");
expect(script).toContain("set ssl:verify-certificate yes");
expect(script).toContain("set ssl:check-hostname yes");
expect(script).toContain("open -u 'deploy-user','password with '\\'' quote'");
});
it("uploads .part files, downloads them for verification, then renames them", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-deploy-test-"));
temporaryDirectories.push(directory);
const archivePath = path.join(directory, "pleno-vue-deadbeef-123-1.zip");
const checksumPath = `${archivePath}.sha256`;
const archive = Buffer.from("verified release archive");
const hash = crypto.createHash("sha256").update(archive).digest("hex");
await fs.writeFile(archivePath, archive);
await fs.writeFile(checksumPath, `${hash} ${path.basename(archivePath)}\n`);
const calls = [];
const runner = vi.fn(async (command, args, options) => {
calls.push({ command, args, input: options.input });
const downloads = [...options.input.matchAll(/get '[^']+' -o '([^']+)'/g)].map((match) => match[1]);
if (downloads.length === 2) {
await fs.copyFile(archivePath, downloads[0]);
await fs.copyFile(checksumPath, downloads[1]);
}
});
const deploymentConfig = config({ archivePath, checksumPath });
const transport = createLftpTransport(deploymentConfig, { runner });
await expect(transport.uploadArchive()).resolves.toMatchObject({
archiveName: path.basename(archivePath),
sha256: hash,
});
expect(calls).toHaveLength(2);
expect(calls[0].args).toEqual([]);
expect(calls[0].input).toContain("mkdir -p -f 'archives'");
expect(calls[0].input).toContain(`${path.basename(archivePath)}.part`);
expect(calls[0].input).toContain("get 'archives/");
expect(calls[1].input).toContain(
`mv 'archives/${path.basename(archivePath)}.sha256.part' 'archives/${path.basename(archivePath)}.sha256'`
);
expect(calls[1].input).toContain(
`mv 'archives/${path.basename(archivePath)}.part' 'archives/${path.basename(archivePath)}'`
);
expect(calls[0].args.join(" ")).not.toContain(deploymentConfig.ftp.password);
});
it("permanently removes only validated inactive release and archive paths", async () => {
const scripts = [];
const runner = vi.fn(async (_command, _args, options) => scripts.push(options.input));
const transport = createLftpTransport(config(), { runner });
await transport.removeRelease("old-release");
await transport.removeArchives(["old-release"]);
expect(scripts.join("\n")).toContain("rm -r -f 'releases/old-release'");
expect(scripts.join("\n")).toContain("rm -f 'archives/pleno-vue-old-release.zip'");
await expect(transport.removeRelease("../outside")).rejects.toThrow("safe filename component");
});
it("queues a bounded activation request without replacing the account-scoped executable", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activation-request-test-"));
temporaryDirectories.push(directory);
const deploymentConfig = config();
const target = `releases/${deploymentConfig.releaseId}/dist`;
let uploadedRequest = "";
const scripts = [];
const runner = vi.fn(async (_command, _args, options) => {
scripts.push(options.input);
const requestMatch = options.input.match(
/put '([^']+\.request)' -o 'activation-requests\/gha-test\.request\.part'/
);
if (requestMatch) uploadedRequest = await fs.readFile(requestMatch[1], "utf8");
const resultMatch = options.input.match(/get 'activation-results\/gha-test\.result' -o '([^']+)'/);
if (resultMatch) {
await fs.writeFile(
resultMatch[1],
`schema_version=1\nrequest_id=gha-test\nstatus=success\ntarget=${target}\nmessage=activated\n`
);
}
});
const transport = createLftpTransport(deploymentConfig, {
runner,
activationRequestId: "gha-test",
now: () => 2_000_000_000_000,
});
await expect(transport.stageAndActivate({ archiveName: "release.zip", sha256: "a".repeat(64) })).resolves.toBe(
target
);
expect(scripts[0]).not.toContain("release-activate.sh");
expect(scripts[0]).toContain("mkdir -p -f 'activation-requests'");
expect(scripts[0]).toContain("mkdir -p -f 'activation-results'");
expect(uploadedRequest).toContain(`release_id=${deploymentConfig.releaseId}`);
expect(uploadedRequest).toContain(`commit_sha=${deploymentConfig.expectedCommit}`);
expect(uploadedRequest).toContain("archive_sha256=" + "a".repeat(64));
expect(uploadedRequest).toContain("expires_at=2000000150");
const [authenticatedBody, requestHmac] = uploadedRequest.split("request_hmac=");
expect(requestHmac.trim()).toBe(
crypto
.createHmac("sha256", Buffer.from(deploymentConfig.activationKey, "hex"))
.update(authenticatedBody)
.digest("hex")
);
});
it("polls for a committed result when the request upload outcome is ambiguous", async () => {
const deploymentConfig = config();
const target = `releases/${deploymentConfig.releaseId}/dist`;
let calls = 0;
const runner = vi.fn(async (_command, _args, options) => {
calls += 1;
if (calls === 1) throw new Error("FTPS disconnected after the final rename");
const resultMatch = options.input.match(/get 'activation-results\/gha-ambiguous\.result' -o '([^']+)'/);
if (resultMatch) {
await fs.writeFile(
resultMatch[1],
`schema_version=1\nrequest_id=gha-ambiguous\nstatus=success\ntarget=${target}\nmessage=activated\n`
);
}
});
const transport = createLftpTransport(deploymentConfig, {
runner,
activationRequestId: "gha-ambiguous",
});
await expect(transport.activateExisting(target)).resolves.toBe(target);
expect(runner).toHaveBeenCalledTimes(2);
});
});
describe("account-scoped release activator", () => {
it("stages a validated archive, switches current atomically, and leaves current untouched on failure", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activator-test-"));
temporaryDirectories.push(directory);
const activationRoot = path.join(directory, "frontend-deployments");
const activationKey = "c".repeat(64);
const activationKeyPath = path.join(directory, "activation.key");
const packageRoot = path.join(directory, "package");
const dist = path.join(packageRoot, "dist");
await fs.mkdir(path.join(activationRoot, "archives"), { recursive: true });
await fs.mkdir(path.join(activationRoot, "activation-requests"), { recursive: true });
await fs.writeFile(activationKeyPath, `${activationKey}\n`);
const writeDist = async (directoryPath, commit, buildId, marker = "app") => {
await fs.mkdir(directoryPath, { recursive: true });
await fs.writeFile(path.join(directoryPath, "index.html"), `<div id="${marker}"></div>`);
await fs.writeFile(path.join(directoryPath, ".htaccess"), "DirectoryIndex index.html\n");
await fs.writeFile(path.join(directoryPath, "release-entry.json"), "{}\n");
await fs.writeFile(
path.join(directoryPath, "release-manifest.json"),
JSON.stringify({ commit_sha: commit, build_id: buildId })
);
};
const oldCommit = "a".repeat(40);
const oldBuildId = "122-1";
const oldReleaseId = `${oldCommit}-${oldBuildId}`;
const oldTarget = `releases/${oldReleaseId}/dist`;
await writeDist(path.join(activationRoot, oldTarget), oldCommit, oldBuildId, "old-app");
await fs.symlink(oldTarget, path.join(activationRoot, "current"));
await writeDist(dist, COMMIT_SHA, "123-1");
const releaseId = `${COMMIT_SHA}-123-1`;
const target = `releases/${releaseId}/dist`;
const archiveName = `pleno-vue-${releaseId}.zip`;
const archivePath = path.join(activationRoot, "archives", archiveName);
await execFileAsync("zip", ["-qr", archivePath, "dist"], { cwd: packageRoot });
const archive = await fs.readFile(archivePath);
const archiveSha256 = crypto.createHash("sha256").update(archive).digest("hex");
const writeRequest = async (
requestId,
hash,
requestedReleaseId = releaseId,
requestedBuildId = "123-1",
action = "stage",
expiresAt = Math.floor(Date.now() / 1000) + 300
) => {
const requestBody = [
"schema_version=1",
`request_id=${requestId}`,
`action=${action}`,
`release_id=${requestedReleaseId}`,
`commit_sha=${COMMIT_SHA}`,
`build_id=${requestedBuildId}`,
`archive_name=${action === "stage" ? archiveName : "unused"}`,
`archive_sha256=${action === "stage" ? hash : "unused"}`,
`expires_at=${expiresAt}`,
"",
].join("\n");
const requestHmac = crypto
.createHmac("sha256", Buffer.from(activationKey, "hex"))
.update(requestBody)
.digest("hex");
await fs.writeFile(
path.join(activationRoot, "activation-requests", `${requestId}.request`),
`${requestBody}request_hmac=${requestHmac}\n`
);
};
const activator = path.resolve("scripts/release/cpanel-activate.sh");
const activatorEnvironment = {
...process.env,
CPANEL_ACTIVATION_ROOT: activationRoot,
CPANEL_ACTIVATION_KEY_FILE: activationKeyPath,
};
await writeRequest("activation-success", archiveSha256);
await execFileAsync("sh", [activator], { env: activatorEnvironment });
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
expect(await fs.readFile(path.join(activationRoot, "current", "index.html"), "utf8")).toContain('id="app"');
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
).toContain("status=success");
const processedSuccess = path.join(
activationRoot,
"activation-requests",
"processed",
"activation-success.processed"
);
const processingSuccess = path.join(activationRoot, "activation-requests", "activation-success.processing");
await fs.rename(processedSuccess, processingSuccess);
await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result"));
await execFileAsync("sh", [activator], { env: activatorEnvironment });
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
).toContain("status=success");
const failedReleaseId = `${COMMIT_SHA}-123-2`;
await writeRequest("activation-failure", "0".repeat(64), failedReleaseId, "123-2");
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-failure.result"), "utf8")
).toContain("status=failure");
const existingReleaseId = `${COMMIT_SHA}-123-3`;
await writeDist(path.join(activationRoot, "releases", existingReleaseId, "dist"), COMMIT_SHA, "123-3");
await writeRequest("activation-existing", archiveSha256, existingReleaseId, "123-3");
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-existing.result"), "utf8")
).toContain("message=release_already_exists");
await writeRequest(
"activation-expired",
archiveSha256,
`${COMMIT_SHA}-123-4`,
"123-4",
"stage",
Math.floor(Date.now() / 1000) - 1
);
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
await writeRequest("activation-unauthenticated", archiveSha256, `${COMMIT_SHA}-123-5`, "123-5");
const unauthenticatedRequest = path.join(
activationRoot,
"activation-requests",
"activation-unauthenticated.request"
);
await fs.writeFile(
unauthenticatedRequest,
(
await fs.readFile(unauthenticatedRequest, "utf8")
).replace(/request_hmac=[a-f0-9]{64}/, `request_hmac=${"0".repeat(64)}`)
);
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
await fs.rm(path.join(activationRoot, target, "index.html"));
await fs.rename(processedSuccess, processingSuccess);
await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result"));
await fs.symlink(oldTarget, path.join(activationRoot, "current.activation-success.rollback"));
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(oldTarget);
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
).toContain("message=post_activation_validation");
await fs.writeFile(path.join(activationRoot, target, "index.html"), '<div id="app"></div>');
await writeRequest("activation-switch", "unused", releaseId, "123-1", "switch");
await execFileAsync("sh", [activator], { env: activatorEnvironment });
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
const missingReleaseId = `${COMMIT_SHA}-missing`;
await writeRequest("activation-missing", "unused", missingReleaseId, "missing", "switch");
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
expect(
await fs.readFile(path.join(activationRoot, "activation-results", "activation-missing.result"), "utf8")
).toContain("message=release_validation");
});
});
describe("cPanel Fileman adapter", () => {
it("uses token authentication and API2 Fileman parameters over HTTPS", async () => {
const requests = [];
const fetchImpl = vi.fn(async (url, options) => {
requests.push({ url: new URL(url), options });
return {
ok: true,
status: 200,
async json() {
return {
cpanelresult: {
event: { result: "1" },
data: [{ result: 1 }],
},
};
},
};
});
const deploymentConfig = config();
const client = new CpanelFilemanClient(deploymentConfig, { fetchImpl });
await client.remove("public_html/frontend/staging/obsolete-release");
expect(requests).toHaveLength(1);
expect(requests[0].url.protocol).toBe("https:");
expect(requests[0].url.searchParams.get("cpanel_jsonapi_apiversion")).toBe("2");
expect(requests[0].url.searchParams.get("cpanel_jsonapi_module")).toBe("Fileman");
expect(requests[0].url.searchParams.get("cpanel_jsonapi_func")).toBe("fileop");
expect(requests[0].url.searchParams.get("op")).toBe("trash");
expect(requests[0].url.searchParams.get("sourcefiles")).toBe("public_html/frontend/staging/obsolete-release");
expect(requests[0].url.searchParams.has("destfiles")).toBe(false);
expect(requests[0].options.headers.Authorization).toBe("cpanel cpanel-user:cpanel-token");
});
it("redacts credentials echoed by a cPanel error", async () => {
const deploymentConfig = config();
const client = new CpanelFilemanClient(deploymentConfig, {
fetchImpl: async () => ({
ok: true,
status: 200,
async json() {
return {
status: 0,
errors: [`bad ${deploymentConfig.cpanel.token}`],
};
},
}),
});
await expect(client.list(deploymentConfig.cpanel.root)).rejects.toThrow("bad [redacted]");
});
it("lists deployment entries through the supported Fileman UAPI", async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
status: "1",
data: [
{ file: "current", type: "link" },
{ file: "releases", type: "dir" },
],
}),
}));
const client = new CpanelFilemanClient(config(), { fetchImpl });
await expect(client.list(config().cpanel.root)).resolves.toEqual([
{ file: "current", type: "link" },
{ file: "releases", type: "dir" },
]);
const endpoint = fetchImpl.mock.calls[0][0];
expect(endpoint.pathname).toBe("/execute/Fileman/list_files");
expect(endpoint.searchParams.get("dir")).toBe("public_html/frontend");
expect(endpoint.searchParams.get("show_hidden")).toBe("1");
expect(endpoint.searchParams.get("types")).toBe("dir|file|link");
});
it("captures the rollback target from the live release manifest", async () => {
const deploymentConfig = config();
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ commit_sha: COMMIT_SHA, build_id: "122-1" }),
}));
await expect(capturePublishedReleaseTarget(deploymentConfig, { fetchImpl })).resolves.toBe(
`releases/${COMMIT_SHA}-122-1/dist`
);
expect(fetchImpl).toHaveBeenCalledWith(
new URL("release-manifest.json", deploymentConfig.frontendUrl),
expect.objectContaining({ headers: expect.objectContaining({ "Cache-Control": "no-cache" }) })
);
});
it("rejects a stale commit immediately before activation", async () => {
const deploymentConfig = config();
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ commit: { sha: "0123456789abcdef0123456789abcdef01234567" } }),
}));
await expect(assertExpectedCommitCurrent(deploymentConfig, { fetchImpl })).rejects.toThrow(
"Release commit is stale"
);
});
});
function inMemorySwitchClient(deploymentConfig, initialTarget) {
let current = initialTarget;
let next = "";
const root = deploymentConfig.cpanel.root;
const entriesForRoot = () => [
{ file: "archives", type: "dir" },
{ file: "releases", type: "dir" },
{ file: "staging", type: "dir" },
...(current ? [{ file: "current", type: "link", link: `${root}/${current}` }] : []),
...(next ? [{ file: "current.next", type: "link", link: `${root}/${next}` }] : []),
];
return {
get current() {
return current;
},
setCurrent(target) {
current = target;
},
async list(directory) {
if (directory === root) return entriesForRoot();
if (directory === `${root}/archives`) return [];
if (directory === `${root}/releases`) {
return current ? [{ file: current.split("/")[1], type: "dir" }] : [];
}
if (current && directory === `${root}/releases/${current.split("/")[1]}`) {
return [{ file: "dist", type: "dir" }];
}
return [];
},
async link(source, destination) {
expect(destination).toBe(`${root}/current.next`);
next = targetRelativeToRoot(root, source);
},
async rename(source, destination) {
expect(source).toBe(`${root}/current.next`);
expect(destination).toBe(`${root}/current`);
current = next;
next = "";
},
async unlink() {
next = "";
},
};
}
describe("activation, rollback, and retention", () => {
it("rejects a manifest-derived rollback target that is absent on cPanel", async () => {
const deploymentConfig = config();
const client = { list: vi.fn(async () => []) };
await expect(
assertReleaseTargetExists(client, deploymentConfig, `releases/${COMMIT_SHA}-122-1/dist`)
).rejects.toThrow("rollback release directory does not exist");
});
it("restores the previous pointer when public verification fails", async () => {
const deploymentConfig = config();
const previousTarget = "releases/previous-release/dist";
const newTarget = `releases/${deploymentConfig.releaseId}/dist`;
const client = inMemorySwitchClient(deploymentConfig, previousTarget);
const outputs = [];
const verifyRelease = vi.fn();
const transport = {
uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }),
stageAndActivate: async () => {
client.setCurrent(newTarget);
return newTarget;
},
activateExisting: async (target) => client.setCurrent(target),
verifyRelease,
};
await expect(
deployRelease(deploymentConfig, {
client,
transport,
capturePrevious: async () => previousTarget,
checkCurrent: async () => {},
verify: async () => {
throw new Error("application gate failed");
},
prune: async () => [],
publish: (values) => outputs.push(values),
})
).rejects.toThrow("the previous release was restored");
expect(client.current).toBe(previousTarget);
expect(verifyRelease).toHaveBeenCalledWith(newTarget);
expect(outputs[0]).toMatchObject({
RELEASE_ROLLBACK_TARGET: previousTarget,
RELEASE_ACTIVE_TARGET: newTarget,
});
});
it("reconciles a lost activation result before verifying the committed release", async () => {
const deploymentConfig = config();
delete deploymentConfig.cpanel;
const previousTarget = `releases/${"a".repeat(40)}-122-1/dist`;
const newTarget = `releases/${deploymentConfig.releaseId}/dist`;
const verifyRelease = vi.fn();
const verify = vi.fn();
const transport = {
uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }),
stageAndActivate: async () => {
throw new DeploymentError("Timed out waiting for the account-scoped release activator.");
},
activateExisting: vi.fn(),
verifyRelease,
};
await expect(
deployRelease(deploymentConfig, {
transport,
capturePrevious: async () => previousTarget,
captureActive: async () => newTarget,
checkCurrent: async () => {},
verify,
prune: async () => [],
publish: () => {},
})
).resolves.toMatchObject({ activeTarget: newTarget });
expect(verifyRelease).toHaveBeenCalledWith(newTarget);
expect(verify).toHaveBeenCalled();
expect(transport.activateExisting).not.toHaveBeenCalled();
});
it("supports an idempotent rollback-only workflow step", async () => {
const deploymentConfig = config();
const target = "releases/previous-release/dist";
const client = inMemorySwitchClient(deploymentConfig, target);
const publish = vi.fn();
const activateExisting = vi.fn(async () => target);
await expect(
rollbackRelease(deploymentConfig, target, {
client,
transport: { activateExisting },
publish,
})
).resolves.toMatchObject({ activeTarget: target });
expect(activateExisting).toHaveBeenCalledWith(target);
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ RELEASE_ACTIVE_TARGET: target }));
});
it("propagates server-side rollback target validation failures without publishing outputs", async () => {
const deploymentConfig = config();
const activateExisting = vi.fn(async () => {
throw new DeploymentError("Server-side release activation failed during release_validation.");
});
const publish = vi.fn();
await expect(
rollbackRelease(deploymentConfig, "releases/missing-release/dist", {
transport: { activateExisting },
publish,
})
).rejects.toThrow("release_validation");
expect(activateExisting).toHaveBeenCalledWith("releases/missing-release/dist");
expect(publish).not.toHaveBeenCalled();
});
it("prunes only inactive releases and preserves active and rollback targets", async () => {
const deploymentConfig = config({ retainCount: 2 });
const removed = [];
const client = {
async list() {
return [
{ file: "active", type: "dir", mtime: 50 },
{ file: "rollback", type: "dir", mtime: 40 },
{ file: "old", type: "dir", mtime: 30 },
{ file: ".trash", type: "dir", mtime: 1 },
];
},
async remove(remotePath) {
removed.push(remotePath);
},
};
await expect(
pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist", "releases/rollback/dist"])
).resolves.toEqual(["old"]);
expect(removed).toEqual([`${deploymentConfig.cpanel.root}/releases/old`]);
});
it("does not prune when cPanel cannot provide reliable modification times", async () => {
const deploymentConfig = config({ retainCount: 2 });
const client = {
list: async () => [{ file: "active", mtime: 50 }, { file: "unknown-age" }],
remove: vi.fn(),
};
await expect(pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist"])).resolves.toEqual([]);
expect(client.remove).not.toHaveBeenCalled();
});
});