Files
pleno-vue/tests/unit/cpanel-root.spec.js
Jeppe B 1a959c2ce8 Activate cPanel releases with a pinned account runner (#193)
Replace unsafe legacy Fileman symlink activation with an authenticated, root-owned account runner and crash-safe pointer reconciliation.
2026-07-20 18:20:03 +02:00

424 lines
15 KiB
JavaScript

import { describe, expect, it, vi } from "vitest";
import { DeploymentError } from "../../scripts/release/cpanel-deploy-lib.mjs";
import { CpanelAccountClient, auditRoot, readRootConfig, restoreRoot } from "../../scripts/release/cpanel-root-lib.mjs";
function environment(overrides = {}) {
return {
PRODUCTION_CPANEL_USER: "truckwash",
PRODUCTION_CPANEL_API_TOKEN: "secret-token",
PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083",
PRODUCTION_CPANEL_PATH: "frontend-deployments",
PRODUCTION_CPANEL_WEBROOT: "public_html",
PRODUCTION_FRONTEND_URL: "https://truckwash.io",
...overrides,
};
}
function config() {
return readRootConfig(environment());
}
function auditClient(overrides = {}) {
const home = [
{ file: "public_html", type: "dir", mode: "0755" },
{ file: "public_html.recovery-before-bootstrap", type: "dir", mode: "0755" },
];
const entries = new Map([
[".", home],
["frontend-deployments", [{ file: "current", type: "link" }]],
[
"frontend-deployments/current",
["index.html", ".htaccess", "release-manifest.json", "release-entry.json"].map((file) => ({
file,
type: "file",
})),
],
["public_html", [{ file: "index.html", type: "file", mode: "0600" }]],
]);
const rename = vi.fn(async (source, destination) => {
const sourceIndex = home.findIndex((entry) => entry.file === source);
if (sourceIndex < 0) throw new Error(`missing source ${source}`);
if (home.some((entry) => entry.file === destination)) throw new Error(`existing destination ${destination}`);
home[sourceIndex] = { ...home[sourceIndex], file: destination };
if (entries.has(source)) {
entries.set(destination, entries.get(source));
entries.delete(source);
}
});
return {
home,
entries,
allowedMutable: new Set(),
list: vi.fn(async (directory) => entries.get(directory) || []),
domains: vi.fn(async () => []),
rename,
...overrides,
};
}
describe("cPanel primary webroot audit", () => {
it("validates account paths and keeps the API token out of failures", () => {
expect(readRootConfig(environment()).deploymentRoot).toBe("frontend-deployments");
expect(() => readRootConfig(environment({ PRODUCTION_CPANEL_WEBROOT: "../public_html" }))).toThrow(DeploymentError);
expect(() => readRootConfig(environment({ PRODUCTION_CPANEL_API_TOKEN: "token\nvalue" }))).toThrow(
"Missing or invalid PRODUCTION_CPANEL_API_TOKEN"
);
});
it("reports the retained pre-bootstrap root and the unreadable physical webroot", async () => {
const report = await auditRoot(config(), { client: auditClient() });
expect(report).toMatchObject({
healthy: false,
rootTargetVerified: false,
webrootAccess: { accessible: true, error: "" },
current: {
path: "frontend-deployments/current",
type: "link",
accessible: true,
missingFiles: [],
},
webroot: { name: "public_html", type: "dir", mode: "0755" },
});
expect(report.webrootEntries).toContainEqual(expect.objectContaining({ name: "index.html", mode: "0600" }));
expect(report.recoveryCandidates.map(({ name }) => name)).toEqual(["public_html.recovery-before-bootstrap"]);
expect(report.stateToken).toMatch(/^[a-f0-9]{64}$/);
});
it("keeps recovery candidates when the current webroot cannot be followed", async () => {
const client = auditClient();
client.list.mockImplementation(async (directory) => {
if (directory === "public_html") throw new Error("dangling root link");
return client.entries.get(directory) || [];
});
const report = await auditRoot(config(), { client });
expect(report.webrootAccess).toEqual({ accessible: false, error: "dangling root link" });
expect(report.recoveryCandidates).toHaveLength(1);
expect(report.healthy).toBe(false);
});
it("recognizes the retained before-atomic bootstrap webroot", async () => {
const client = auditClient();
client.home[1].file = "public_html.before-atomic-20260720T0819Z";
const report = await auditRoot(config(), { client });
expect(report.recoveryCandidates.map(({ name }) => name)).toEqual(["public_html.before-atomic-20260720T0819Z"]);
});
it("reports an invalid current entry without hiding recovery candidates", async () => {
const client = auditClient();
client.entries.set("frontend-deployments", [{ file: "current", type: "dir" }]);
const report = await auditRoot(config(), { client });
expect(report.current).toMatchObject({ type: "dir", accessible: false });
expect(report.recoveryCandidates).toHaveLength(1);
expect(report.healthy).toBe(false);
});
it("limits Fileman mutations to an exact allowlist", async () => {
const client = new CpanelAccountClient(config(), {
allowedMutable: ["public_html", "public_html.failed-1"],
fetchImpl: vi.fn(),
});
await expect(client.rename("public_html", "unrelated")).rejects.toThrow("outside the explicit restore allowlist");
expect(client.fetch).not.toHaveBeenCalled();
});
it("lists account entries through the supported Fileman UAPI", async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ status: "1", data: [{ file: "public_html", type: "dir" }] }),
}));
const client = new CpanelAccountClient(config(), { fetchImpl });
await expect(client.list(".")).resolves.toEqual([{ file: "public_html", type: "dir" }]);
const url = fetchImpl.mock.calls[0][0];
expect(url.pathname).toBe("/execute/Fileman/list_files");
expect(url.searchParams.get("dir")).toBe(".");
expect(url.searchParams.get("show_hidden")).toBe("1");
expect(url.searchParams.get("types")).toBe("dir|file|link");
});
it("fails closed when Fileman UAPI does not return a list", async () => {
const client = new CpanelAccountClient(config(), {
fetchImpl: vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ result: { status: "1", data: {} } }),
})),
});
await expect(client.list("public_html")).rejects.toThrow("unexpected data shape");
});
it("reports only sanitized response structure for an unknown UAPI failure", async () => {
const client = new CpanelAccountClient(config(), {
fetchImpl: vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ status: 0, data: null, metadata: {} }),
})),
});
await expect(client.list(".")).rejects.toThrow(
"response shape root=[data,metadata,status] result=[data,metadata,status] statusType=number dataType=object"
);
});
it("fails closed when DomainInfo does not return the documented list shape", async () => {
const client = new CpanelAccountClient(config(), {
fetchImpl: vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ result: { status: 1, data: {} } }),
})),
});
await expect(client.domains()).rejects.toThrow("unexpected data shape");
});
it("fails closed when a domain record omits its identity or document root", async () => {
const client = auditClient({ domains: vi.fn(async () => [{ domain: "files.example.test" }]) });
await expect(auditRoot(config(), { client })).rejects.toThrow("incomplete domain data");
});
});
describe("cPanel retained webroot restore", () => {
it("requires the exact recovery name and typed confirmation", async () => {
const client = auditClient();
await expect(
restoreRoot(config(), "public_html.recovery-before-bootstrap", "bad-state", "yes", { client, runId: "1" })
).rejects.toThrow("exact state token");
expect(client.rename).not.toHaveBeenCalled();
});
it("preserves the broken state and activates the retained webroot before verification", async () => {
const client = auditClient();
const verify = vi.fn(async () => {});
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "123", verify }
)
).resolves.toEqual({
restored: "public_html.recovery-before-bootstrap",
displaced: "public_html.failed-123",
});
expect(client.rename.mock.calls).toEqual([
["public_html", "public_html.failed-123"],
["public_html.recovery-before-bootstrap", "public_html"],
]);
expect(verify).toHaveBeenCalledOnce();
});
it("refuses a restore when cPanel state changed after the audited token", async () => {
const client = auditClient();
const state = await auditRoot(config(), { client });
client.home[0].mode = "0700";
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "stale" }
)
).rejects.toThrow("changed after the audit");
expect(client.rename).not.toHaveBeenCalled();
});
it("refuses to overwrite a displaced-state entry from an earlier attempt", async () => {
const client = auditClient();
client.home.push({ file: "public_html.failed-collision", type: "dir" });
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "collision" }
)
).rejects.toThrow("already exists");
expect(client.rename).not.toHaveBeenCalled();
});
it("refuses an unreadable physical webroot while preserving audit recovery data", async () => {
const client = auditClient();
client.list.mockImplementation(async (directory) => {
if (directory === "public_html") throw new Error("permission denied");
return client.entries.get(directory) || [];
});
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "unreadable-dir" }
)
).rejects.toThrow("could not be inspected");
expect(client.rename).not.toHaveBeenCalled();
});
it("refuses Fileman restore when the active webroot is a symbolic link", async () => {
const client = auditClient();
client.home[0].type = "link";
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "linked-root" }
)
).rejects.toThrow("physical current webroot");
expect(client.rename).not.toHaveBeenCalled();
});
it("refuses Fileman restore from a symbolic-link recovery candidate", async () => {
const client = auditClient();
client.home[1].type = "link";
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "linked-recovery" }
)
).rejects.toThrow("physical retained directory");
expect(client.rename).not.toHaveBeenCalled();
});
it("refuses to replace a webroot that contains another domain document root", async () => {
const client = auditClient({
domains: vi.fn(async () => [
{
domain: "files.example.test",
domain_type: "addon",
documentroot: "/home/truckwash/public_html/files",
},
]),
});
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "nested-domain" }
)
).rejects.toThrow("nested domain document roots exist");
expect(client.rename).not.toHaveBeenCalled();
});
it("reconciles a committed first rename whose response was lost", async () => {
const client = auditClient();
const rename = client.rename.getMockImplementation();
let call = 0;
client.rename.mockImplementation(async (...args) => {
call += 1;
if (call === 1) {
await rename(...args);
throw new Error("response lost after commit");
}
if (call === 2) throw new Error("activation rejected before commit");
return await rename(...args);
});
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "ambiguous-first" }
)
).rejects.toThrow("pre-restore state was reinstated");
expect(client.home.map(({ file }) => file).sort()).toEqual([
"public_html",
"public_html.recovery-before-bootstrap",
]);
expect(client.rename.mock.calls).toEqual([
["public_html", "public_html.failed-ambiguous-first"],
["public_html.recovery-before-bootstrap", "public_html"],
["public_html.failed-ambiguous-first", "public_html"],
]);
});
it("reinstates the exact pre-restore state when live verification fails", async () => {
const client = auditClient();
const verify = vi.fn(async () => {
throw new Error("still broken");
});
const state = await auditRoot(config(), { client });
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{ client, runId: "456", verify }
)
).rejects.toThrow("pre-restore state was reinstated");
expect(client.rename.mock.calls).toEqual([
["public_html", "public_html.failed-456"],
["public_html.recovery-before-bootstrap", "public_html"],
["public_html", "public_html.recovery-before-bootstrap"],
["public_html.failed-456", "public_html"],
]);
});
it("reports incomplete compensation without claiming the old state was restored", async () => {
const client = auditClient();
const state = await auditRoot(config(), { client });
const rename = client.rename.getMockImplementation();
client.rename.mockImplementation(async (...args) => {
if (client.rename.mock.calls.length === 3) throw new Error("compensation failed");
return await rename(...args);
});
await expect(
restoreRoot(
config(),
"public_html.recovery-before-bootstrap",
state.stateToken,
`RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`,
{
client,
runId: "rollback-failure",
verify: async () => {
throw new Error("site failed");
},
}
)
).rejects.toThrow("automatic rollback was incomplete");
expect(client.rename).toHaveBeenCalledTimes(3);
});
});