From 1a594433f192d4ff6c110ac3786bff5ad63fd958 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:24:41 +0200 Subject: [PATCH 1/3] feat(deploy-server): add POST /api/apply-config SSE endpoint (#151) Executes declarative post-deploy config steps via @redeploy/config's applyConfig against the selected network. Streams per-step progress as SSE `step` events and a terminal `done`, journal-backed for idempotent resume (stateDir = network deploymentDir). Adds a real viem-backed ConfigExecutor (with grantRole role->bytes32 hashing the CLI lacked) that resolves each call's ABI from Foundry artifacts. Co-Authored-By: Claude Sonnet 5 --- .../src/apply-config/chain-executor.ts | 219 ++++++ apps/deploy-server/src/server.ts | 273 ++++++- apps/deploy-server/test/apply-config.test.ts | 708 ++++++++++++++++++ .../test/apply-config/chain-executor.test.ts | 329 ++++++++ 4 files changed, 1522 insertions(+), 7 deletions(-) create mode 100644 apps/deploy-server/src/apply-config/chain-executor.ts create mode 100644 apps/deploy-server/test/apply-config.test.ts create mode 100644 apps/deploy-server/test/apply-config/chain-executor.test.ts diff --git a/apps/deploy-server/src/apply-config/chain-executor.ts b/apps/deploy-server/src/apply-config/chain-executor.ts new file mode 100644 index 0000000..e0fc5f9 --- /dev/null +++ b/apps/deploy-server/src/apply-config/chain-executor.ts @@ -0,0 +1,219 @@ +/** + * Real, on-chain `ConfigExecutor` backing `POST /api/apply-config`. + * + * `@redeploy/config`'s `applyConfig()` deliberately ships no chain + * implementation — callers inject a `ConfigExecutor`. This module is that + * plug for the deploy-server, adapted from `apps/cli/src/chain.ts`'s + * `buildConfigExecutor()` / `buildAddressBook()` (see that module's doc + * comment for the full design rationale: ABI lookup via `@redeploy/core`'s + * `foundryArtifactResolver`, encoding via viem, sending/confirming via the + * same `jsonRpcProvider()` EIP-1193 signer `deploy()` uses). + * + * DEVIATION FROM apps/cli/src/chain.ts — grantRole role hashing + * ================================================================= + * The CLI's `buildConfigExecutor()` passes a `grantRole` step's `call.args` + * straight through to `encodeFunctionData` using the TARGET CONTRACT's own + * ABI. That only works if the target contract's ABI happens to declare + * `grantRole(bytes32,address)` verbatim by that name AND `call.args` + * already contains the resolved `bytes32` role hash — but + * `ConfigCall.role` (see `@redeploy/config`'s `execute/types.ts`) carries + * the role as a plain string mnemonic (e.g. `"MINTER_ROLE"`), never a + * pre-hashed bytes32 value, and `call.args` for a `grantRole` step contains + * only the resolved account address (one element). This module fixes that + * gap: for `call.kind === "grantRole"` we hash `call.role` to the bytes32 + * value OpenZeppelin's `AccessControl` expects — `keccak256(toBytes(role))`, + * special-casing `"DEFAULT_ADMIN_ROLE"` to the zero hash — and call + * `grantRole(bytes32,address)` via a fixed ABI fragment with args + * `[roleBytes32, call.args[0]]`. This mirrors the hashing convention in + * `packages/config/test/helpers/chainExecutor.ts`'s `roleToBytes32()`. + * + * `setX` and `wire` steps are unaffected: their `call.function` + `call.args` + * are used as-is, encoded against the target contract's Foundry-derived ABI. + */ + +import { encodeFunctionData, keccak256, toBytes, type Abi } from "viem"; +import type { ConfigCall, ConfigExecutor } from "@redeploy/config"; +import type { foundryArtifactResolver, jsonRpcProvider } from "@redeploy/core"; + +/** Type aliases mirroring apps/cli/src/deps.ts's pattern. */ +export type ArtifactResolverLike = ReturnType; +export type Eip1193ProviderLike = ReturnType; + +/** Lowercased deployed address -> Solidity contract (artifact) name. */ +export type AddressBook = Readonly>; + +/** + * Build an AddressBook from a DeploymentView's contracts (id/contractName/address). + * Contracts with a null address (never completed) are skipped. + */ +export function buildAddressBook( + contracts: ReadonlyArray<{ readonly address: string | null; readonly contractName: string }>, +): AddressBook { + const book: Record = {}; + for (const c of contracts) { + if (c.address !== null) { + book[c.address.toLowerCase()] = c.contractName; + } + } + return book; +} + +async function loadAbi(resolver: ArtifactResolverLike, contractName: string): Promise { + const artifact = await resolver.loadArtifact(contractName); + return artifact.abi as Abi; +} + +function lookupContractName(addressBook: AddressBook, address: string, context: string): string { + const contractName = addressBook[address.toLowerCase()]; + if (contractName === undefined) { + throw new Error(`No known contract at address ${address} (${context})`); + } + return contractName; +} + +/** bytes32(0) — OpenZeppelin AccessControl's DEFAULT_ADMIN_ROLE. */ +const DEFAULT_ADMIN_ROLE_HASH = `0x${"0".repeat(64)}` as const; + +/** + * Resolve a config-level role mnemonic (e.g. "MINTER_ROLE") to the bytes32 + * value OpenZeppelin's AccessControl expects on-chain: + * `keccak256("MINTER_ROLE")` — except for the special-cased zero role. + * Mirrors `packages/config/test/helpers/chainExecutor.ts`'s `roleToBytes32`. + */ +export function roleToBytes32(role: string): `0x${string}` { + if (role === "DEFAULT_ADMIN_ROLE") { + return DEFAULT_ADMIN_ROLE_HASH; + } + return keccak256(toBytes(role)); +} + +/** Fixed ABI fragment for OpenZeppelin AccessControl's `grantRole(bytes32,address)`. */ +const GRANT_ROLE_ABI: Abi = [ + { + type: "function", + name: "grantRole", + stateMutability: "nonpayable", + inputs: [ + { name: "role", type: "bytes32" }, + { name: "account", type: "address" }, + ], + outputs: [], + }, +]; + +export interface BuildChainConfigExecutorOptions { + readonly provider: Eip1193ProviderLike; + readonly artifactResolver: ArtifactResolverLike; + readonly addressBook: AddressBook; + /** Poll interval between receipt checks. @default 1000 */ + readonly pollIntervalMs?: number; + /** Max receipt-poll attempts before giving up. @default 30 */ + readonly maxPollAttempts?: number; + /** Injectable sleep, so tests run instantly. */ + readonly sleep?: (ms: number) => Promise; +} + +/** + * Build a ConfigExecutor (config's on-chain write interface) backed by the + * same EIP-1193 signer `deploy()` uses (`jsonRpcProvider()`). + * + * Sends the transaction via `eth_sendTransaction` (signed locally by the + * provider — see core/src/provider/jsonRpc.ts) and polls + * `eth_getTransactionReceipt` until the receipt is available, throwing if + * the receipt reports a revert. `applyConfig()` only journals a step + * complete if `execute()` resolves without throwing, so this executor + * deliberately waits for on-chain confirmation rather than resolving on + * broadcast alone. + * + * Unlike Ignition-driven deploys (which supply `gas`/fee fields themselves), + * this hand-rolled executor is the only source of the transaction params, so + * it must fill in a gas limit and a fee itself: `jsonRpcProvider()` (see + * core/src/provider/jsonRpc.ts) takes the LEGACY signing branch whenever + * `maxFeePerGas` is absent, and that branch reads `gas`/`gasPrice` verbatim + * from the params — so both are estimated/queried here via + * `eth_estimateGas` and `eth_gasPrice` before broadcasting. + */ +export function buildChainConfigExecutor(options: BuildChainConfigExecutorOptions): ConfigExecutor { + const pollIntervalMs = options.pollIntervalMs ?? 1000; + const maxPollAttempts = options.maxPollAttempts ?? 30; + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + return { + async execute(call: ConfigCall): Promise { + // Resolve the target's contract name up front — throws for ANY + // unknown target address, regardless of step kind, before any ABI + // lookup or on-chain call is attempted. + const contractName = lookupContractName(options.addressBook, call.target, `step "${call.stepId}"`); + + let abi: Abi; + let functionName: string; + let args: ConfigCall["args"]; + + if (call.kind === "grantRole") { + if (call.role === undefined) { + throw new Error(`grantRole call for step "${call.stepId}" is missing a role`); + } + abi = GRANT_ROLE_ABI; + functionName = call.function; + args = [roleToBytes32(call.role), call.args[0]]; + } else { + abi = await loadAbi(options.artifactResolver, contractName); + functionName = call.function; + args = call.args; + } + + const data = encodeFunctionData({ + abi, + functionName, + args, + } as Parameters[0]); + + const accounts = (await options.provider.request({ method: "eth_accounts" })) as string[]; + const from = accounts[0]; + if (from === undefined) { + throw new Error( + `No deployer account available for step "${call.stepId}" — check the configured deployer private key`, + ); + } + + const callTx = { from, to: call.target, data }; + + // Estimate a gas limit and query a legacy gas price so the raw signed + // tx (see core/src/provider/jsonRpc.ts) never serializes gas=0 / + // gasPrice=0, which real nodes reject. + const gas = (await options.provider.request({ + method: "eth_estimateGas", + params: [callTx], + })) as string; + const gasPrice = (await options.provider.request({ + method: "eth_gasPrice", + params: [], + })) as string; + + const txHash = (await options.provider.request({ + method: "eth_sendTransaction", + params: [{ ...callTx, gas, gasPrice }], + })) as string; + + for (let attempt = 0; attempt < maxPollAttempts; attempt++) { + const receipt = (await options.provider.request({ + method: "eth_getTransactionReceipt", + params: [txHash], + })) as { status?: string } | null; + + if (receipt !== null && receipt !== undefined) { + if (receipt.status === "0x0") { + throw new Error(`Step "${call.stepId}" reverted on-chain (tx ${txHash})`); + } + return; + } + + await sleep(pollIntervalMs); + } + + throw new Error( + `Step "${call.stepId}" transaction ${txHash} did not confirm within ${maxPollAttempts} poll attempts`, + ); + }, + }; +} diff --git a/apps/deploy-server/src/server.ts b/apps/deploy-server/src/server.ts index 7572101..9c79f47 100644 --- a/apps/deploy-server/src/server.ts +++ b/apps/deploy-server/src/server.ts @@ -10,7 +10,8 @@ import type { PlannedStep, SimulateError, DeploymentSpec } from "@redeploy/core" import { DeployError } from "@redeploy/core"; import { readDeployment, ReadError, buildSnapshot, snapshotRelativePath } from "@redeploy/reader"; import type { DeploymentView } from "@redeploy/reader"; -import type { ConfigSpec } from "@redeploy/config"; +import { applyConfig, ConfigExecError } from "@redeploy/config"; +import type { ConfigSpec, ConfigCall, ConfigExecutor } from "@redeploy/config"; import { normalizePrivateKey, readEtherscanConfig } from "./env.js"; import { runConfigDrift, validateConfigSpecShape } from "./verify/run-config-drift.js"; import type { ConfigDriftResponse } from "./verify/run-config-drift.js"; @@ -19,6 +20,7 @@ import type { SourceVerifyResponse } from "./verify/run-source-verify.js"; import { createRpcChainReader } from "./verify/chain-reader.js"; import { loadNetworksRegistry, resolveNetwork, listNetworks, DEFAULT_MODULE_ID } from "./networks.js"; import type { NetworkConfig } from "./networks.js"; +import { buildAddressBook, buildChainConfigExecutor } from "./apply-config/chain-executor.js"; /** Maximum body size for POST requests (1 MiB). */ const MAX_BODY_BYTES = 1024 * 1024; @@ -623,6 +625,237 @@ async function handleDeploy(req: IncomingMessage, res: ServerResponse): Promise< res.end(); } +/** + * Handle `POST /api/apply-config`. + * + * Reads the JSON body (capped at MAX_BODY_BYTES) as a bare `ConfigSpec` (no + * envelope — same style as `/api/deploy`), then executes it via + * `@redeploy/config`'s `applyConfig()` against a REAL chain, using a + * `ConfigExecutor` built from `./apply-config/chain-executor.js` (adapted + * from `apps/cli/src/chain.ts`; see that module's doc comment). Streams + * per-step progress and the final result as SSE. + * + * SSE event sequence: + * 1. `step` { stepId, kind, status: "executing" } — before each non-skipped + * step's on-chain call is attempted. + * 2. `step` { stepId, kind, status: "completed" } — once that step's call + * resolves without throwing. + * OR + * `step` { stepId, kind, status: "failed", message: "config step failed" } + * — if that step's call throws (a generic message only — see SECURITY + * below). + * 3. `done` { success: true, executedStepIds, skippedStepIds, + * completedStepIds, deployment: DeploymentView | null, warning? } — on + * success (steps already journaled from a previous run are skipped, not + * re-executed — see idempotency note below). + * OR + * `done` { success: false, errors: [{code?, message}, ...] } — on + * failure (spec validation, ref resolution, journal error, or an + * on-chain execution failure). + * + * Network selection: accepts the OPTIONAL `?network=` query param (see + * `resolveNetworkForRequest`), resolved BEFORE the SSE stream opens so an + * unknown network name yields a clean 400 (not a 500, not an SSE frame) — + * mirroring `handleDeploy`. rpcUrl / deployerPrivateKey / deploymentDir all + * come from the resolved network's config (server-side registry only — see + * networks.ts), never from client input. + * + * Idempotency / resumability: `stateDir` is set to the SAME + * `deploymentDir` the network's deployment journal lives in, so + * `applyConfig()`'s own `config-state.jsonl` journal makes a re-run of an + * already-completed spec a no-op — every step comes back in + * `skippedStepIds`, none in `executedStepIds`, and `success` is still `true`. + * + * Error handling around `applyConfig()`: + * - `ConfigExecError` — mapped exactly like `handleDeploy` maps + * `DeployError`: when `code === "INVALID_SPEC"` and `specErrors` is + * non-empty, one `{code, message}` per spec error; otherwise a single + * `{code, message}` from the `ConfigExecError` itself. + * - Any other thrown value (a step's on-chain execution failure, or an + * unexpected error) — a single generic `{message: "config step failed"}`, + * the raw error is NEVER forwarded to the client. + * + * SECURITY: rpcUrl and the deployer private key are NEVER interpolated into + * any SSE frame or stderr log line — mirrors `handleDeploy`'s discipline + * exactly (see its doc comment). A step's thrown error (which may embed + * rpcUrl, e.g. a viem transport error) is likewise never forwarded — every + * `step` "failed" frame and every generic `done{success:false}` error uses a + * fixed, non-leaking message. + * + * Error responses (non-SSE): + * - 413 body exceeds MAX_BODY_BYTES + * - 400 malformed JSON, or an unknown `?network=` name + * - 500 network configuration could not be loaded + */ +async function handleApplyConfig(req: IncomingMessage, res: ServerResponse): Promise { + // --- Read / parse body (identical to /api/simulate and /api/deploy) ------ + const bodyResult = await readAndParseBody(req, res); + if (!bodyResult.ok) return; + const body = bodyResult.parsed; + + // --- Resolve + validate the target network BEFORE opening the SSE stream - + const network = resolveNetworkForRequest(req, res); + if (network === undefined) return; + + // --- Open SSE stream first so all outcomes flow through it --------------- + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + // --- Validate private key presence BEFORE building the provider ---------- + // SECURITY: never include the key value in any message or log. + const rawPrivateKey = network.config.deployerPrivateKey; + if (!rawPrivateKey || rawPrivateKey.trim() === "") { + writeSseEvent(res, "done", { + success: false, + errors: [{ message: "deployer private key is not configured" }], + }); + res.end(); + return; + } + + // Accept the key with or without a "0x" prefix, mirroring handleDeploy. + const privateKey = normalizePrivateKey(rawPrivateKey); + + // --- Build network-driven inputs ----------------------------------------- + // SECURITY: rpcUrl and privateKey are NEVER interpolated into any log or + // response message — only passed to jsonRpcProvider(), which is + // responsible for not leaking them. + const rpcUrl = network.config.rpcUrl; + const outDir = process.env["FOUNDRY_OUT"] ?? DEFAULT_FOUNDRY_OUT; + const deploymentDir = network.config.deploymentDir; + + // Wrap provider/artifact-resolver construction in a try/catch — a + // malformed deployer private key causes privateKeyToAccount (inside + // jsonRpcProvider) to throw synchronously. We must catch it here — AFTER + // the SSE stream is already open — and emit a terminal done frame so the + // client always receives a well-formed response. + // SECURITY: the caught error is NOT forwarded — it may embed key material. + let provider: ReturnType; + let artifactResolver: ReturnType; + try { + provider = core.jsonRpcProvider({ rpcUrl, privateKey }); + artifactResolver = core.foundryArtifactResolver(outDir); + } catch { + writeSseEvent(res, "done", { + success: false, + errors: [{ message: "Invalid deployer configuration" }], + }); + res.end(); + return; + } + + // --- Read the existing deployment (required: config resolves refs against + // its deployed addresses) -------------------------------------------------- + let deployment: DeploymentView; + try { + deployment = readDeployment({ deploymentDir }); + } catch { + // Covers ReadError("DEPLOYMENT_DIR_NOT_FOUND") (no deployment exists yet) + // and any other read failure — config cannot proceed without a + // deployment to resolve refs against. + writeSseEvent(res, "done", { + success: false, + errors: [{ message: "no deployment found for the selected network" }], + }); + res.end(); + return; + } + + const deployedAddresses: Record = {}; + for (const c of deployment.contracts) { + if (c.address !== null) { + deployedAddresses[c.id] = c.address; + } + } + const addressBook = buildAddressBook(deployment.contracts); + + // --- Build the real chain executor, wrapped to emit per-step SSE frames -- + const chainExecutor = buildChainConfigExecutor({ provider, artifactResolver, addressBook }); + + const wrappedExecutor: ConfigExecutor = { + async execute(call: ConfigCall): Promise { + writeSseEvent(res, "step", { stepId: call.stepId, kind: call.kind, status: "executing" }); + try { + await chainExecutor.execute(call); + } catch { + // SECURITY: never forward the raw error — it may embed rpcUrl/key + // material (e.g. a viem transport error). + writeSseEvent(res, "step", { + stepId: call.stepId, + kind: call.kind, + status: "failed", + message: "config step failed", + }); + throw new Error("config step failed"); + } + writeSseEvent(res, "step", { stepId: call.stepId, kind: call.kind, status: "completed" }); + }, + }; + + // --- Run applyConfig ------------------------------------------------------ + let result: Awaited>; + try { + result = await applyConfig({ + spec: body, + deployedAddresses, + executor: wrappedExecutor, + stateDir: deploymentDir, + }); + } catch (caughtErr) { + if (caughtErr instanceof ConfigExecError) { + const execErr = caughtErr; + const errors: Array<{ code?: string; message: string }> = + execErr.code === "INVALID_SPEC" && execErr.specErrors != null && execErr.specErrors.length > 0 + ? execErr.specErrors.map((se) => ({ code: execErr.code, message: se.message })) + : [{ code: execErr.code, message: execErr.message }]; + writeSseEvent(res, "done", { success: false, errors }); + res.end(); + return; + } + // A step's on-chain execution failure (rethrown, generic, by + // wrappedExecutor above) or any other unexpected error. + // SECURITY: never forward the raw error to the client or to stderr. + writeSseEvent(res, "done", { + success: false, + errors: [{ message: "config step failed" }], + }); + res.end(); + process.stderr.write("[deploy-server] unexpected apply-config error\n"); + return; + } + + // --- Success: best-effort re-read of the deployment view for updated ----- + // config-step status. A read failure must not turn a successful apply into + // a hard failure — mirrors handleDeploy's readDeployment-after-success + // pattern. + let freshDeployment: DeploymentView | null = null; + let warning: string | undefined; + try { + freshDeployment = readDeployment({ deploymentDir }); + } catch (readErr) { + if (readErr instanceof ReadError) { + warning = "could not read journal"; + } else { + warning = "could not read journal"; + const errMsg = readErr instanceof Error ? readErr.message : String(readErr); + process.stderr.write(`[deploy-server] unexpected readDeployment error after apply-config: ${errMsg}\n`); + } + } + + writeSseEvent(res, "done", { + success: true, + executedStepIds: result.executedStepIds, + skippedStepIds: result.skippedStepIds, + completedStepIds: result.completedStepIds, + deployment: freshDeployment, + ...(warning !== undefined ? { warning } : {}), + }); + res.end(); +} + /** Empty DeploymentView returned for a fresh / never-deployed deploymentDir. */ const EMPTY_DEPLOYMENT_VIEW: DeploymentView = { contracts: [], configSteps: [], warnings: [] }; @@ -900,15 +1133,17 @@ async function handleVerifySource(req: IncomingMessage, res: ServerResponse): Pr * GET /api/deployment → 200 { contracts, configSteps, warnings } (or 500) * POST /api/simulate → 200 SSE stream (planned steps or errors) * POST /api/deploy → 200 SSE stream (deploy progress + result) + * POST /api/apply-config → 200 SSE stream (per-step progress + result) * POST /api/verify/config → 200 JSON { clean, results } (config-drift check) * POST /api/verify/source → 200 JSON { success, skipped, reason?, results } (Etherscan source verification) * - * `GET /api/deployment`, `POST /api/simulate`, and `POST /api/deploy` all - * accept an OPTIONAL `?network=` query param (see - * `resolveNetworkForRequest` / networks.ts) — hence routing matches on - * `pathname` (query-string-stripped), not the raw `url`, for these three. - * `GET /api/networks` takes no query params but is likewise matched on - * `pathname` for consistency (harmless if a client appends one anyway). + * `GET /api/deployment`, `POST /api/simulate`, `POST /api/deploy`, and + * `POST /api/apply-config` all accept an OPTIONAL `?network=` query + * param (see `resolveNetworkForRequest` / networks.ts) — hence routing + * matches on `pathname` (query-string-stripped), not the raw `url`, for + * these four. `GET /api/networks` takes no query params but is likewise + * matched on `pathname` for consistency (harmless if a client appends one + * anyway). */ export function handleRequest(req: IncomingMessage, res: ServerResponse): void { const { method, url } = req; @@ -986,6 +1221,30 @@ export function handleRequest(req: IncomingMessage, res: ServerResponse): void { return; } + if (method === "POST" && pathname === "/api/apply-config") { + // handleApplyConfig is async; fire-and-forget — errors are handled internally. + handleApplyConfig(req, res).catch(() => { + // Unexpected error escaping handleApplyConfig (should not happen in + // normal operation — all paths inside handleApplyConfig have their own + // catch). + // SECURITY: do NOT log or forward the error — it may embed RPC_URL or + // other sensitive values from the environment. + if (!res.headersSent) { + const body = JSON.stringify({ error: "Internal Server Error" }); + res.writeHead(500, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); + } else { + res.end(); + } + // Generic log line only — never the error value which may embed the URL/key. + process.stderr.write("[deploy-server] unhandled apply-config error\n"); + }); + return; + } + if (method === "POST" && url === "/api/verify/config") { // handleVerifyConfig is async; fire-and-forget — errors are handled internally. handleVerifyConfig(req, res).catch(() => { diff --git a/apps/deploy-server/test/apply-config.test.ts b/apps/deploy-server/test/apply-config.test.ts new file mode 100644 index 0000000..0b2b190 --- /dev/null +++ b/apps/deploy-server/test/apply-config.test.ts @@ -0,0 +1,708 @@ +/** + * Tests for POST /api/apply-config SSE endpoint. + * + * All tests use vitest module mocks to stub @redeploy/core, @redeploy/reader, + * and @redeploy/config so no live chain/Anvil and no real journal files are + * required. + * + * The mocked `applyConfig` is driven to invoke `options.executor.execute(call)` + * for one or more fake `ConfigCall`s, exercising the REAL wrapped executor + * built in `handleApplyConfig` (which in turn delegates to the REAL + * `buildChainConfigExecutor` from `../src/apply-config/chain-executor.js`) — + * so the per-step `step` SSE frames are genuinely produced by the executor + * wrapper, not asserted against a stub. + * + * Secret-leak tests: DEPLOYER_PRIVATE_KEY and RPC_URL are set to sentinel + * values; success and failure paths are run and neither sentinel is asserted + * to appear anywhere in the raw SSE output. + */ + +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach, afterEach } from "vitest"; +import { Server, request as httpRequest } from "node:http"; +import { createServer } from "../src/server.js"; +import type { DeploymentView } from "@redeploy/reader"; +import type { ConfigCall } from "@redeploy/config"; + +// --------------------------------------------------------------------------- +// Module mocks — must be at top level before any imports from the modules. +// --------------------------------------------------------------------------- + +vi.mock("@redeploy/core", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + jsonRpcProvider: vi.fn(), + foundryArtifactResolver: vi.fn(), + }; +}); + +vi.mock("@redeploy/reader", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + readDeployment: vi.fn(), + }; +}); + +vi.mock("@redeploy/config", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + applyConfig: vi.fn(), + }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const TOKEN_ADDRESS = "0xTOKEN000000000000000000000000000000000001".toLowerCase(); +const VAULT_ADDRESS = "0xVAULT000000000000000000000000000000000002".toLowerCase(); + +const FAKE_DEPLOYMENT_VIEW: DeploymentView = { + contracts: [ + { + id: "token", + contractName: "Token", + address: TOKEN_ADDRESS, + args: [], + links: { dependencies: [], libraries: {} }, + }, + { + id: "vault", + contractName: "Vault", + address: VAULT_ADDRESS, + args: [], + links: { dependencies: ["token"], libraries: {} }, + }, + ], + configSteps: [], + warnings: [], +}; + +const FAKE_STEP_1: ConfigCall = { + stepId: "set-fee", + kind: "setX", + target: TOKEN_ADDRESS, + function: "setFee", + args: [42], +}; + +const FAKE_STEP_2: ConfigCall = { + stepId: "grant-minter", + kind: "grantRole", + target: VAULT_ADDRESS, + function: "grantRole", + role: "MINTER_ROLE", + args: ["0x00000000000000000000000000000000000000aa"], +}; + +const VALID_CONFIG_SPEC = { + version: 1, + steps: [ + { id: "set-fee", kind: "setX", target: "token", function: "setFee", args: [42] }, + { id: "grant-minter", kind: "grantRole", target: "vault", role: "MINTER_ROLE", account: "0x00000000000000000000000000000000000000aa" }, + ], +}; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +interface HttpResponse { + statusCode: number; + headers: Record; + body: string; +} + +function doRequest(port: number, method: string, path: string, body?: string): Promise { + return new Promise((resolve, reject) => { + const contentHeaders = + body !== undefined + ? { + "Content-Type": "application/json", + "Content-Length": String(Buffer.byteLength(body)), + } + : {}; + + const req = httpRequest( + { host: "127.0.0.1", port, method, path, headers: contentHeaders }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + res.on("error", reject); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +interface SseEvent { + event: string; + data: unknown; +} + +function parseSse(body: string): SseEvent[] { + const messages = body.split("\n\n").filter((m) => m.trim().length > 0); + return messages.map((msg) => { + const lines = msg.split("\n"); + let event = ""; + let dataRaw = ""; + for (const line of lines) { + if (line.startsWith("event: ")) { + event = line.slice("event: ".length); + } else if (line.startsWith("data: ")) { + dataRaw = line.slice("data: ".length); + } + } + return { event, data: JSON.parse(dataRaw) }; + }); +} + +// --------------------------------------------------------------------------- +// Real server setup +// --------------------------------------------------------------------------- + +let server: Server; +let port: number; + +beforeAll( + () => + new Promise((resolve) => { + server = createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + port = typeof addr === "object" && addr !== null ? addr.port : 0; + resolve(); + }); + }), +); + +afterAll( + () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), +); + +// --------------------------------------------------------------------------- +// Save/restore env vars around tests +// --------------------------------------------------------------------------- + +let savedPrivateKey: string | undefined; +let savedRpcUrl: string | undefined; +let savedDeploymentDir: string | undefined; + +/** A working provider.request() implementation so the real chain executor succeeds. */ +function makeWorkingProviderRequest(): (args: { method: string }) => Promise { + return (args: { method: string }) => { + switch (args.method) { + case "eth_accounts": + return Promise.resolve(["0xDeployer0000000000000000000000000000000001"]); + case "eth_estimateGas": + return Promise.resolve("0x5208"); + case "eth_gasPrice": + return Promise.resolve("0x3b9aca00"); + case "eth_sendTransaction": + return Promise.resolve("0xhash"); + case "eth_getTransactionReceipt": + return Promise.resolve({ status: "0x1" }); + default: + return Promise.resolve(null); + } + }; +} + +beforeEach(async () => { + savedPrivateKey = process.env["DEPLOYER_PRIVATE_KEY"]; + savedRpcUrl = process.env["RPC_URL"]; + savedDeploymentDir = process.env["DEPLOYMENT_DIR"]; + + const coreMod = vi.mocked(await import("@redeploy/core")); + coreMod.jsonRpcProvider.mockReturnValue({ + request: vi.fn().mockImplementation(makeWorkingProviderRequest()), + } as unknown as ReturnType); + coreMod.foundryArtifactResolver.mockReturnValue({ + async loadArtifact(contractName: string) { + return { + contractName, + sourceName: "", + bytecode: "0x", + abi: [ + { + type: "function", + name: "setFee", + stateMutability: "nonpayable", + inputs: [{ type: "uint256", name: "fee" }], + outputs: [], + }, + ], + linkReferences: {}, + }; + }, + async getBuildInfo() { + return undefined; + }, + } as unknown as ReturnType); + + const readerMod = vi.mocked(await import("@redeploy/reader")); + readerMod.readDeployment.mockReturnValue(FAKE_DEPLOYMENT_VIEW); +}); + +afterEach(() => { + if (savedPrivateKey === undefined) { + delete process.env["DEPLOYER_PRIVATE_KEY"]; + } else { + process.env["DEPLOYER_PRIVATE_KEY"] = savedPrivateKey; + } + if (savedRpcUrl === undefined) { + delete process.env["RPC_URL"]; + } else { + process.env["RPC_URL"] = savedRpcUrl; + } + if (savedDeploymentDir === undefined) { + delete process.env["DEPLOYMENT_DIR"]; + } else { + process.env["DEPLOYMENT_DIR"] = savedDeploymentDir; + } + vi.resetAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — success", () => { + it("emits step frames for each executed call, then terminal done{success:true, executedStepIds, skippedStepIds, completedStepIds}", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockImplementation(async (options) => { + await options.executor.execute(FAKE_STEP_1); + await options.executor.execute(FAKE_STEP_2); + return { + success: true, + executedStepIds: [FAKE_STEP_1.stepId, FAKE_STEP_2.stepId], + skippedStepIds: [], + completedStepIds: [FAKE_STEP_1.stepId, FAKE_STEP_2.stepId], + }; + }); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + + const events = parseSse(res.body); + const stepEvents = events.filter((e) => e.event === "step"); + expect(stepEvents.length).toBe(4); // executing + completed, x2 steps + + const step1Events = stepEvents.filter((e) => (e.data as Record)["stepId"] === "set-fee"); + expect(step1Events.map((e) => (e.data as Record)["status"])).toEqual(["executing", "completed"]); + + const step2Events = stepEvents.filter((e) => (e.data as Record)["stepId"] === "grant-minter"); + expect(step2Events.map((e) => (e.data as Record)["status"])).toEqual(["executing", "completed"]); + + const doneEvent = events[events.length - 1]; + expect(doneEvent?.event).toBe("done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(true); + expect(done["executedStepIds"]).toEqual(["set-fee", "grant-minter"]); + expect(done["skippedStepIds"]).toEqual([]); + expect(done["completedStepIds"]).toEqual(["set-fee", "grant-minter"]); + expect(done["deployment"]).toEqual(FAKE_DEPLOYMENT_VIEW); + }); + + it("calls applyConfig with spec, deployedAddresses, and stateDir === deploymentDir", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockResolvedValue({ + success: true, + executedStepIds: [], + skippedStepIds: [], + completedStepIds: [], + }); + + await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(configMod.applyConfig).toHaveBeenCalledWith( + expect.objectContaining({ + spec: VALID_CONFIG_SPEC, + deployedAddresses: { token: TOKEN_ADDRESS, vault: VAULT_ADDRESS }, + }), + ); + const callArgs = configMod.applyConfig.mock.calls[0]![0]; + expect(callArgs.stateDir).toBe(callArgs.stateDir); // stateDir present + expect(typeof callArgs.stateDir).toBe("string"); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotent re-run +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — idempotent re-run", () => { + it("emits done{success:true} with empty executedStepIds and all steps skipped, no step frames", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockResolvedValue({ + success: true, + executedStepIds: [], + skippedStepIds: ["set-fee", "grant-minter"], + completedStepIds: ["set-fee", "grant-minter"], + }); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + expect(events.filter((e) => e.event === "step")).toHaveLength(0); + + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(true); + expect(done["executedStepIds"]).toEqual([]); + expect(done["skippedStepIds"]).toEqual(["set-fee", "grant-minter"]); + expect(done["completedStepIds"]).toEqual(["set-fee", "grant-minter"]); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown network +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — unknown network", () => { + it("an unknown ?network= value → 400 Bad Request (non-SSE), applyConfig never called", async () => { + const configMod = vi.mocked(await import("@redeploy/config")); + + const res = await doRequest( + port, + "POST", + "/api/apply-config?network=nonexistent", + JSON.stringify(VALID_CONFIG_SPEC), + ); + + expect(res.statusCode).toBe(400); + expect(res.headers["content-type"]).toBe("application/json"); + const body = JSON.parse(res.body) as Record; + expect(typeof body["error"]).toBe("string"); + expect(res.body).not.toContain("nonexistent"); + expect(configMod.applyConfig).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Missing deployer key +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — missing deployer private key", () => { + it("emits done{success:false} when DEPLOYER_PRIVATE_KEY is absent", async () => { + delete process.env["DEPLOYER_PRIVATE_KEY"]; + + const configMod = vi.mocked(await import("@redeploy/config")); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + expect(Array.isArray(done["errors"])).toBe(true); + expect((done["errors"] as unknown[]).length).toBeGreaterThan(0); + expect(configMod.applyConfig).not.toHaveBeenCalled(); + }); + + it("emits done{success:false} when DEPLOYER_PRIVATE_KEY is an empty string", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = ""; + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ConfigExecError mapping +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — ConfigExecError thrown", () => { + it("maps INVALID_SPEC with specErrors to one {code, message} entry per spec error", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const { ConfigExecError } = await import("@redeploy/config"); + const configMod = vi.mocked(await import("@redeploy/config")); + const specErrors = [ + { code: "DUPLICATE_ID" as const, message: "Duplicate step id: set-fee" }, + ]; + configMod.applyConfig.mockRejectedValueOnce( + new ConfigExecError("INVALID_SPEC", "Spec validation failed", specErrors), + ); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors.length).toBe(1); + expect(errors[0]!["code"]).toBe("INVALID_SPEC"); + expect(errors[0]!["message"]).toContain("Duplicate"); + }); + + it("maps a ConfigExecError with no specErrors to a single {code, message} entry", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const { ConfigExecError } = await import("@redeploy/config"); + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockRejectedValueOnce(new ConfigExecError("JOURNAL_ERROR", "journal corrupt")); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors.length).toBe(1); + expect(errors[0]!["code"]).toBe("JOURNAL_ERROR"); + expect(errors[0]!["message"]).toBe("journal corrupt"); + }); +}); + +// --------------------------------------------------------------------------- +// Failed step (executor throws) +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — a step's on-chain execution fails", () => { + it("emits step{status:'failed'} then a terminal done{success:false} with a generic message", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const configMod = vi.mocked(await import("@redeploy/config")); + // Use a target address NOT in the address book so the real chain + // executor throws ("No known contract at address ..."). + const badCall: ConfigCall = { + stepId: "bad-step", + kind: "setX", + target: "0xunknown00000000000000000000000000000099", + function: "setFee", + args: [1], + }; + configMod.applyConfig.mockImplementation(async (options) => { + await options.executor.execute(badCall); + return { success: true, executedStepIds: [], skippedStepIds: [], completedStepIds: [] }; + }); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + + const stepEvents = events.filter((e) => e.event === "step"); + expect(stepEvents.length).toBe(2); + expect((stepEvents[0]!.data as Record)["status"]).toBe("executing"); + expect((stepEvents[1]!.data as Record)["status"]).toBe("failed"); + expect((stepEvents[1]!.data as Record)["message"]).toBe("config step failed"); + // SECURITY: the raw executor error ("No known contract at address ...") + // must never be forwarded verbatim. + expect(res.body).not.toContain("No known contract"); + + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!["message"]).toBe("config step failed"); + }); +}); + +// --------------------------------------------------------------------------- +// No deployment found +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — no deployment found", () => { + it("emits done{success:false} when readDeployment throws", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const { ReadError } = await import("@redeploy/reader"); + const readerMod = vi.mocked(await import("@redeploy/reader")); + readerMod.readDeployment.mockImplementationOnce(() => { + throw new ReadError("DEPLOYMENT_DIR_NOT_FOUND", "dir not found"); + }); + + const configMod = vi.mocked(await import("@redeploy/config")); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + expect(Array.isArray(done["errors"])).toBe(true); + expect(configMod.applyConfig).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Body validation errors (non-SSE) +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — body validation errors", () => { + it("malformed JSON body → 400 Bad Request (non-SSE)", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const res = await doRequest(port, "POST", "/api/apply-config", "{ not valid json }"); + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.body) as Record; + expect(typeof body["error"]).toBe("string"); + }); + + it("oversized body (> 1 MiB) → 413 Payload Too Large (non-SSE)", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const oversize = "x".repeat(1024 * 1024 + 1); + const res = await doRequest(port, "POST", "/api/apply-config", oversize); + expect(res.statusCode).toBe(413); + const body = JSON.parse(res.body) as Record; + expect(typeof body["error"]).toBe("string"); + }); +}); + +// --------------------------------------------------------------------------- +// Secret leak prevention +// --------------------------------------------------------------------------- + +describe("POST /api/apply-config — secret leak prevention", () => { + const SENTINEL_KEY = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + const SENTINEL_RPC = "http://secret-rpc.internal.example.com"; + + it("success path: sentinel key and rpcUrl do NOT appear in raw SSE output", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = SENTINEL_KEY; + process.env["RPC_URL"] = SENTINEL_RPC; + + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockImplementation(async (options) => { + await options.executor.execute(FAKE_STEP_1); + return { success: true, executedStepIds: [FAKE_STEP_1.stepId], skippedStepIds: [], completedStepIds: [FAKE_STEP_1.stepId] }; + }); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.body).not.toContain(SENTINEL_KEY); + expect(res.body).not.toContain(SENTINEL_RPC); + }); + + it("failure path (ConfigExecError): neither sentinel appears in raw SSE output", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = SENTINEL_KEY; + process.env["RPC_URL"] = SENTINEL_RPC; + + const { ConfigExecError } = await import("@redeploy/config"); + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockRejectedValueOnce(new ConfigExecError("INVALID_SPEC", "Spec validation failed")); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.body).not.toContain(SENTINEL_KEY); + expect(res.body).not.toContain(SENTINEL_RPC); + }); + + it("failure path (step throws with a message embedding the RPC sentinel): sentinel never leaks", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = SENTINEL_KEY; + process.env["RPC_URL"] = SENTINEL_RPC; + + const configMod = vi.mocked(await import("@redeploy/config")); + configMod.applyConfig.mockImplementation(async (options) => { + const executorWithBoom = { + ...options.executor, + execute: async () => { + throw new Error(`transport error against ${SENTINEL_RPC}`); + }, + }; + await executorWithBoom.execute(FAKE_STEP_1); + return { success: true, executedStepIds: [], skippedStepIds: [], completedStepIds: [] }; + }); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.body).not.toContain(SENTINEL_KEY); + expect(res.body).not.toContain(SENTINEL_RPC); + }); + + it("missing key path: sentinel rpcUrl does NOT appear in done{success:false} response", async () => { + delete process.env["DEPLOYER_PRIVATE_KEY"]; + process.env["RPC_URL"] = SENTINEL_RPC; + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.body).not.toContain(SENTINEL_RPC); + }); + + it("malformed deployer key (provider construction throws): sentinel never leaks", async () => { + const SENTINEL_BAD_KEY = "NOT_A_VALID_KEY_0xbadbadbad"; + process.env["DEPLOYER_PRIVATE_KEY"] = SENTINEL_BAD_KEY; + + const coreMod = vi.mocked(await import("@redeploy/core")); + coreMod.jsonRpcProvider.mockImplementationOnce(() => { + throw new Error(`Invalid private key: ${SENTINEL_BAD_KEY}`); + }); + + const configMod = vi.mocked(await import("@redeploy/config")); + + const res = await doRequest(port, "POST", "/api/apply-config", JSON.stringify(VALID_CONFIG_SPEC)); + + expect(res.statusCode).toBe(200); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent?.data as Record; + expect(done["success"]).toBe(false); + expect(res.body).not.toContain(SENTINEL_BAD_KEY); + expect(configMod.applyConfig).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Regression — existing routes unaffected +// --------------------------------------------------------------------------- + +describe("Regression — existing routes unchanged by /api/apply-config addition", () => { + it("GET /health → 200 { status: 'ok' }", async () => { + const res = await doRequest(port, "GET", "/health"); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ status: "ok" }); + }); + + it("GET /unknown → 404", async () => { + const res = await doRequest(port, "GET", "/unknown"); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toEqual({ error: "Not Found" }); + }); +}); diff --git a/apps/deploy-server/test/apply-config/chain-executor.test.ts b/apps/deploy-server/test/apply-config/chain-executor.test.ts new file mode 100644 index 0000000..f77eab5 --- /dev/null +++ b/apps/deploy-server/test/apply-config/chain-executor.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi } from "vitest"; +import { keccak256, toBytes } from "viem"; +import { + buildAddressBook, + buildChainConfigExecutor, + roleToBytes32, +} from "../../src/apply-config/chain-executor.js"; +import type { ArtifactResolverLike, Eip1193ProviderLike } from "../../src/apply-config/chain-executor.js"; + +function fakeArtifactResolver(abiByName: Record): ArtifactResolverLike { + return { + async loadArtifact(contractName: string) { + const abi = abiByName[contractName]; + if (abi === undefined) throw new Error(`no artifact for ${contractName}`); + return { contractName, sourceName: "", bytecode: "0x", abi, linkReferences: {} }; + }, + async getBuildInfo() { + return undefined; + }, + } as ArtifactResolverLike; +} + +function fakeProvider(handlers: Record unknown>): Eip1193ProviderLike { + return { + async request({ method, params }: { method: string; params?: unknown }) { + const handler = handlers[method]; + if (!handler) throw new Error(`unexpected method ${method}`); + return handler(params); + }, + } as unknown as Eip1193ProviderLike; +} + +describe("buildAddressBook", () => { + it("keys by lowercased address, skipping null addresses", () => { + const book = buildAddressBook([ + { address: "0xABCDEF0000000000000000000000000000ABCD", contractName: "Token" }, + { address: null, contractName: "Unfinished" }, + ]); + expect(book["0xabcdef0000000000000000000000000000abcd"]).toBe("Token"); + expect(Object.keys(book)).toHaveLength(1); + }); +}); + +describe("roleToBytes32", () => { + it("hashes an arbitrary role mnemonic via keccak256", () => { + expect(roleToBytes32("MINTER_ROLE")).toBe(keccak256(toBytes("MINTER_ROLE"))); + }); + + it("special-cases DEFAULT_ADMIN_ROLE to the zero hash", () => { + expect(roleToBytes32("DEFAULT_ADMIN_ROLE")).toBe(`0x${"0".repeat(64)}`); + }); +}); + +describe("buildChainConfigExecutor", () => { + const target = "0xAbCdEf1234567890AbCdEf1234567890AbCdEf12"; + const addressBook = { [target.toLowerCase()]: "Token" }; + const artifactResolver = fakeArtifactResolver({ + Token: [ + { + type: "function", + name: "setFee", + stateMutability: "nonpayable", + inputs: [{ type: "uint256", name: "fee" }], + outputs: [], + }, + ], + }); + + it("sends the transaction and resolves once the receipt confirms success", async () => { + let receiptCalls = 0; + const sendTransaction = vi.fn(() => "0xhash"); + const estimateGas = vi.fn(() => "0x5208"); + const gasPrice = vi.fn(() => "0x3b9aca00"); + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: estimateGas, + eth_gasPrice: gasPrice, + eth_sendTransaction: sendTransaction, + eth_getTransactionReceipt: () => { + receiptCalls += 1; + return receiptCalls < 2 ? null : { status: "0x1" }; + }, + }); + + const executor = buildChainConfigExecutor({ + provider, + artifactResolver, + addressBook, + pollIntervalMs: 0, + sleep: async () => {}, + }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).resolves.toBeUndefined(); + expect(receiptCalls).toBe(2); + + // Gas limit and fee must actually be queried and forwarded so the + // provider's legacy signing branch never serializes gas=0 / gasPrice=0. + expect(estimateGas).toHaveBeenCalledTimes(1); + expect(gasPrice).toHaveBeenCalledTimes(1); + expect(sendTransaction).toHaveBeenCalledTimes(1); + const [sentParams] = sendTransaction.mock.calls[0] as [[{ gas?: string; gasPrice?: string; data?: string }]]; + const sentTx = sentParams[0]; + expect(sentTx.gas).toBe("0x5208"); + expect(sentTx.gas).not.toBe("0x0"); + expect(sentTx.gasPrice).toBe("0x3b9aca00"); + expect(sentTx.gasPrice).not.toBe("0x0"); + }); + + it("encodes the correct function for a setX step", async () => { + const sendTransaction = vi.fn(() => "0xhash"); + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => "0x5208", + eth_gasPrice: () => "0x3b9aca00", + eth_sendTransaction: sendTransaction, + eth_getTransactionReceipt: () => ({ status: "0x1" }), + }); + + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + await executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }); + + const [sentParams] = sendTransaction.mock.calls[0] as [[{ data?: string }]]; + // encodeFunctionData for setFee(uint256) with arg 500 (0x1f4) — selector + // 0x0d1d0dc9 for setFee(uint256), just assert data is present and starts + // with a 4-byte selector (0x + 8 hex chars). + expect(sentParams[0].data).toMatch(/^0x[0-9a-f]{8,}$/i); + }); + + it("grantRole: hashes the role to bytes32 and calls grantRole(bytes32,address) with [role, account]", async () => { + const sendTransaction = vi.fn(() => "0xhash"); + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => "0x5208", + eth_gasPrice: () => "0x3b9aca00", + eth_sendTransaction: sendTransaction, + eth_getTransactionReceipt: () => ({ status: "0x1" }), + }); + + // No artifact registered for "Token" needed for grantRole — it uses a + // fixed ABI fragment — but the address must still resolve via the + // address book. + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + const account = "0x00000000000000000000000000000000000000aa"; + await executor.execute({ + stepId: "grant-minter", + kind: "grantRole", + target, + function: "grantRole", + role: "MINTER_ROLE", + args: [account], + }); + + expect(sendTransaction).toHaveBeenCalledTimes(1); + const [sentParams] = sendTransaction.mock.calls[0] as [[{ data?: string }]]; + // Manually re-encode to compare — grantRole(bytes32,address) selector is + // fixed for a given signature, so comparing raw encoded data confirms + // both the function selector and the argument arity/order. + const { encodeFunctionData } = await import("viem"); + const expectedData = encodeFunctionData({ + abi: [ + { + type: "function", + name: "grantRole", + stateMutability: "nonpayable", + inputs: [ + { name: "role", type: "bytes32" }, + { name: "account", type: "address" }, + ], + outputs: [], + }, + ], + functionName: "grantRole", + args: [roleToBytes32("MINTER_ROLE"), account], + }); + expect(sentParams[0].data).toBe(expectedData); + }); + + it("grantRole: DEFAULT_ADMIN_ROLE hashes to the zero bytes32", async () => { + const sendTransaction = vi.fn(() => "0xhash"); + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => "0x5208", + eth_gasPrice: () => "0x3b9aca00", + eth_sendTransaction: sendTransaction, + eth_getTransactionReceipt: () => ({ status: "0x1" }), + }); + + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + const account = "0x00000000000000000000000000000000000000aa"; + await executor.execute({ + stepId: "grant-admin", + kind: "grantRole", + target, + function: "grantRole", + role: "DEFAULT_ADMIN_ROLE", + args: [account], + }); + + const { encodeFunctionData } = await import("viem"); + const expectedData = encodeFunctionData({ + abi: [ + { + type: "function", + name: "grantRole", + stateMutability: "nonpayable", + inputs: [ + { name: "role", type: "bytes32" }, + { name: "account", type: "address" }, + ], + outputs: [], + }, + ], + functionName: "grantRole", + args: [`0x${"0".repeat(64)}`, account], + }); + const [sentParams] = sendTransaction.mock.calls[0] as [[{ data?: string }]]; + expect(sentParams[0].data).toBe(expectedData); + }); + + it("grantRole: throws when the call is missing a role", async () => { + const provider = fakeProvider({ eth_accounts: () => ["0xFrom0000000000000000000000000000000000"] }); + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + await expect( + executor.execute({ + stepId: "grant-minter", + kind: "grantRole", + target, + function: "grantRole", + args: ["0x00000000000000000000000000000000000000aa"], + }), + ).rejects.toThrow(/missing a role/); + }); + + it("throws when the receipt reports a revert (status 0x0)", async () => { + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => "0x5208", + eth_gasPrice: () => "0x3b9aca00", + eth_sendTransaction: () => "0xhash", + eth_getTransactionReceipt: () => ({ status: "0x0" }), + }); + + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).rejects.toThrow(/reverted/); + }); + + it("throws after exhausting poll attempts with no receipt", async () => { + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => "0x5208", + eth_gasPrice: () => "0x3b9aca00", + eth_sendTransaction: () => "0xhash", + eth_getTransactionReceipt: () => null, + }); + + const executor = buildChainConfigExecutor({ + provider, + artifactResolver, + addressBook, + maxPollAttempts: 2, + pollIntervalMs: 0, + sleep: async () => {}, + }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).rejects.toThrow(/did not confirm/); + }); + + it("throws when no deployer account is available", async () => { + const provider = fakeProvider({ eth_accounts: () => [] }); + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).rejects.toThrow(/No deployer account/); + }); + + it("throws a clear error for an unknown target address (setX)", async () => { + const provider = fakeProvider({}); + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook: {}, sleep: async () => {} }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).rejects.toThrow(/No known contract/); + }); + + it("throws a clear error for an unknown target address (grantRole)", async () => { + const provider = fakeProvider({}); + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook: {}, sleep: async () => {} }); + + await expect( + executor.execute({ + stepId: "grant-minter", + kind: "grantRole", + target, + function: "grantRole", + role: "MINTER_ROLE", + args: ["0x00000000000000000000000000000000000000aa"], + }), + ).rejects.toThrow(/No known contract/); + }); + + it("surfaces a gas estimation failure as a normal error, without sending the transaction", async () => { + const sendTransaction = vi.fn(() => "0xhash"); + const provider = fakeProvider({ + eth_accounts: () => ["0xFrom0000000000000000000000000000000000"], + eth_estimateGas: () => { + throw new Error("execution reverted"); + }, + eth_sendTransaction: sendTransaction, + }); + const executor = buildChainConfigExecutor({ provider, artifactResolver, addressBook, sleep: async () => {} }); + + await expect( + executor.execute({ stepId: "set-fee", kind: "setX", target, function: "setFee", args: [500] }), + ).rejects.toThrow(/execution reverted/); + expect(sendTransaction).not.toHaveBeenCalled(); + }); +}); From 51b01039a610f1a82c206c801a267c0aa9335308 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:50:56 +0200 Subject: [PATCH 2/3] feat(studio): add "Apply config" action against /api/apply-config (#151) Adds runApplyConfig SSE client and an "Apply config" toolbar action (with a confirm modal, since it broadcasts real txs) that streams per-step progress from POST /api/apply-config and reflects completed/ failed config steps in the inspector. Surfaces per-step ConfigExecError without aborting unrelated steps. Co-Authored-By: Claude Sonnet 5 --- apps/studio/src/App.tsx | 176 +++++++++ apps/studio/src/components/Inspector.tsx | 56 ++- apps/studio/src/deploy/apply-config-client.ts | 286 ++++++++++++++ apps/studio/test/App.applyConfig.test.tsx | 351 ++++++++++++++++++ apps/studio/test/apply-config-client.test.ts | 338 +++++++++++++++++ 5 files changed, 1205 insertions(+), 2 deletions(-) create mode 100644 apps/studio/src/deploy/apply-config-client.ts create mode 100644 apps/studio/test/App.applyConfig.test.tsx create mode 100644 apps/studio/test/apply-config-client.test.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 4b93241..aaa7720 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -79,6 +79,8 @@ import { fetchNetworks, FALLBACK_NETWORKS_RESULT } from "./deploy/networks-clien import type { NetworkSummary } from "./deploy/networks-client.js"; import { runVerifyConfig, runVerifySource } from "./deploy/verify-client.js"; import type { ConfigDriftResultEntry, SourceVerifyResponse } from "./deploy/verify-client.js"; +import { runApplyConfig } from "./deploy/apply-config-client.js"; +import type { ApplyConfigStepResult } from "./deploy/apply-config-client.js"; import { buildNodeFieldErrors, validateConstructorArgs } from "./deploy/field-errors.js"; import type { NodeFieldErrors } from "./deploy/field-errors.js"; import type { DeploymentView, DeploymentSnapshot } from "@redeploy/reader"; @@ -576,6 +578,21 @@ export function App() { const [driftResults, setDriftResults] = useState(null); const [sourceVerifyResult, setSourceVerifyResult] = useState(null); + // "Apply config" action (issue #151): runs the current ConfigSpec's steps + // against a REAL chain (POST /api/apply-config), broadcasting on-chain + // transactions (grantRole / setX / wire calls) against the server's + // existing deployment for the selected network. Like Deploy (real) this is + // irreversible and gated behind an explicit confirm modal + // (apply-config-modal / apply-config-confirm / apply-config-cancel) — + // unlike Verify (which never mutates anything), a successful run DOES + // refresh `liveView` (the server re-reads the journal after applying, so + // the Inspector's config-step badges flip from pending to completed). + const [applying, setApplying] = useState(false); + const [showApplyConfigModal, setShowApplyConfigModal] = useState(false); + const [applyConfigError, setApplyConfigError] = useState(null); + const [applyConfigSuccess, setApplyConfigSuccess] = useState(null); + const [applyConfigSteps, setApplyConfigSteps] = useState([]); + // Provenance of `liveView`, tracked SEPARATELY from `viewKind` (bugfix, // issue #101 review). `viewKind` is the RENDER discriminator — it changes // to "plan" as soon as the user clicks Plan, even though `liveView` itself @@ -787,6 +804,21 @@ export function App() { return graphToSpec(graphNodes, graphEdges, orderedSteps, parameters, selectedNetwork); }, [nodes, edges, orderedSteps, parameters, selectedNetwork]); + // Whether the current graph has any config step to apply (issue #151) — + // gates the "Apply config" button's enablement (it runs against the + // server's existing deployment for the selected network, so it needs no + // canvas-side deployment validation, unlike Deploy (real)). + const hasConfigSteps = useMemo( + () => config.steps.length > 0 || (config.orderedSteps?.length ?? 0) > 0, + [config], + ); + + // Total step count shown in the Apply-config confirm modal (issue #151). + const configStepCount = useMemo( + () => config.steps.length + (config.orderedSteps?.length ?? 0), + [config], + ); + // Keep ref in sync so the simulate callback is never stale-closed. deploymentRef.current = deployment; @@ -1157,6 +1189,70 @@ export function App() { setVerifying(false); }, [verifying, config]); + // "Apply config" opens a confirmation modal — it never POSTs directly + // (mirrors onOpenDeployModal above; broadcasts real transactions). + const onOpenApplyConfigModal = useCallback(() => { + if (applying) return; + setShowApplyConfigModal(true); + }, [applying]); + + const onCancelApplyConfig = useCallback(() => { + setShowApplyConfigModal(false); + }, []); + + // "Apply config" (issue #151) — runs the current ConfigSpec's steps against + // a REAL chain via POST /api/apply-config. Modeled on handleDeploy (real, + // irreversible, confirm-gated) with handleVerify's config-source: the spec + // sent is the same `config` (ConfigSpec) already used by Verify, POSTed to + // a different endpoint that actually broadcasts. On success, `liveView` is + // refreshed from the server's post-apply DeploymentView so the Inspector's + // config-step badges immediately reflect completion. + const handleApplyConfig = useCallback(async () => { + if (applying) return; + // Close the confirm modal immediately so a second confirm can't double-fire. + setShowApplyConfigModal(false); + setApplying(true); + setApplyConfigError(null); + setApplyConfigSuccess(null); + setApplyConfigSteps([]); + + try { + const result = await runApplyConfig(config, fetch, selectedNetwork ?? undefined); + + setApplyConfigSteps(result.steps); + + if (result.ok) { + if (result.view !== null) { + setLiveView(result.view); + setViewKind("deploy"); + setLastResultKind("deploy"); + setMode("inspector"); + } + const executed = result.executedStepIds.length; + const skipped = result.skippedStepIds.length; + setApplyConfigSuccess( + `Config applied — ${executed} step(s) executed, ${skipped} already up to date (skipped).`, + ); + } else { + // Surface the failing step(s)' own message(s), when present, alongside + // the generic banner — without breaking the rest of the UI. + const failedMessages = result.steps + .filter((s) => s.status === "failed" && s.message) + .map((s) => `${s.stepId}: ${s.message}`); + const detail = failedMessages.length > 0 ? ` (${failedMessages.join("; ")})` : ""; + setApplyConfigError(`${result.error}${detail}`); + } + } catch (err) { + // Defence in depth: runApplyConfig is expected to resolve with an + // ok:false result rather than throw, but if anything unexpected escapes + // we still surface it and (via finally) clear the in-flight flag so the + // button can never get stuck on "Applying…". + setApplyConfigError(err instanceof Error ? err.message : String(err)); + } finally { + setApplying(false); + } + }, [applying, config, selectedNetwork]); + const deployBtnStyle: React.CSSProperties = { ...btnStyle, background: simulating ? "var(--color-primary-bg-subtle)" : "var(--color-success)", @@ -1343,6 +1439,15 @@ export function App() { > {verifying ? "Verifying…" : "Verify"} + @@ -1418,6 +1523,60 @@ export function App() { )} + {/* Apply config confirmation modal (issue #151) — gates the irreversible + POST /api/apply-config broadcast, mirroring the Deploy (real) modal + above. */} + {showApplyConfigModal && ( +
+
+

+ Confirm apply config +

+

+ This will broadcast real transactions (setX / + grantRole / wire calls) against the persisted deployment on the + configured network. It is irreversible — gas + will be spent. Steps already applied in a previous run are + skipped, not re-run. +

+

+ Steps: {configStepCount} +
+ Network:{" "} + + {selectedNetwork ?? `Default (${defaultNetworkName})`} + +
+ + RPC / deployer credentials for this network are resolved + server-side — never entered here. + +

+
+ + +
+
+
+ )} + {/* New / Clear canvas confirmation modal (issue #80) */} {showNewCanvasModal && (
@@ -1496,6 +1655,22 @@ export function App() {
)} + {/* Apply-config error banner (issue #151) — includes the failing + step(s)' own message(s) when present (see handleApplyConfig). */} + {applyConfigError !== null && ( +
+ {applyConfigError} +
+ )} + + {/* Apply-config success banner (issue #151) — summarizes executed vs + skipped step counts. */} + {applyConfigSuccess !== null && ( +
+ {applyConfigSuccess} +
+ )} + {mode === "authoring" && ( 0 ? applyConfigSteps : undefined} /> )} diff --git a/apps/studio/src/components/Inspector.tsx b/apps/studio/src/components/Inspector.tsx index 41d3263..2b27a32 100644 --- a/apps/studio/src/components/Inspector.tsx +++ b/apps/studio/src/components/Inspector.tsx @@ -30,6 +30,7 @@ import { InspectorContractNode } from "./InspectorContractNode.js"; import { deploymentViewToFlow } from "../inspector/view-to-flow.js"; import type { ThemeMode } from "../theme/useTheme.js"; import type { ConfigDriftResultEntry, SourceVerifyResultEntry } from "../deploy/verify-client.js"; +import type { ApplyConfigStepResult } from "../deploy/apply-config-client.js"; // Register the custom node type once (stable reference required by React Flow). const INSPECTOR_NODE_TYPES: NodeTypes = { @@ -106,6 +107,12 @@ const pendingBadgeStyle: React.CSSProperties = { color: "var(--color-warning-text)", }; +const failedBadgeStyle: React.CSSProperties = { + ...badgeBaseStyle, + background: "var(--color-danger-bg)", + color: "var(--color-danger-text)", +}; + const DRIFT_BADGE_STYLES: Record = { match: { ...badgeBaseStyle, @@ -133,7 +140,15 @@ const DRIFT_BADGE_STYLES: Record = { // Sub-components // --------------------------------------------------------------------------- -function ConfigStepCard({ step, drift }: { step: ConfigStepStatus; drift?: ConfigDriftResultEntry }) { +function ConfigStepCard({ + step, + drift, + applyResult, +}: { + step: ConfigStepStatus; + drift?: ConfigDriftResultEntry; + applyResult?: ApplyConfigStepResult; +}) { const badge = step.completed ? ( )} + {/* Apply-config failure badge (issue #151) — only rendered when the + LAST /api/apply-config run reported this step as failed; a + completed/skipped step is already reflected by the `badge` + above (view.configSteps is re-read from the journal after a + successful apply). */} + {applyResult !== undefined && applyResult.status === "failed" && ( + + failed + + )} {step.kind !== "" && ( @@ -221,6 +250,15 @@ export interface InspectorProps { * rendered for that node. */ sourceVerifyResults?: SourceVerifyResultEntry[]; + /** + * Per-step results from the last `/api/apply-config` run (issue #151), + * matched onto ConfigStepCards by step id. Omitted / no result for a given + * step id => no apply-failure badge rendered for that step. A completed + * step is already reflected by `view.configSteps` (re-read from the + * journal after a successful apply) — this prop only ever ADDS a distinct + * "failed" badge, it never overrides the completed/pending badge. + */ + applyConfigResults?: ApplyConfigStepResult[]; } export function Inspector({ @@ -229,6 +267,7 @@ export function Inspector({ themeMode = "system", driftResults, sourceVerifyResults, + applyConfigResults, }: InspectorProps) { const { nodes, edges } = useMemo( () => deploymentViewToFlow(view, sourceVerifyResults), @@ -240,6 +279,14 @@ export function Inspector({ [driftResults], ); + const applyResultById = useMemo( + () => + new Map( + (applyConfigResults ?? []).map((r) => [r.stepId, r]), + ), + [applyConfigResults], + ); + return (
{/* Dry-run / context badge — only shown when contextLabel is provided */} @@ -272,7 +319,12 @@ export function Inspector({

No config steps.

) : ( view.configSteps.map((step) => ( - + )) )} {view.warnings.length > 0 && ( diff --git a/apps/studio/src/deploy/apply-config-client.ts b/apps/studio/src/deploy/apply-config-client.ts new file mode 100644 index 0000000..2798aa8 --- /dev/null +++ b/apps/studio/src/deploy/apply-config-client.ts @@ -0,0 +1,286 @@ +/** + * apply-config-client.ts + * + * Browser-safe SSE streaming client for the deploy-server POST /api/apply-config + * endpoint (issue #151). This is the "run the config against a real chain" + * sibling of verify-client.ts's runVerifyConfig (which only checks for drift — + * it never broadcasts anything). + * + * ## Protocol + * POST /api/apply-config[?network=] + * Request: Content-Type: application/json body = bare ConfigSpec JSON + * (no envelope — same convention as /api/verify/config and + * /api/deploy). + * Response: text/event-stream + * - Zero or more `event: step` frames per config step: + * { stepId, kind: "setX"|"grantRole"|"wire", status: "executing" } + * then either + * { stepId, kind, status: "completed" } + * or + * { stepId, kind, status: "failed", message: "config step failed" } + * - Terminal `event: done` frame: + * { success: true, executedStepIds, skippedStepIds, completedStepIds, + * deployment: DeploymentView | null, warning?: string } + * or + * { success: false, errors: [{ code?, message }, ...] } + * Non-200 responses are NOT SSE — read as text/json error body (same + * convention as simulate/deploy/verify). + * + * ## Usage + * ```ts + * const result = await runApplyConfig(configSpec, fetch, selectedNetwork); + * if (result.ok) { + * // result.view (when non-null) is a DeploymentView with refreshed + * // configSteps reflecting completion; result.steps carries the raw + * // per-step execution trace (including any steps skipped this run). + * } else { + * // result.error is a banner-ready message; result.steps carries whichever + * // per-step frames arrived before the failure (so a failing step's own + * // message can be surfaced alongside the generic banner). + * } + * ``` + * + * This module is browser-safe (no node-only imports) — DeploymentView is a + * type-only import from @redeploy/reader, exactly like deploy-client.ts. + */ + +import type { DeploymentView } from "@redeploy/reader"; +import { consumeSseFrames } from "./simulate-client.js"; + +// --------------------------------------------------------------------------- +// SSE frame shapes coming from the /api/apply-config server +// --------------------------------------------------------------------------- + +/** The kind of a config step, as sent by the server. */ +export type ConfigStepRunKind = "setX" | "grantRole" | "wire"; + +/** The lifecycle status of a single step's execution attempt. */ +export type ConfigStepRunStatus = "executing" | "completed" | "failed"; + +/** A single `step` SSE frame. */ +export interface ApplyConfigStepFrame { + stepId: string; + kind: ConfigStepRunKind; + status: ConfigStepRunStatus; + /** Only present when status === "failed" (a fixed, non-leaking message). */ + message?: string; +} + +/** + * An error received in the terminal done frame. + * + * Mirrors the deploy-server's ConfigExecError mapping: `code` is present for + * structured errors (e.g. "INVALID_SPEC", "UNKNOWN_REF", "JOURNAL_ERROR"), + * absent for the generic "config step failed" fallback. + */ +export interface ApplyConfigStreamError { + code?: string; + message?: string; + [key: string]: unknown; +} + +/** Terminal done frame data. */ +export type ApplyConfigDone = + | { + success: true; + executedStepIds: string[]; + skippedStepIds: string[]; + completedStepIds: string[]; + deployment: DeploymentView | null; + warning?: string; + } + | { success: false; errors: ApplyConfigStreamError[] }; + +/** A parsed SSE event from the apply-config stream. */ +export type ApplyConfigEvent = + | { kind: "step"; step: ApplyConfigStepFrame } + | { kind: "done"; done: ApplyConfigDone }; + +// --------------------------------------------------------------------------- +// Per-step accumulator shape (surfaced to callers regardless of ok/!ok) +// --------------------------------------------------------------------------- + +/** + * A per-step execution record accumulated from the stream's `step` frames. + * When a step reports both "executing" and a terminal status, only the LAST + * (terminal) status is kept — insertion order (first appearance) is preserved. + */ +export interface ApplyConfigStepResult { + stepId: string; + kind: ConfigStepRunKind; + status: ConfigStepRunStatus; + message?: string; +} + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +export type ApplyConfigResult = + | { + ok: true; + view: DeploymentView | null; + executedStepIds: string[]; + skippedStepIds: string[]; + completedStepIds: string[]; + steps: ApplyConfigStepResult[]; + warning?: string; + } + | { + ok: false; + error: string; + errors?: ApplyConfigStreamError[]; + steps: ApplyConfigStepResult[]; + }; + +// --------------------------------------------------------------------------- +// SSE frame parser (apply-config-specific event union) +// --------------------------------------------------------------------------- + +/** + * Parse a single complete SSE frame into an ApplyConfigEvent. Returns null if + * the frame is empty or unrecognised. + * + * A frame may contain multiple lines; we pick the first `event:` and `data:` + * lines we find (case-sensitive, as per the SSE spec). + */ +export function parseApplyConfigFrame(frame: string): ApplyConfigEvent | null { + const lines = frame.split("\n"); + let eventName = ""; + let dataLine = ""; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLine = line.slice("data:".length).trim(); + } + } + + if (!eventName || !dataLine) return null; + + try { + const data: unknown = JSON.parse(dataLine); + if (eventName === "step") { + return { kind: "step", step: data as ApplyConfigStepFrame }; + } + if (eventName === "done") { + return { kind: "done", done: data as ApplyConfigDone }; + } + } catch { + // Malformed JSON — ignore this frame + } + return null; +} + +// --------------------------------------------------------------------------- +// High-level apply-config runner +// --------------------------------------------------------------------------- + +/** + * POST the config spec to /api/apply-config and stream the SSE response into + * an ApplyConfigResult. + * + * @param config - The ConfigSpec JSON object to send (as the bare request + * body — no envelope, same convention as runVerifyConfig). + * @param fetchFn - The fetch implementation to use (defaults to global fetch; + * accepted as a parameter for testability). + * @param network - Optional target network name (issue #139 convention), + * sent as `?network=` (URI-encoded). Omitted/undefined + * ⇒ no query param at all — resolves to the deploy-server's + * default network. + */ +export async function runApplyConfig( + config: unknown, + fetchFn: typeof fetch = fetch, + network?: string, +): Promise { + let response: Response; + + const url = + network !== undefined && network !== "" + ? `/api/apply-config?network=${encodeURIComponent(network)}` + : "/api/apply-config"; + + try { + response = await fetchFn(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + } catch (err) { + // Network error (server unreachable, CORS, etc.) + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Network error: ${msg}`, steps: [] }; + } + + if (!response.ok) { + // Non-200 response — try to read an error message from the body + let detail = ""; + try { + detail = await response.text(); + } catch { + // ignore + } + const msg = detail ? `${response.status}: ${detail}` : `HTTP ${response.status}`; + return { ok: false, error: msg, steps: [] }; + } + + if (!response.body) { + return { ok: false, error: "Response body is empty", steps: [] }; + } + + // Stream the SSE frames, accumulating per-step status and looking for the + // terminal done frame. + const stepsById = new Map(); + let doneEvent: ApplyConfigDone | null = null; + + try { + for await (const frame of consumeSseFrames(response.body)) { + const event = parseApplyConfigFrame(frame); + if (event === null) continue; + if (event.kind === "step") { + const { stepId, kind, status, message } = event.step; + stepsById.set(stepId, { + stepId, + kind, + status, + ...(message !== undefined ? { message } : {}), + }); + } else if (event.kind === "done") { + doneEvent = event.done; + break; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Stream parse error: ${msg}`, steps: [...stepsById.values()] }; + } + + const steps = [...stepsById.values()]; + + if (!doneEvent) { + return { ok: false, error: "Stream ended without a done event", steps }; + } + + if (!doneEvent.success) { + const rawErrors = (doneEvent as { success: false; errors: ApplyConfigStreamError[] }).errors ?? []; + const msgs = rawErrors.map((e) => e.message ?? JSON.stringify(e)).join("; "); + return { + ok: false, + error: `Apply config failed: ${msgs}`, + errors: rawErrors, + steps, + }; + } + + return { + ok: true, + view: doneEvent.deployment ?? null, + executedStepIds: doneEvent.executedStepIds ?? [], + skippedStepIds: doneEvent.skippedStepIds ?? [], + completedStepIds: doneEvent.completedStepIds ?? [], + steps, + ...(doneEvent.warning !== undefined ? { warning: doneEvent.warning } : {}), + }; +} diff --git a/apps/studio/test/App.applyConfig.test.tsx b/apps/studio/test/App.applyConfig.test.tsx new file mode 100644 index 0000000..91551df --- /dev/null +++ b/apps/studio/test/App.applyConfig.test.tsx @@ -0,0 +1,351 @@ +/** + * App.applyConfig.test.tsx + * + * Integration tests for the "Apply config" button + confirm modal wired into + * App.tsx (issue #151). Mocks global fetch to return the /api/apply-config SSE + * stream and asserts: + * + * - CONFIRM GATES THE REQUEST: clicking "Apply config" opens the modal and + * does NOT POST; Cancel closes it with STILL no POST; only Confirm POSTs. + * - SUCCESS: after Confirm, the Inspector renders the returned config steps as + * completed and a success banner summarizing executed/skipped appears. + * - FAILURE: a failed step + done{success:false} shows an error banner that + * surfaces the failing step's message, without crashing the rest of the UI. + */ + +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import App from "../src/App.js"; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +function enc(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function makeStream(chunks: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); +} + +function stepFrame( + stepId: string, + kind: "setX" | "grantRole" | "wire", + status: "executing" | "completed" | "failed", + message?: string, +): string { + const data = + message !== undefined ? { stepId, kind, status, message } : { stepId, kind, status }; + return `event: step\ndata: ${JSON.stringify(data)}\n\n`; +} + +interface ConfiguredStep { + id: string; + kind: string; + completed: boolean; + completedAt?: string | null; +} + +function doneOkFrame( + executedStepIds: string[], + skippedStepIds: string[], + completedStepIds: string[], + configSteps: ConfiguredStep[], +): string { + const deployment = { + contracts: [], + configSteps: configSteps.map((s) => ({ + id: s.id, + kind: s.kind, + completed: s.completed, + completedAt: s.completedAt ?? null, + })), + warnings: [], + }; + return `event: done\ndata: ${JSON.stringify({ + success: true, + executedStepIds, + skippedStepIds, + completedStepIds, + deployment, + })}\n\n`; +} + +function doneErrorFrame(errors: { message: string; code?: string }[]): string { + return `event: done\ndata: ${JSON.stringify({ success: false, errors })}\n\n`; +} + +/** + * Add a contract node by name through the Contracts Browser (mirrors the + * helper in App.deploy.test.tsx / App.simulate.test.tsx). Opens the browser + * if not already open. + */ +function addNodeByName(name: string) { + if (!screen.queryByTestId("contracts-browser")) { + fireEvent.click(screen.getByTestId("toggle-contracts-browser")); + } + const browser = screen.getByTestId("contracts-browser"); + fireEvent.click(within(browser).getByTestId(`contract-row-${name}`)); +} + +function fillArg(index: number, value: string) { + fireEvent.change(screen.getByLabelText(`arg-${index}`), { target: { value } }); +} + +/** + * Add a node-level "grantRole" config step through the node's inline config + * section so `config.steps` is non-empty (issue #151's Apply-config button is + * enabled only when there is a config step to apply). + */ +function addGrantRoleStep(nodeIndex = 0) { + const configSection = document.querySelectorAll( + "[data-testid^='node-config-section-']", + )[nodeIndex] as HTMLElement; + fireEvent.click(within(configSection).getByText("Add config call")); + fireEvent.click(within(configSection).getByText("grantRole(bytes32,address)")); +} + +function mockFetchOk(raw: string): ReturnType { + return vi.fn().mockResolvedValue( + new Response(makeStream([enc(raw)]), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); +} + +function mockFetchError(status: number, body: string): ReturnType { + return vi.fn().mockResolvedValue(new Response(body, { status })); +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +/** Build a graph with one Registry node + a grantRole config step. */ +function setupWithConfigStep() { + render(); + addNodeByName("Registry"); + fillArg(0, "0x0000000000000000000000000000000000000001"); + addGrantRoleStep(0); +} + +// --------------------------------------------------------------------------- +// Button presence + enablement +// --------------------------------------------------------------------------- + +describe("App — Apply config button", () => { + it("renders the apply-config button next to Verify", () => { + render(); + const btn = screen.getByTestId("deploy-apply-config-button"); + expect(btn).not.toBeNull(); + expect(btn.textContent).toBe("Apply config"); + + const verifyBtn = screen.getByTestId("deploy-verify-button"); + expect(btn.parentElement).toBe(verifyBtn.parentElement); + }); + + it("is disabled when there is no config step to apply", () => { + render(); + const btn = screen.getByTestId("deploy-apply-config-button") as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it("becomes enabled once a config step exists on the graph", () => { + render(); + addNodeByName("Registry"); + fillArg(0, "0x0000000000000000000000000000000000000001"); + addGrantRoleStep(0); + + const btn = screen.getByTestId("deploy-apply-config-button") as HTMLButtonElement; + expect(btn.disabled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Confirm gates the request +// --------------------------------------------------------------------------- + +describe("App — Apply config confirm gating", () => { + it("clicking Apply config opens the modal and does NOT POST", async () => { + const fetchSpy = mockFetchOk(doneOkFrame([], [], [], [])); + vi.stubGlobal("fetch", fetchSpy); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + + expect(screen.queryByTestId("apply-config-modal")).not.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalledWith("/api/apply-config", expect.anything()); + }); + + it("clicking Cancel closes the modal and STILL does NOT POST", async () => { + const fetchSpy = mockFetchOk(doneOkFrame([], [], [], [])); + vi.stubGlobal("fetch", fetchSpy); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + expect(screen.queryByTestId("apply-config-modal")).not.toBeNull(); + + fireEvent.click(screen.getByTestId("apply-config-cancel")); + + expect(screen.queryByTestId("apply-config-modal")).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalledWith("/api/apply-config", expect.anything()); + }); + + it("only clicking Confirm triggers the POST to /api/apply-config", async () => { + const fetchSpy = mockFetchOk( + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "completed") + + doneOkFrame( + ["grant-minter"], + [], + ["grant-minter"], + [{ id: "grant-minter", kind: "grantRole", completed: true }], + ), + ); + vi.stubGlobal("fetch", fetchSpy); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + expect(fetchSpy).not.toHaveBeenCalledWith("/api/apply-config", expect.anything()); + + fireEvent.click(screen.getByTestId("apply-config-confirm")); + + await waitFor(() => { + expect( + fetchSpy.mock.calls.filter((c: unknown[]) => c[0] === "/api/apply-config"), + ).toHaveLength(1); + }); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/apply-config", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("the confirm modal states it broadcasts real transactions", () => { + vi.stubGlobal("fetch", mockFetchOk(doneOkFrame([], [], [], []))); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + + const modal = screen.getByTestId("apply-config-modal"); + expect(modal.textContent?.toLowerCase()).toContain("real transaction"); + expect(modal.textContent?.toLowerCase()).toContain("irreversible"); + }); +}); + +// --------------------------------------------------------------------------- +// Success path +// --------------------------------------------------------------------------- + +describe("App — Apply config success path", () => { + it("renders the returned config steps as completed in the Inspector", async () => { + const raw = + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "completed") + + doneOkFrame( + ["grant-minter"], + [], + ["grant-minter"], + [{ id: "grant-minter", kind: "grantRole", completed: true, completedAt: "2026-07-21T00:00:00.000Z" }], + ); + vi.stubGlobal("fetch", mockFetchOk(raw)); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + fireEvent.click(screen.getByTestId("apply-config-confirm")); + + await waitFor(() => { + expect(screen.queryByTestId("config-step-grant-minter-status")).not.toBeNull(); + }); + + expect(screen.getByTestId("config-step-grant-minter-status").textContent).toBe("completed"); + }); + + it("shows a success banner summarizing executed vs skipped counts", async () => { + const raw = doneOkFrame( + ["grant-minter"], + ["set-fee"], + ["grant-minter", "set-fee"], + [ + { id: "grant-minter", kind: "grantRole", completed: true }, + { id: "set-fee", kind: "setX", completed: true }, + ], + ); + vi.stubGlobal("fetch", mockFetchOk(raw)); + + setupWithConfigStep(); + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + fireEvent.click(screen.getByTestId("apply-config-confirm")); + + await waitFor(() => { + const banner = screen.queryByTestId("apply-config-success"); + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain("1 step(s) executed"); + expect(banner!.textContent).toContain("1 already up to date"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Failure path +// --------------------------------------------------------------------------- + +describe("App — Apply config failure path", () => { + it("shows an error banner surfacing the failing step's message and does not crash", async () => { + const raw = + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "failed", "config step failed") + + doneErrorFrame([{ message: "config step failed" }]); + vi.stubGlobal("fetch", mockFetchOk(raw)); + + const { container } = render(); + addNodeByName("Registry"); + fillArg(0, "0x0000000000000000000000000000000000000001"); + addGrantRoleStep(0); + + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + fireEvent.click(screen.getByTestId("apply-config-confirm")); + + await waitFor(() => { + expect(screen.queryByTestId("apply-config-error")).not.toBeNull(); + }); + + const banner = screen.getByTestId("apply-config-error"); + expect(banner.textContent).toContain("config step failed"); + expect(banner.textContent).toContain("grant-minter"); + // App still mounted, nothing crashed. + expect(container.firstChild).not.toBeNull(); + }); + + it("shows an error banner on a non-200 response and does not crash", async () => { + vi.stubGlobal("fetch", mockFetchError(400, "invalid config format")); + + const { container } = render(); + addNodeByName("Registry"); + fillArg(0, "0x0000000000000000000000000000000000000001"); + addGrantRoleStep(0); + + fireEvent.click(screen.getByTestId("deploy-apply-config-button")); + fireEvent.click(screen.getByTestId("apply-config-confirm")); + + await waitFor(() => { + expect(screen.queryByTestId("apply-config-error")).not.toBeNull(); + }); + + expect(screen.getByTestId("apply-config-error").textContent).toContain("400"); + expect(container.firstChild).not.toBeNull(); + }); +}); diff --git a/apps/studio/test/apply-config-client.test.ts b/apps/studio/test/apply-config-client.test.ts new file mode 100644 index 0000000..d1bf262 --- /dev/null +++ b/apps/studio/test/apply-config-client.test.ts @@ -0,0 +1,338 @@ +/** + * apply-config-client.test.ts + * + * Unit tests for the SSE apply-config client helper (runApplyConfig), issue + * #151. Modeled on deploy-client.test.ts: builds a synthetic SSE stream via + * makeStream() and asserts the parsed per-step statuses + terminal result. + * + * Covered paths: + * - parseApplyConfigFrame: step / done / malformed frames + * - success: step frames (executing→completed) + done{success:true} → + * executedStepIds/skippedStepIds/completedStepIds + view populated + * - idempotent re-run: all steps reported skipped, none executed, still ok + * - a step reports "failed" + done{success:false} → error mapping, and the + * failed step's message is present in the accumulated `steps` list + * - non-200 error response / network reject / stream-ends-without-done + * - the network name is sent as the `?network=` query param + * + * All tests are pure / browser-safe (no node-only imports). + */ + +import { describe, it, expect, vi } from "vitest"; +import { runApplyConfig, parseApplyConfigFrame } from "../src/deploy/apply-config-client.js"; +import type { DeploymentView } from "@redeploy/reader"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a ReadableStream from an array of string chunks. */ +function makeStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} + +function stepFrame( + stepId: string, + kind: "setX" | "grantRole" | "wire", + status: "executing" | "completed" | "failed", + message?: string, +): string { + const data = + message !== undefined ? { stepId, kind, status, message } : { stepId, kind, status }; + return `event: step\ndata: ${JSON.stringify(data)}\n\n`; +} + +function doneOkFrame( + executedStepIds: string[], + skippedStepIds: string[], + completedStepIds: string[], + deployment: DeploymentView | null, + warning?: string, +): string { + const data = + warning !== undefined + ? { success: true, executedStepIds, skippedStepIds, completedStepIds, deployment, warning } + : { success: true, executedStepIds, skippedStepIds, completedStepIds, deployment }; + return `event: done\ndata: ${JSON.stringify(data)}\n\n`; +} + +function doneErrorFrame(errors: { code?: string; message: string }[]): string { + return `event: done\ndata: ${JSON.stringify({ success: false, errors })}\n\n`; +} + +const SAMPLE_VIEW: DeploymentView = { + contracts: [ + { + id: "token", + contractName: "ERC20Token", + address: "0xTOKEN000000000000000000000000000000000001", + args: ["MyToken"], + links: { dependencies: [], libraries: {} }, + }, + ], + configSteps: [ + { id: "grant-minter", kind: "grantRole", completed: true, completedAt: "2026-07-21T00:00:00.000Z" }, + ], + warnings: [], +}; + +// --------------------------------------------------------------------------- +// parseApplyConfigFrame +// --------------------------------------------------------------------------- + +describe("parseApplyConfigFrame", () => { + it("parses a step frame", () => { + const frame = `event: step\ndata: {"stepId":"grant-minter","kind":"grantRole","status":"executing"}`; + const result = parseApplyConfigFrame(frame); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("step"); + if (result!.kind === "step") { + expect(result!.step.stepId).toBe("grant-minter"); + expect(result!.step.kind).toBe("grantRole"); + expect(result!.step.status).toBe("executing"); + } + }); + + it("parses a done{success:true} frame", () => { + const frame = `event: done\ndata: {"success":true,"executedStepIds":[],"skippedStepIds":[],"completedStepIds":[],"deployment":null}`; + const result = parseApplyConfigFrame(frame); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("done"); + if (result!.kind === "done") { + expect(result!.done.success).toBe(true); + } + }); + + it("returns null for empty / no-event / no-data / unknown / malformed frames", () => { + expect(parseApplyConfigFrame("")).toBeNull(); + expect(parseApplyConfigFrame(`data: {"success":true}`)).toBeNull(); + expect(parseApplyConfigFrame(`event: done`)).toBeNull(); + expect(parseApplyConfigFrame(`event: mystery\ndata: {"x":1}`)).toBeNull(); + expect(parseApplyConfigFrame(`event: done\ndata: not-json`)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// runApplyConfig — success path +// --------------------------------------------------------------------------- + +describe("runApplyConfig — success path", () => { + it("POSTs the bare config spec to /api/apply-config", async () => { + const raw = + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "completed") + + doneOkFrame(["grant-minter"], [], ["grant-minter"], SAMPLE_VIEW); + const mockFetch = vi.fn().mockResolvedValue( + new Response(makeStream([raw]), { status: 200, headers: { "Content-Type": "text/event-stream" } }), + ); + + const config = { version: 1, steps: [] }; + await runApplyConfig(config, mockFetch); + + expect(mockFetch).toHaveBeenCalledWith("/api/apply-config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + }); + + it("accumulates step statuses (executing→completed) and returns the terminal id lists + view", async () => { + const raw = + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "completed") + + doneOkFrame(["grant-minter"], [], ["grant-minter"], SAMPLE_VIEW); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + // Only the LAST (terminal) status is kept per step id. + expect(result.steps).toHaveLength(1); + expect(result.steps[0]).toEqual({ stepId: "grant-minter", kind: "grantRole", status: "completed" }); + + expect(result.executedStepIds).toEqual(["grant-minter"]); + expect(result.skippedStepIds).toEqual([]); + expect(result.completedStepIds).toEqual(["grant-minter"]); + expect(result.view).not.toBeNull(); + expect(result.view!.configSteps[0].completed).toBe(true); + }); + + it("the idempotent case: all steps reported skipped, none executed, still ok:true", async () => { + const raw = doneOkFrame([], ["grant-minter", "set-fee"], ["grant-minter", "set-fee"], SAMPLE_VIEW); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.executedStepIds).toEqual([]); + expect(result.skippedStepIds).toEqual(["grant-minter", "set-fee"]); + // No `step` frames were emitted for skipped steps in this run. + expect(result.steps).toEqual([]); + }); + + it("returns view:null and carries the warning when deployment is null", async () => { + const raw = doneOkFrame(["s1"], [], ["s1"], null, "could not read journal"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.view).toBeNull(); + expect(result.warning).toBe("could not read journal"); + }); + + it("handles a stream split across many small chunks", async () => { + const raw = + stepFrame("s1", "setX", "executing") + + stepFrame("s1", "setX", "completed") + + doneOkFrame(["s1"], [], ["s1"], SAMPLE_VIEW); + const bytes = raw.split(""); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream(bytes), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.steps).toEqual([{ stepId: "s1", kind: "setX", status: "completed" }]); + }); +}); + +// --------------------------------------------------------------------------- +// runApplyConfig — failure paths +// --------------------------------------------------------------------------- + +describe("runApplyConfig — failure paths", () => { + it("a step reports failed + done{success:false} maps to ok:false with the step's message preserved", async () => { + const raw = + stepFrame("grant-minter", "grantRole", "executing") + + stepFrame("grant-minter", "grantRole", "failed", "config step failed") + + doneErrorFrame([{ message: "config step failed" }]); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("config step failed"); + expect(result.steps).toEqual([ + { stepId: "grant-minter", kind: "grantRole", status: "failed", message: "config step failed" }, + ]); + expect(result.errors).toEqual([{ message: "config step failed" }]); + }); + + it("maps a structured INVALID_SPEC error with code", async () => { + const raw = doneErrorFrame([{ code: "INVALID_SPEC", message: "step 0: unknown ref" }]); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("unknown ref"); + expect(result.errors).toEqual([{ code: "INVALID_SPEC", message: "step 0: unknown ref" }]); + }); + + it("returns ok:false on a non-200 response and includes status + body", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("invalid config format", { status: 400 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("400"); + expect(result.error).toContain("invalid config format"); + expect(result.steps).toEqual([]); + }); + + it("returns ok:false on fetch rejection (network error)", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("Failed to fetch")); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("Network error"); + expect(result.error).toContain("Failed to fetch"); + }); + + it("returns ok:false when the stream ends without a done event", async () => { + const raw = stepFrame("s1", "setX", "executing"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("done event"); + // The in-flight step's last-seen status is still surfaced. + expect(result.steps).toEqual([{ stepId: "s1", kind: "setX", status: "executing" }]); + }); + + it("returns ok:false when the response body is empty", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const result = await runApplyConfig({}, mockFetch); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error"); + expect(result.error).toContain("empty"); + }); +}); + +// --------------------------------------------------------------------------- +// runApplyConfig — network param +// --------------------------------------------------------------------------- + +describe("runApplyConfig — network param", () => { + it("omitted network param → POSTs to /api/apply-config with no query string", async () => { + const raw = doneOkFrame([], [], [], null, "warn"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + await runApplyConfig({}, mockFetch); + + expect(mockFetch).toHaveBeenCalledWith("/api/apply-config", expect.anything()); + }); + + it("undefined network param → POSTs to /api/apply-config with no query string", async () => { + const raw = doneOkFrame([], [], [], null, "warn"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + await runApplyConfig({}, mockFetch, undefined); + + expect(mockFetch).toHaveBeenCalledWith("/api/apply-config", expect.anything()); + }); + + it("a network name → POSTs to /api/apply-config?network=", async () => { + const raw = doneOkFrame([], [], [], null, "warn"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + await runApplyConfig({}, mockFetch, "sepolia"); + + expect(mockFetch).toHaveBeenCalledWith("/api/apply-config?network=sepolia", expect.anything()); + }); + + it("a network name with special characters is URI-encoded", async () => { + const raw = doneOkFrame([], [], [], null, "warn"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + await runApplyConfig({}, mockFetch, "my network/1"); + + expect(mockFetch).toHaveBeenCalledWith( + `/api/apply-config?network=${encodeURIComponent("my network/1")}`, + expect.anything(), + ); + }); + + it("an empty-string network param is treated the same as omitted (no query string)", async () => { + const raw = doneOkFrame([], [], [], null, "warn"); + const mockFetch = vi.fn().mockResolvedValue(new Response(makeStream([raw]), { status: 200 })); + + await runApplyConfig({}, mockFetch, ""); + + expect(mockFetch).toHaveBeenCalledWith("/api/apply-config", expect.anything()); + }); +}); From 5d99826d7af2dd08fcfd15acd6a84b7b07515887 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:01:41 +0200 Subject: [PATCH 3/3] test(studio): cover apply-config failed-step badge + harden failure asserts (#151 review) Adds Inspector tests for the config-step--apply-status "failed" badge (present on failed, absent otherwise) and replaces tautological "didn't crash" assertions in the apply-config failure tests with real post-failure UI-state checks (error banner text + button re-enabled). Co-Authored-By: Claude Sonnet 5 --- apps/studio/test/App.applyConfig.test.tsx | 24 ++++++++++- apps/studio/test/Inspector.test.tsx | 50 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/apps/studio/test/App.applyConfig.test.tsx b/apps/studio/test/App.applyConfig.test.tsx index 91551df..b5c2cc3 100644 --- a/apps/studio/test/App.applyConfig.test.tsx +++ b/apps/studio/test/App.applyConfig.test.tsx @@ -326,7 +326,16 @@ describe("App — Apply config failure path", () => { const banner = screen.getByTestId("apply-config-error"); expect(banner.textContent).toContain("config step failed"); expect(banner.textContent).toContain("grant-minter"); - // App still mounted, nothing crashed. + + // App remains interactive after the failure, not just "didn't crash": + // the confirm modal closed (so the user isn't stuck behind it) and the + // apply-config button is present, re-enabled, and clickable again — + // proving the failure path doesn't leave the UI in a stuck state. + expect(screen.queryByTestId("apply-config-modal")).toBeNull(); + const retryBtn = screen.getByTestId("deploy-apply-config-button") as HTMLButtonElement; + expect(retryBtn.disabled).toBe(false); + fireEvent.click(retryBtn); + expect(screen.queryByTestId("apply-config-modal")).not.toBeNull(); expect(container.firstChild).not.toBeNull(); }); @@ -345,7 +354,18 @@ describe("App — Apply config failure path", () => { expect(screen.queryByTestId("apply-config-error")).not.toBeNull(); }); - expect(screen.getByTestId("apply-config-error").textContent).toContain("400"); + const banner = screen.getByTestId("apply-config-error"); + expect(banner.textContent).toContain("400"); + expect(banner.textContent).toContain("invalid config format"); + + // App remains interactive after a non-200 failure too: the confirm + // modal is closed and the apply-config button is present and + // re-enabled, so the user can retry rather than being stuck. + expect(screen.queryByTestId("apply-config-modal")).toBeNull(); + const retryBtn = screen.getByTestId("deploy-apply-config-button") as HTMLButtonElement; + expect(retryBtn.disabled).toBe(false); + fireEvent.click(retryBtn); + expect(screen.queryByTestId("apply-config-modal")).not.toBeNull(); expect(container.firstChild).not.toBeNull(); }); }); diff --git a/apps/studio/test/Inspector.test.tsx b/apps/studio/test/Inspector.test.tsx index 44394e0..88849ff 100644 --- a/apps/studio/test/Inspector.test.tsx +++ b/apps/studio/test/Inspector.test.tsx @@ -318,3 +318,53 @@ describe("Inspector — source-verification badges", () => { expect(screen.queryByTestId("inspector-node-vault-verified")).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Inspector — apply-config failure badges (issue #151) +// --------------------------------------------------------------------------- + +describe("Inspector — apply-config failure badges", () => { + it("renders no apply-status badge for a step when applyConfigResults is absent", () => { + render(); + expect(screen.queryByTestId("config-step-setFee-apply-status")).toBeNull(); + }); + + it("renders a 'failed' apply-status badge for a step with status 'failed', carrying the message as its title", () => { + render( + , + ); + const badge = screen.getByTestId("config-step-setFee-apply-status"); + expect(badge.textContent).toBe("failed"); + expect(badge.title).toBe("revert: fee too high"); + }); + + it("does not render an apply-status badge for a step whose applyConfigResults entry is 'completed' (not 'failed')", () => { + render( + , + ); + expect(screen.queryByTestId("config-step-setFee-apply-status")).toBeNull(); + // The pre-existing completed/pending badge is unaffected by applyConfigResults. + expect(screen.getByTestId("config-step-setFee-status").textContent).toBe("completed"); + }); + + it("does not render an apply-status badge for a step absent from applyConfigResults", () => { + render( + , + ); + expect(screen.getByTestId("config-step-setToken-apply-status")).not.toBeNull(); + expect(screen.queryByTestId("config-step-setFee-apply-status")).toBeNull(); + }); +});