fix: harden V1 deployment manifests
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { atomicWrite, finalizeDeployment, preflightDeploy } from "./finalize-manifest.mjs";
|
||||
import { selectManifest } from "./select-manifest.mjs";
|
||||
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs";
|
||||
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
|
||||
|
||||
const TOKEN = "0x1000000000000000000000000000000000000001";
|
||||
const PROXY = "0x2000000000000000000000000000000000000002";
|
||||
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
|
||||
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
||||
const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
||||
|
||||
test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
|
||||
await withFixture(async (root) => {
|
||||
@@ -28,6 +29,19 @@ test("preflight rejects an existing target canonical manifest with the safe reco
|
||||
});
|
||||
});
|
||||
|
||||
test("direct manifest CLIs print the exact educational warning", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const finalizeLogs = [];
|
||||
await runFinalizeCli(["preflight-deploy", "anvil"], { root, log: (line) => finalizeLogs.push(line) });
|
||||
assert.deepEqual(finalizeLogs, [EDUCATIONAL_WARNING]);
|
||||
|
||||
await writeJson(join(root, "deployments", "anvil.json"), manifest());
|
||||
const selectLogs = [];
|
||||
await runSelectCli(["anvil"], { root, log: (line) => selectLogs.push(line) });
|
||||
assert.deepEqual(selectLogs, [EDUCATIONAL_WARNING]);
|
||||
});
|
||||
});
|
||||
|
||||
test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const pending = manifest({ deploymentBlock: 0 });
|
||||
@@ -52,7 +66,11 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre
|
||||
["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/],
|
||||
["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/],
|
||||
["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/],
|
||||
["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret/i],
|
||||
["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i],
|
||||
["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "test test test test test test test test test test test junk", "Bob"] }) }, /prohibited/i],
|
||||
["private key in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "Bob"] }) }, /prohibited/i],
|
||||
["credential in RPC path", { pending: manifest({ deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public endpoint|prohibited/i],
|
||||
["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i],
|
||||
];
|
||||
|
||||
for (const [name, options, expected] of cases) {
|
||||
@@ -72,6 +90,18 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre
|
||||
}
|
||||
});
|
||||
|
||||
test("finalizer failure leaves an initially absent canonical manifest absent", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const pending = manifest({ deploymentBlock: 0, actorLabels: ["owner", "MNEMONIC", "Bob"] });
|
||||
await writeJson(join(root, "deployments", "pending.json"), pending);
|
||||
await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 }));
|
||||
await writeBroadcast(root, pending);
|
||||
|
||||
await assert.rejects(() => finalizeDeployment({ root, rpc: fakeRpc() }), /prohibited/i);
|
||||
await assert.rejects(() => access(join(root, "deployments", "anvil.json")));
|
||||
});
|
||||
});
|
||||
|
||||
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const anvil = manifest({ deploymentBlock: 31 });
|
||||
@@ -85,6 +115,9 @@ test("selection atomically replaces active with only a valid named canonical man
|
||||
await selectManifest({ root, network: "base-sepolia" });
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes);
|
||||
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base);
|
||||
const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json"));
|
||||
await selectManifest({ root, network: "anvil" });
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes);
|
||||
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 }));
|
||||
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||
@@ -106,17 +139,32 @@ test("atomic writes stage a same-directory temporary file before renaming it int
|
||||
assert.equal(calls[0][0], "write");
|
||||
assert.equal(dirname(calls[0][1]), dirname(target));
|
||||
assert.notEqual(calls[0][1], target);
|
||||
assert.match(calls[0][1], /\.json$/);
|
||||
assert.deepEqual(calls[1], ["rename", calls[0][1], target]);
|
||||
});
|
||||
|
||||
test("atomic writes remove the ignored staging file when rename fails", async () => {
|
||||
const target = "/tmp/deployments/active.json";
|
||||
const calls = [];
|
||||
const io = {
|
||||
writeFile: async (path) => calls.push(["write", path]),
|
||||
rename: async () => { throw new Error("rename failed"); },
|
||||
rm: async (path) => calls.push(["rm", path]),
|
||||
};
|
||||
|
||||
await assert.rejects(() => atomicWrite(target, "confirmed", io), /rename failed/);
|
||||
assert.deepEqual(calls[1], ["rm", calls[0][1]]);
|
||||
});
|
||||
|
||||
function manifest(overrides = {}) {
|
||||
const baseSepolia = overrides.network === "base-sepolia";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
network: "anvil",
|
||||
chainId: 31337,
|
||||
network: baseSepolia ? "base-sepolia" : "anvil",
|
||||
chainId: baseSepolia ? 84532 : 31337,
|
||||
deploymentBlock: 1,
|
||||
rpcUrl: "http://127.0.0.1:8545",
|
||||
explorerUrl: "",
|
||||
rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545",
|
||||
explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "",
|
||||
token: TOKEN,
|
||||
proxy: PROXY,
|
||||
implementation: IMPLEMENTATION,
|
||||
|
||||
Reference in New Issue
Block a user