Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Orchestrator Unit Tests (push) Has been cancelled
CI / Orchestrator E2E (Testcontainers) (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
174 lines
6.3 KiB
TypeScript
174 lines
6.3 KiB
TypeScript
/**
|
|
* End-to-end round-trip against a real EVM node.
|
|
*
|
|
* Spawns the ganache CLI as a child process on a random dev port,
|
|
* deploys NotaryRegistry.sol compiled via in-process solc, and
|
|
* exercises services/notaryChain.ts (`anchorPlan` + `finalizeAnchor`)
|
|
* against it via ethers v6. This closes the
|
|
* orchestrator-unit-tests-pass-but-the-adapter-to-reality-boundary-
|
|
* is-uncovered gap flagged in gap-analysis v2 §7.9 / §8.5 — PR Q's
|
|
* existing suite covers Postgres only.
|
|
*
|
|
* Gated on RUN_E2E=1 to stay out of the fast unit-test path. Runs on
|
|
* CI via the `orchestrator-e2e` job (see .github/workflows/ci.yml).
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from "child_process";
|
|
import { JsonRpcProvider, Wallet, ContractFactory, Contract } from "ethers";
|
|
import { compileNotaryRegistry } from "./helpers/compileNotaryRegistry";
|
|
|
|
const RUN_E2E = process.env.RUN_E2E === "1";
|
|
const d = RUN_E2E ? describe : describe.skip;
|
|
|
|
const DEPLOYER_PK = "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d";
|
|
|
|
async function waitForRpc(url: string, timeoutMs = 30_000): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const r = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ jsonrpc: "2.0", method: "eth_chainId", params: [], id: 1 }),
|
|
});
|
|
if (r.ok) return;
|
|
} catch {
|
|
/* not ready yet */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
throw new Error(`RPC did not come up within ${timeoutMs}ms: ${url}`);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type NotaryChainModule = typeof import("../../src/services/notaryChain");
|
|
|
|
d("NotaryRegistry chain round-trip (E2E)", () => {
|
|
let ganacheProc: ChildProcess;
|
|
let port: number;
|
|
let rpcUrl: string;
|
|
let contractAddress: string;
|
|
let chain: NotaryChainModule;
|
|
|
|
beforeAll(async () => {
|
|
port = 18545 + Math.floor(Math.random() * 1000);
|
|
rpcUrl = `http://127.0.0.1:${port}`;
|
|
|
|
ganacheProc = spawn(
|
|
"node_modules/.bin/ganache",
|
|
[
|
|
"--port",
|
|
String(port),
|
|
"--chain.chainId",
|
|
"1337",
|
|
"--wallet.accounts",
|
|
`${DEPLOYER_PK},1000000000000000000000`,
|
|
"--logging.quiet",
|
|
],
|
|
{ stdio: "pipe", cwd: process.cwd() },
|
|
);
|
|
|
|
await waitForRpc(rpcUrl);
|
|
|
|
const provider = new JsonRpcProvider(rpcUrl);
|
|
const wallet = new Wallet(DEPLOYER_PK, provider);
|
|
const { abi, bytecode } = compileNotaryRegistry();
|
|
|
|
// OZ v5 Ownable requires `initialOwner` in the constructor.
|
|
const factory = new ContractFactory(abi, bytecode, wallet);
|
|
const deployer = await wallet.getAddress();
|
|
const contract = (await factory.deploy(deployer)) as unknown as Contract;
|
|
await contract.waitForDeployment();
|
|
contractAddress = await contract.getAddress();
|
|
|
|
// Wire the service under test to this chain. Import after the env
|
|
// is set so the service's lazy loader picks it up.
|
|
process.env.CHAIN_138_RPC_URL = rpcUrl;
|
|
process.env.CHAIN_138_CHAIN_ID = "1337";
|
|
process.env.NOTARY_REGISTRY_ADDRESS = contractAddress;
|
|
process.env.ORCHESTRATOR_PRIVATE_KEY = DEPLOYER_PK;
|
|
|
|
jest.resetModules();
|
|
chain = await import("../../src/services/notaryChain");
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
if (ganacheProc && !ganacheProc.killed) {
|
|
ganacheProc.kill("SIGTERM");
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
delete process.env.CHAIN_138_RPC_URL;
|
|
delete process.env.CHAIN_138_CHAIN_ID;
|
|
delete process.env.NOTARY_REGISTRY_ADDRESS;
|
|
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
|
});
|
|
|
|
it("anchorPlan writes a PlanRegistered record on-chain", async () => {
|
|
const plan = {
|
|
plan_id: "e2e-plan-" + Date.now(),
|
|
steps: [],
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
const expectedHash = chain.computePlanHash(plan as never);
|
|
const result = await chain.anchorPlan(plan as never);
|
|
|
|
expect(result.mode).toBe("chain");
|
|
expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
|
|
expect(result.blockNumber).toBeGreaterThan(0);
|
|
expect(result.planHash).toBe(expectedHash);
|
|
|
|
// Directly query the contract to prove the state transition landed.
|
|
const provider = new JsonRpcProvider(rpcUrl);
|
|
const { abi } = compileNotaryRegistry();
|
|
const readOnly = new Contract(contractAddress, abi, provider);
|
|
const stored = await readOnly.getFunction("plans")(chain.planIdToBytes32(plan.plan_id));
|
|
// plans(bytes32) → (planHash, creator, registeredAt, finalizedAt, success, receiptHash)
|
|
expect(stored[0]).toMatch(/^0x[0-9a-fA-F]{64}$/);
|
|
expect(Number(stored[2])).toBeGreaterThan(0); // registeredAt
|
|
expect(Number(stored[3])).toBe(0); // finalizedAt
|
|
}, 60_000);
|
|
|
|
it("finalizeAnchor writes a PlanFinalized record with a receipt hash", async () => {
|
|
const plan = {
|
|
plan_id: "e2e-finalize-" + Date.now(),
|
|
steps: [],
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
await chain.anchorPlan(plan as never);
|
|
const result = await chain.finalizeAnchor(plan.plan_id, true);
|
|
|
|
expect(result.mode).toBe("chain");
|
|
expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
|
|
expect(result.receiptHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
|
|
expect(result.blockNumber).toBeGreaterThan(0);
|
|
}, 60_000);
|
|
|
|
it("anchorPlan falls back to mock when envs are cleared", async () => {
|
|
const saved = {
|
|
rpc: process.env.CHAIN_138_RPC_URL,
|
|
addr: process.env.NOTARY_REGISTRY_ADDRESS,
|
|
pk: process.env.ORCHESTRATOR_PRIVATE_KEY,
|
|
};
|
|
delete process.env.CHAIN_138_RPC_URL;
|
|
delete process.env.NOTARY_REGISTRY_ADDRESS;
|
|
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
|
|
|
try {
|
|
jest.resetModules();
|
|
const mockOnly = await import("../../src/services/notaryChain");
|
|
const result = await mockOnly.anchorPlan({
|
|
plan_id: "mock-plan",
|
|
steps: [],
|
|
created_at: new Date().toISOString(),
|
|
} as never);
|
|
expect(result.mode).toBe("mock");
|
|
expect(result.txHash).toBeUndefined();
|
|
expect(result.planHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
|
|
} finally {
|
|
if (saved.rpc) process.env.CHAIN_138_RPC_URL = saved.rpc;
|
|
if (saved.addr) process.env.NOTARY_REGISTRY_ADDRESS = saved.addr;
|
|
if (saved.pk) process.env.ORCHESTRATOR_PRIVATE_KEY = saved.pk;
|
|
}
|
|
});
|
|
});
|