Replace unsafe legacy Fileman symlink activation with an authenticated, root-owned account runner and crash-safe pointer reconciliation.
424 lines
15 KiB
JavaScript
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);
|
|
});
|
|
});
|