From f72dc6cb5597cd85ae26ea4d4b622f119fe76b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:08:42 +0200 Subject: [PATCH 1/7] feat(ci): update Docker images and enhance proxy proposal caching mechanism --- .github/workflows/pr-multisig-v1-smoke.yml | 2 +- docker-compose.ci.yml | 3 +- scripts/ci/README.md | 17 ++++++++-- scripts/ci/scenarios/steps/proxyBot.ts | 36 ++++++++++++++-------- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr-multisig-v1-smoke.yml b/.github/workflows/pr-multisig-v1-smoke.yml index c6c73b34..4ef723bd 100644 --- a/.github/workflows/pr-multisig-v1-smoke.yml +++ b/.github/workflows/pr-multisig-v1-smoke.yml @@ -102,7 +102,7 @@ jobs: shell: bash run: | for i in 1 2 3; do - docker pull node:20-alpine && break + docker pull node:22-slim && break echo "Pull attempt $i failed, retrying in 30s..." sleep 30 done diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 3197837c..5a226a85 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -78,6 +78,7 @@ services: CI_ROUTE_SCENARIOS: ${CI_ROUTE_SCENARIOS:-} CI_ROUTE_CHAIN_REPORT_PATH: ${CI_ROUTE_CHAIN_REPORT_PATH:-/artifacts/ci-route-chain-report.md} CI_CONTEXT_PATH: ${CI_CONTEXT_PATH:-/tmp/ci-wallet-context.json} + CI_RUN_WALLET_STATUS: ${CI_RUN_WALLET_STATUS:-false} CI_DREP_ANCHOR_URL: ${CI_DREP_ANCHOR_URL:-} CI_DREP_ANCHOR_JSON: ${CI_DREP_ANCHOR_JSON:-} CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} @@ -94,7 +95,7 @@ services: sh -c " status=0; node .ci-dist/bootstrap.mjs || status=$$?; - if [ \"$$status\" -eq 0 ]; then node .ci-dist/wallet-status.mjs || status=$$?; fi; + if [ \"$$status\" -eq 0 ] && [ \"$${CI_RUN_WALLET_STATUS:-false}\" = \"true\" ]; then node .ci-dist/wallet-status.mjs || status=$$?; fi; if [ \"$$status\" -eq 0 ]; then node .ci-dist/route-chain.mjs || status=$$?; fi; rm -f \"${CI_CONTEXT_PATH:-/tmp/ci-wallet-context.json}\"; exit \"$$status\" diff --git a/scripts/ci/README.md b/scripts/ci/README.md index cbd03b34..4226f8d6 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -252,6 +252,7 @@ Primary variables (in workflow/compose): - `CI_DREP_ANCHOR_JSON` (required by the default run for `scenario.drep-certificates`): the raw JSON content of the CIP-119 DRep metadata document. Parsed and sent as `anchorJson`; the API computes the anchor data hash server-side — no outbound fetch anywhere. Both vars are forwarded into the `ci-runner` container via `docker-compose.ci.yml`. - `CI_STAKE_POOL_ID_HEX` (**required** for `scenario.stake-certificates`): hex stake pool id stored in bootstrap context and used as `poolId` in the `register_and_delegate` certificate body. - `CI_HTTP_RETRIES` (default `6`), `CI_HTTP_RETRY_DELAY_MS` (default `1000`), `CI_HTTP_MAX_RETRY_DELAY_MS` (default `30000`): route-chain API retry controls for transient responses (`408`, `418`, `429`, `500`, `502`, `503`, `504`). Exponential backoff with `Retry-After` header support. Defaults are long enough to ride out the app's 60-second in-process rate-limit window without changing app behavior. +- `CI_RUN_WALLET_STATUS` (default `false`): when running the composed `ci-runner` command, set to `true` to print the optional pre-route wallet balance check. The route-chain report always collects end-of-run wallet balances, so the default CI path skips this extra Blockfrost lookup. Validation notes: @@ -425,7 +426,7 @@ docker compose -f docker-compose.ci.yml run --rm ` ci-runner node .ci-dist/bootstrap.mjs ``` -Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). +Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). The composed `ci-runner` skips this by default; set `CI_RUN_WALLET_STATUS=true` if you want it included there. ```powershell docker compose -f docker-compose.ci.yml run --rm ` @@ -449,6 +450,12 @@ View generated report on host: Get-Content ".\ci-artifacts\ci-route-chain-report.md" ``` +When you are done, remove the CI containers and volume: + +```powershell +docker compose -f docker-compose.ci.yml down -v --remove-orphans +``` + ## Local execution (Linux/Bash, CI-like) From repo root: @@ -515,7 +522,7 @@ docker compose -f docker-compose.ci.yml run --rm \ ci-runner node .ci-dist/bootstrap.mjs ``` -Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). +Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). The composed `ci-runner` skips this by default; set `CI_RUN_WALLET_STATUS=true` if you want it included there. ```bash docker compose -f docker-compose.ci.yml run --rm \ @@ -537,3 +544,9 @@ View generated report on host: ```bash cat ./ci-artifacts/ci-route-chain-report.md ``` + +When you are done, remove the CI containers and volume: + +```bash +docker compose -f docker-compose.ci.yml down -v --remove-orphans +``` diff --git a/scripts/ci/scenarios/steps/proxyBot.ts b/scripts/ci/scenarios/steps/proxyBot.ts index 4f970779..4c23a5d6 100644 --- a/scripts/ci/scenarios/steps/proxyBot.ts +++ b/scripts/ci/scenarios/steps/proxyBot.ts @@ -1225,6 +1225,28 @@ function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { }; } +let proxyActiveProposalsCache: ActiveProposal[] | undefined; + +async function getCachedProxyActiveProposals(ctx: CIBootstrapContext): Promise { + if (proxyActiveProposalsCache) { + return proxyActiveProposalsCache; + } + + const bot = getDefaultBot(ctx); + const token = await authenticateBot({ ctx, bot }); + const response = await requestJson<{ proposals?: unknown[]; activeCount?: number; sourceCount?: number; error?: string }>({ + url: `${ctx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`, + method: "GET", + token, + }); + if (response.status !== 200) { + throw new Error(`governanceActiveProposals failed (${response.status}): ${stringifyRedacted(response.data)}`); + } + + proxyActiveProposalsCache = getDeterministicActiveProposals(response.data, 1); + return proxyActiveProposalsCache; +} + function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { const runtime: { setup?: ProxySetup; @@ -1351,19 +1373,9 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { description: "Fetch active proposals for optional proxy vote", severity: "critical", execute: async (runCtx) => { - const bot = getDefaultBot(runCtx); - const token = await authenticateBot({ ctx: runCtx, bot }); - const response = await requestJson<{ proposals?: unknown[]; activeCount?: number; sourceCount?: number; error?: string }>({ - url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`, - method: "GET", - token, - }); - if (response.status !== 200) { - throw new Error(`governanceActiveProposals failed (${response.status}): ${stringifyRedacted(response.data)}`); - } - runtime.activeProposals = getDeterministicActiveProposals(response.data, 1); + runtime.activeProposals = await getCachedProxyActiveProposals(runCtx); return { - message: `selected ${runtime.activeProposals.length} active proposal(s) for optional proxy vote`, + message: `selected ${runtime.activeProposals.length} cached active proposal(s) for optional proxy vote`, artifacts: { selectedProposalIds: runtime.activeProposals.map((proposal) => proposal.proposalId) }, }; }, From 14963c7d5fb6adad783b4a53d591eedcc8c2fd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:50:29 +0200 Subject: [PATCH 2/7] feat: add support for parallel branches in proxy full lifecycle scenarios - Extend Scenario type to include optional parallelBranches for parallel execution. - Implement parallel execution logic in createScenarioProxyFullLifecycle. - Add validation to ensure unique wallet addresses for parallel branches. - Introduce collateral reservation logic to reserve distinct signer-0 collateral UTxOs for parallel lifecycles. - Update UTxO selection functions to utilize reserved collateral references. - Enhance tests to cover parallel execution scenarios and collateral reservation behavior. --- scripts/ci/README.md | 16 +- scripts/ci/framework/runner.ts | 134 +++++++++--- scripts/ci/framework/types.ts | 5 + scripts/ci/scenarios/flows/utxoShapeFlow.ts | 9 +- .../scenarios/proxyCollateralReservations.ts | 90 ++++++++ .../ci/scenarios/proxyLifecyclePreflight.ts | 17 +- scripts/ci/scenarios/steps/proxyBot.ts | 203 ++++++++++++++++-- src/__tests__/ciRunner.test.ts | 120 +++++++++++ src/__tests__/proxyBotSelection.test.ts | 23 ++ src/__tests__/proxyCiPreflight.test.ts | 94 +++++++- 10 files changed, 645 insertions(+), 66 deletions(-) create mode 100644 scripts/ci/scenarios/proxyCollateralReservations.ts diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 4226f8d6..3b50311a 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -134,7 +134,9 @@ The manifest currently covers: `scenario.proxy-smoke` runs by default and performs authenticated `proxies` read checks plus negative validation checks that should fail before chain mutation. -`scenario.proxy-full-lifecycle` runs by default in PR smoke for `legacy`, `hierarchical`, and `sdk` wallets when present. The hierarchical coverage reuses the wallet already created for route-chain context and the ring transfer; it does not add a new bootstrap wallet path. It starts each eligible wallet type with three pre-hygiene steps before normal setup: chain recovery reconstructs missing `Proxy` rows from proxy auth tokens still visible at the current CI wallet address, row adoption reattaches valid rows from historical deterministic CI wallets, and hygiene cleans any active rows before the new lifecycle begins. It then runs UTxO shaping and a funding preflight that fetches fresh `freeUtxos`. The hardcoded lifecycle budget is 536 ADA per eligible wallet: 505 ADA DRep registration, 10 ADA initial proxy funding, 1 ADA planned proxy spend, and a 20 ADA fee buffer. Because collateral is reserved outside selected spend inputs, the practical minimum post-shape layout is at least 536 ADA selectable at the multisig wallet address plus a separate ADA-only bot payment-address collateral UTxO. The self-split path needs enough total ADA to leave that 536 ADA selectable budget, create a 6 ADA collateral output, and cover a 2 ADA self-split fee buffer. Adding hierarchical means default PR smoke needs that budget available for one more wallet. Proxy DRep registration uses `CI_DREP_ANCHOR_URL` as the on-chain anchor URL and sends an inline route-chain `anchorJson`; it does not use `CI_DREP_ANCHOR_JSON`. +`scenario.proxy-full-lifecycle` runs by default in PR smoke for `legacy`, `hierarchical`, and `sdk` wallets when present. The hierarchical coverage reuses the wallet already created for route-chain context and the ring transfer; it does not add a new bootstrap wallet path. Before the wallet branches run, route-chain verifies that each eligible branch has a unique `walletId` and `walletAddress`, ensures enough UTxO shape for the full lifecycle, and reserves one distinct ADA-only signer-0 collateral UTxO per branch at `bot.paymentAddress`. The branch lifecycles then run in parallel by default while each wallet type keeps its own steps sequential. Set `CI_PROXY_FULL_LIFECYCLE_PARALLEL=false` to run the same reservation-aware wallet chains serially. + +Each eligible wallet type starts with three pre-hygiene steps before normal setup: chain recovery reconstructs missing `Proxy` rows from proxy auth tokens still visible at the current CI wallet address, row adoption reattaches valid rows from historical deterministic CI wallets, and hygiene cleans any active rows before the new lifecycle begins. It then runs UTxO shaping and a funding preflight that fetches fresh `freeUtxos`. The hardcoded lifecycle budget is 536 ADA per eligible wallet: 505 ADA DRep registration, 10 ADA initial proxy funding, 1 ADA planned proxy spend, and a 20 ADA fee buffer. Because collateral is reserved outside selected spend inputs, the practical minimum post-shape layout is at least 536 ADA selectable at the multisig wallet address plus a separate ADA-only bot payment-address collateral UTxO reserved for that wallet branch. The self-split path needs enough total ADA to leave that 536 ADA selectable budget, create a 6 ADA collateral output, and cover a 2 ADA self-split fee buffer. Adding hierarchical means default PR smoke needs that budget available for one more wallet. Proxy DRep registration uses `CI_DREP_ANCHOR_URL` as the on-chain anchor URL and sends an inline route-chain `anchorJson`; it does not use `CI_DREP_ANCHOR_JSON`. The first full-lifecycle steps for each eligible wallet type are ordered as: @@ -145,13 +147,13 @@ The first full-lifecycle steps for each eligible wallet type are ordered as: 5. `v1.proxy.full.preflight.` 6. `v1.proxy.client-build-smoke.` (non-critical) -Step 6, `v1.proxy.client-build-smoke.`, is a non-submitting step that calls `buildProxySetupTx` from `src/lib/proxy/txBuilders.ts` directly with a real `MeshTxBuilder` and Blockfrost-resolved UTxOs. It fetches UTxOs at the wallet script address and the bot payment address from Blockfrost, selects a param UTxO (≥ 20 ADA) and an ADA-only collateral, builds the setup transaction, and calls `txBuilder.complete()` to run the Aiken evaluator against preprod. It asserts that the result is a non-empty CBOR hex string and discards it without calling `addTransaction` or any signing step. This catches blueprint schema regressions (`plutus.json` validator index changes), `applyParamsToScript`/`resolveScriptHash` failures, Aiken evaluator errors, and `MeshTxBuilder` API changes that would not be caught by the mock-builder unit tests in `src/__tests__/proxyTxBuilders.test.ts`. Marked non-critical so a slow Blockfrost evaluator response does not block the lifecycle steps that follow. +Step 6, `v1.proxy.client-build-smoke.`, is a non-submitting step that calls `buildProxySetupTx` from `src/lib/proxy/txBuilders.ts` directly with a real `MeshTxBuilder` and Blockfrost-resolved UTxOs. It fetches UTxOs at the wallet script address and the bot payment address from Blockfrost, selects a param UTxO (≥ 20 ADA) and the branch's reserved ADA-only collateral, builds the setup transaction, and calls `txBuilder.complete()` to run the Aiken evaluator against preprod. It asserts that the result is a non-empty CBOR hex string and discards it without calling `addTransaction` or any signing step. This catches blueprint schema regressions (`plutus.json` validator index changes), `applyParamsToScript`/`resolveScriptHash` failures, Aiken evaluator errors, and `MeshTxBuilder` API changes that would not be caught by the mock-builder unit tests in `src/__tests__/proxyTxBuilders.test.ts`. Marked non-critical so a slow Blockfrost evaluator response does not block the lifecycle steps that follow. Chain recovery is CI-only and evidence-based. It scans non-lovelace assets at the current bootstrap `walletAddress` (up to 25 asset candidates; excess are skipped), asks Blockfrost for each asset's mint transaction, tests the mint transaction inputs as candidate `paramUtxo` values with `deriveProxyScripts`, and only creates or reactivates a `Proxy` row when the derived `authTokenId` exactly matches the observed asset unit. This handles clean-database rebuilds where old proxy auth tokens and proxy DReps remain on-chain but the app has no `Proxy` rows. It cannot recover a proxy if the auth token is no longer discoverable at the current CI wallet address. -When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use bot payment-address collateral that is distinct from selected wallet spend inputs; DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while preserving an auth token, followed by a burn transaction and cleanup finalization. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage. +When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use the branch's reserved bot payment-address collateral, which is distinct from both selected wallet spend inputs and other parallel branches' collateral. DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while preserving an auth token, followed by a burn transaction and cleanup finalization. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage. -Runtime expectation: `scenario.proxy-smoke` is the quick, non-mutating proxy subset. `scenario.proxy-full-lifecycle` is a real-chain scenario with multiple broadcasts per eligible wallet and can dominate default PR smoke duration during slow preprod/Blockfrost periods. The GitHub Actions job timeout is intentionally higher than the nominal happy path to leave room for confirmation polling. +Runtime expectation: `scenario.proxy-smoke` is the quick, non-mutating proxy subset. `scenario.proxy-full-lifecycle` is a real-chain scenario with multiple broadcasts per eligible wallet and can dominate default PR smoke duration during slow preprod/Blockfrost periods. The expensive per-wallet lifecycles run in parallel by default after the isolation step, but the GitHub Actions job timeout is still intentionally higher than the nominal happy path to leave room for confirmation polling. For each tested wallet type, the `nativeScript` step stores decoded script payloads in step artifacts (`artifacts.nativeScripts`) and the list of script entry types (`artifacts.scriptTypes`) inside `ci-route-chain-report.md`, so script structure is visible during CI triage. @@ -323,12 +325,12 @@ Balance source: direct on-chain UTxO lookup per wallet address from bootstrap co ## Proxy Full Lifecycle UTxO Shaping -`scenario.proxy-full-lifecycle` needs a wallet script UTxO for proxy setup/spend and a separate key-address collateral UTxO at `bot.paymentAddress` for each eligible wallet type (`legacy`, `hierarchical`, `sdk`). When a funded wallet has enough ADA but lacks the required wallet/key UTxO shape, the route-chain now performs an idempotent self-split before the proxy preflight: +`scenario.proxy-full-lifecycle` needs a wallet script UTxO for proxy setup/spend and a separate key-address collateral UTxO at `bot.paymentAddress` for each eligible wallet type (`legacy`, `hierarchical`, `sdk`). Parallel mode requires one distinct signer-0 collateral UTxO per eligible wallet branch; the same signer address may be shared, but the same collateral ref is never intentionally shared. When a funded wallet has enough ADA but lacks the required wallet/key UTxO shape, the route-chain now performs an idempotent self-split before the proxy preflight: -- If fresh `freeUtxos` plus fresh `bot.paymentAddress` UTxOs already satisfy the lifecycle budget and key collateral shape, the shaping step is a no-op. +- If fresh `freeUtxos` plus fresh `bot.paymentAddress` UTxOs already satisfy the lifecycle budget and distinct key collateral shape, the shaping step is a no-op. - If wallet ADA is sufficient but the shape is not, the step submits a real preprod self-split through `/api/v1/addTransaction`, creating a 6 ADA collateral output at `bot.paymentAddress` and returning the rest as change to the wallet script address. The split requires the 536 ADA lifecycle budget plus the 6 ADA collateral output and a 2 ADA self-split fee buffer. - The self-split is signed by signer 1 and signer 2 using the existing `CI_MNEMONIC_2` / `CI_MNEMONIC_3` route-chain signing path, then waits for the original inputs to disappear from fresh `freeUtxos`. -- Server-built proxy transactions are persisted with no initial signed addresses. Because key-address collateral lives at `bot.paymentAddress`, proxy setup and action transactions first add signer index 0 (`CI_MNEMONIC_1`) as a real collateral witness, then signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 wallet. +- Server-built proxy transactions are persisted with no initial signed addresses. Because key-address collateral lives at `bot.paymentAddress`, proxy setup and action transactions first add signer index 0 (`CI_MNEMONIC_1`) as a real collateral witness, then signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 wallet. If a branch's reserved collateral ref is no longer available, the branch fails instead of selecting another branch's collateral. - Manual funding is still required when the wallet does not have enough total ADA for the proxy lifecycle budget plus the 6 ADA collateral output and fee buffer. Because the self-split is an on-chain transaction, it can add one confirmation wait per wallet type, but only when the current UTxO shape needs repair. diff --git a/scripts/ci/framework/runner.ts b/scripts/ci/framework/runner.ts index 122f360b..4daff7ad 100644 --- a/scripts/ci/framework/runner.ts +++ b/scripts/ci/framework/runner.ts @@ -7,6 +7,70 @@ function now(): number { return Date.now(); } +async function runStep(args: { + step: Scenario["steps"][number]; + ctx: CIBootstrapContext; +}): Promise { + const stepStart = now(); + const severity = args.step.severity ?? "critical"; + try { + const result = await args.step.execute(args.ctx); + return { + id: args.step.id, + description: args.step.description, + status: "passed", + severity, + message: result.message, + artifacts: result.artifacts, + durationMs: now() - stepStart, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + id: args.step.id, + description: args.step.description, + status: "failed", + severity, + message: "Step failed", + durationMs: now() - stepStart, + error: errorMessage, + }; + } +} + +async function runSerialSteps(args: { + steps: Scenario["steps"]; + ctx: CIBootstrapContext; + continueOnNonCriticalFailure: boolean; +}): Promise<{ reports: StepReport[]; failed: boolean; criticalFailed: boolean }> { + const reports: StepReport[] = []; + let failed = false; + let criticalFailed = false; + + for (const step of args.steps) { + const report = await runStep({ step, ctx: args.ctx }); + reports.push(report); + + if (report.status !== "failed") { + continue; + } + + const isCritical = report.severity === "critical"; + if (isCritical || !args.continueOnNonCriticalFailure) { + failed = true; + } + if (isCritical) { + criticalFailed = true; + break; + } + if (!args.continueOnNonCriticalFailure) { + break; + } + } + + return { reports, failed, criticalFailed }; +} + export async function runScenarios(args: { scenarios: Scenario[]; ctx: CIBootstrapContext; @@ -22,42 +86,46 @@ export async function runScenarios(args: { const steps: StepReport[] = []; let scenarioFailed = false; - for (const step of scenario.steps) { - const stepStart = now(); - const severity = step.severity ?? "critical"; - try { - const result = await step.execute(ctx); - steps.push({ - id: step.id, - description: step.description, - status: "passed", - severity, - message: result.message, - artifacts: result.artifacts, - durationMs: now() - stepStart, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isCritical = severity === "critical"; - steps.push({ - id: step.id, - description: step.description, - status: "failed", - severity, - message: "Step failed", - durationMs: now() - stepStart, - error: errorMessage, - }); - if (isCritical || !continueOnNonCriticalFailure) { + const serial = await runSerialSteps({ + steps: scenario.steps, + ctx, + continueOnNonCriticalFailure, + }); + steps.push(...serial.reports); + scenarioFailed = serial.failed; + if (serial.failed) { + overallFailed = true; + } + + if (!serial.criticalFailed && scenario.parallelBranches?.length) { + const branchResults = await Promise.all( + scenario.parallelBranches.map(async (branch) => { + const result = await runSerialSteps({ + steps: branch.steps, + ctx, + continueOnNonCriticalFailure, + }); + return { + branch, + ...result, + }; + }), + ); + for (const branchResult of branchResults) { + steps.push( + ...branchResult.reports.map((report) => ({ + ...report, + artifacts: { + branchId: branchResult.branch.id, + branchDescription: branchResult.branch.description, + ...(report.artifacts ?? {}), + }, + })), + ); + if (branchResult.failed) { scenarioFailed = true; overallFailed = true; } - if (isCritical) { - break; - } - if (!continueOnNonCriticalFailure) { - break; - } } } diff --git a/scripts/ci/framework/types.ts b/scripts/ci/framework/types.ts index 909ee4f7..72a65753 100644 --- a/scripts/ci/framework/types.ts +++ b/scripts/ci/framework/types.ts @@ -54,6 +54,11 @@ export type Scenario = { id: string; description: string; steps: RouteStep[]; + parallelBranches?: Array<{ + id: string; + description: string; + steps: RouteStep[]; + }>; }; export type StepReport = { diff --git a/scripts/ci/scenarios/flows/utxoShapeFlow.ts b/scripts/ci/scenarios/flows/utxoShapeFlow.ts index 63275a75..6fa0d5c6 100644 --- a/scripts/ci/scenarios/flows/utxoShapeFlow.ts +++ b/scripts/ci/scenarios/flows/utxoShapeFlow.ts @@ -177,6 +177,7 @@ async function buildSelfSplitTransaction(args: { export async function ensureProxyLifecycleUtxoShape(args: { ctx: CIBootstrapContext; walletType: CIWalletType; + minKeyCollateralCandidates?: number; }): Promise { const wallet = getWalletByType(args.ctx, args.walletType); if (!wallet) throw new Error(`Missing ${args.walletType} wallet`); @@ -198,6 +199,7 @@ export async function ensureProxyLifecycleUtxoShape(args: { const analysis = analyzeProxyFullLifecycleUtxoShape({ walletUtxos: utxos, collateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, }); if (analysis.status === "pass") { return { @@ -216,7 +218,11 @@ export async function ensureProxyLifecycleUtxoShape(args: { `Proxy lifecycle self-split cannot leave ${formatAda(PROXY_LIFECYCLE_COLLATERAL_SPLIT_LOVELACE)} collateral plus enough selectable ADA. ${analysis.diagnostics}. Add at least ${formatAda(analysis.selfSplitRequiredLovelace - analysis.totalLovelace)} plus any desired safety margin before running proxy full lifecycle.`, ); } - assertProxyFullLifecyclePreflight({ walletUtxos: utxos, collateralUtxos }); + assertProxyFullLifecyclePreflight({ + walletUtxos: utxos, + collateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, + }); } requireProxyShapeEnvironment(args.ctx, wallet.walletAddress, bot.paymentAddress); @@ -292,6 +298,7 @@ export async function ensureProxyLifecycleUtxoShape(args: { const shaped = assertProxyFullLifecyclePreflight({ walletUtxos: shapedUtxos, collateralUtxos: shapedCollateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, }); return { diff --git a/scripts/ci/scenarios/proxyCollateralReservations.ts b/scripts/ci/scenarios/proxyCollateralReservations.ts new file mode 100644 index 00000000..80eff140 --- /dev/null +++ b/scripts/ci/scenarios/proxyCollateralReservations.ts @@ -0,0 +1,90 @@ +import type { CIWalletType } from "../framework/types"; +import { + COLLATERAL_REQUIRED_LOVELACE, + key, + parseLovelace, + type ScriptUtxo, + type UtxoRef, + toRef, +} from "./proxyLifecyclePreflight"; + +export type ProxyCollateralReservation = { + walletType: CIWalletType; + reservationKey: string; + collateralRef: UtxoRef; +}; + +export function isProxyLifecycleCollateralCandidate(utxo: ScriptUtxo): boolean { + return ( + parseLovelace(utxo) >= COLLATERAL_REQUIRED_LOVELACE && + utxo.output.amount.every((asset) => asset.unit === "lovelace") + ); +} + +export function sortCollateralCandidates(utxos: ScriptUtxo[]): ScriptUtxo[] { + return [...utxos].filter(isProxyLifecycleCollateralCandidate).sort((left, right) => { + const leftLovelace = parseLovelace(left); + const rightLovelace = parseLovelace(right); + if (leftLovelace < rightLovelace) return -1; + if (leftLovelace > rightLovelace) return 1; + if (left.input.txHash !== right.input.txHash) { + return left.input.txHash.localeCompare(right.input.txHash); + } + return left.input.outputIndex - right.input.outputIndex; + }); +} + +export function createProxyLifecycleReservationKey(walletType: CIWalletType): string { + return `scenario.proxy-full-lifecycle:${walletType}`; +} + +export function reserveProxyLifecycleCollateral(args: { + walletTypes: CIWalletType[]; + collateralUtxos: ScriptUtxo[]; +}): Map { + const candidates = sortCollateralCandidates(args.collateralUtxos); + if (candidates.length < args.walletTypes.length) { + throw new Error( + `Proxy full lifecycle parallel isolation requires ${args.walletTypes.length} distinct ADA-only signer-0 collateral UTxO(s), but only found ${candidates.length}`, + ); + } + + const reservations = new Map(); + const used = new Set(); + for (let i = 0; i < args.walletTypes.length; i += 1) { + const walletType = args.walletTypes[i]!; + const candidate = candidates.find((utxo) => !used.has(key(toRef(utxo)))); + if (!candidate) { + throw new Error(`No unreserved signer-0 collateral UTxO remains for ${walletType}`); + } + const collateralRef = toRef(candidate); + used.add(key(collateralRef)); + reservations.set(walletType, { + walletType, + reservationKey: createProxyLifecycleReservationKey(walletType), + collateralRef, + }); + } + + return reservations; +} + +export function requireReservedCollateralUtxo(args: { + collateralUtxos: ScriptUtxo[]; + reservedCollateralRef: UtxoRef; + context: string; +}): ScriptUtxo { + const reservedKey = key(args.reservedCollateralRef); + const collateral = args.collateralUtxos.find((utxo) => key(toRef(utxo)) === reservedKey); + if (!collateral) { + throw new Error( + `${args.context} reserved signer-0 collateral UTxO ${reservedKey} is not currently available`, + ); + } + if (!isProxyLifecycleCollateralCandidate(collateral)) { + throw new Error( + `${args.context} reserved signer-0 collateral UTxO ${reservedKey} is no longer an ADA-only collateral candidate`, + ); + } + return collateral; +} diff --git a/scripts/ci/scenarios/proxyLifecyclePreflight.ts b/scripts/ci/scenarios/proxyLifecyclePreflight.ts index c996c337..e2f18df8 100644 --- a/scripts/ci/scenarios/proxyLifecyclePreflight.ts +++ b/scripts/ci/scenarios/proxyLifecyclePreflight.ts @@ -76,12 +76,15 @@ export type ProxyLifecycleUtxoShapeAnalysis = { export type ProxyLifecycleUtxoShapeInput = { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }; export function analyzeProxyFullLifecycleUtxoShape(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }): ProxyLifecycleUtxoShapeAnalysis { + const minKeyCollateralCandidates = Math.max(1, args.minKeyCollateralCandidates ?? 1); const lovelaces = args.walletUtxos.map(parseLovelace); const totalLovelace = lovelaces.reduce((sum, value) => sum + value, 0n); const largestUtxoLovelace = lovelaces.reduce( @@ -95,7 +98,7 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { utxo.output.amount.every((asset) => asset.unit === "lovelace"), ); const hasSetupCandidate = setupCandidates > 0; - const hasKeyCollateral = keyCollateralCandidates.length > 0; + const hasKeyCollateral = keyCollateralCandidates.length >= minKeyCollateralCandidates; const drepRequiredLovelace = getProxyFullLifecycleRequiredLovelace(); const drepSelectableLovelace = totalLovelace; const requiredTotalLovelace = getProxyFullLifecycleRequiredLovelace(); @@ -103,7 +106,7 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { drepRequiredLovelace + PROXY_LIFECYCLE_COLLATERAL_SPLIT_LOVELACE + SELF_SPLIT_FEE_BUFFER_LOVELACE; const diagnostics = `total=${formatAda(totalLovelace)}, largestUtxO=${formatAda(largestUtxoLovelace)}, ` + - `setupCandidates=${setupCandidates}, keyCollateralCandidates=${keyCollateralCandidates.length}, ` + + `setupCandidates=${setupCandidates}, keyCollateralCandidates=${keyCollateralCandidates.length}/${minKeyCollateralCandidates}, ` + `drepSelectable=${formatAda(drepSelectableLovelace)}, drepRequired=${formatAda(drepRequiredLovelace)}, ` + `required=${formatAda(requiredTotalLovelace)} ` + `(DRep register ${formatAda(DREP_REGISTER_REQUIRED_LOVELACE)} + ` + @@ -142,15 +145,21 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { export function assertProxyFullLifecyclePreflight(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }): Omit< ProxyLifecycleUtxoShapeAnalysis, "status" | "diagnostics" | "selfSplitRequiredLovelace" | "hasSetupCandidate" | "hasKeyCollateral" > { + const minKeyCollateralCandidates = Math.max(1, args.minKeyCollateralCandidates ?? 1); const analysis = analyzeProxyFullLifecycleUtxoShape(args); - if (analysis.keyCollateralCandidates === 0) { + if (analysis.keyCollateralCandidates < minKeyCollateralCandidates) { + const collateralMessage = + minKeyCollateralCandidates === 1 + ? `no bot payment-address UTxO has at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)} for Plutus collateral` + : `expected ${minKeyCollateralCandidates} distinct bot payment-address collateral UTxO(s) with at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)}, found ${analysis.keyCollateralCandidates}`; throw new Error( - `Proxy full lifecycle preflight failed: no bot payment-address UTxO has at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)} for Plutus collateral. ${analysis.diagnostics}. Run proxy lifecycle UTxO shaping or fund the bot payment address before running proxy full lifecycle.`, + `Proxy full lifecycle preflight failed: ${collateralMessage}. ${analysis.diagnostics}. Run proxy lifecycle UTxO shaping or fund the bot payment address before running proxy full lifecycle.`, ); } if (analysis.setupCandidates === 0) { diff --git a/scripts/ci/scenarios/steps/proxyBot.ts b/scripts/ci/scenarios/steps/proxyBot.ts index 4c23a5d6..ca3334c3 100644 --- a/scripts/ci/scenarios/steps/proxyBot.ts +++ b/scripts/ci/scenarios/steps/proxyBot.ts @@ -10,6 +10,11 @@ import { runSigningFlow } from "../flows/signingFlow"; import { ensureProxyLifecycleUtxoShape } from "../flows/utxoShapeFlow"; import { recoverProxyRowsFromChainForWalletType } from "../proxyChainRecovery"; import { adoptProxyOrphansForWalletType } from "../proxyOrphanAdoption"; +import { + requireReservedCollateralUtxo, + reserveProxyLifecycleCollateral, + type ProxyCollateralReservation, +} from "../proxyCollateralReservations"; import { getWalletByType } from "./helpers"; import { assertProxyFullLifecyclePreflight, @@ -195,7 +200,15 @@ function isAdaOnlyCollateral(utxo: ScriptUtxo): boolean { function selectSeparateCollateral( utxos: ScriptUtxo[], context: string, + reservedCollateralRef?: UtxoRef, ): ScriptUtxo { + if (reservedCollateralRef) { + return requireReservedCollateralUtxo({ + collateralUtxos: utxos, + reservedCollateralRef, + context, + }); + } const collateral = [...utxos] .filter(isAdaOnlyCollateral) .sort((left, right) => { @@ -216,12 +229,13 @@ function selectSeparateCollateral( export function selectSetupRefs(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef } { const setupUtxo = selectSetupUtxo(args.walletUtxos as unknown as UTxO[]); if (!setupUtxo) { throw new Error(`proxy setup requires a wallet UTxO with at least ${formatAda(SETUP_UTXO_REQUIRED_LOVELACE)}`); } - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy setup"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy setup", args.reservedCollateralRef); return { utxoRefs: [toRef(setupUtxo as unknown as ScriptUtxo)], collateralRef: toRef(collateral) }; } @@ -230,8 +244,9 @@ export function selectAuthTokenRefs(args: { collateralUtxos: ScriptUtxo[]; authTokenId: string; includeAllAuthTokens?: boolean; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef } { - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy action"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy action", args.reservedCollateralRef); if (args.includeAllAuthTokens) { // Cleanup path: include every auth-token UTxO const authTokenUtxos = args.walletUtxos.filter((utxo) => @@ -252,6 +267,7 @@ export function selectDRepRegisterRefs(args: { collateralUtxos: ScriptUtxo[]; authTokenId: string; requiredLovelace?: bigint; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef; selectedLovelace: bigint; requiredLovelace: bigint } { const requiredLovelace = args.requiredLovelace ?? DREP_REGISTER_REQUIRED_LOVELACE; const authTokenUtxo = selectAuthTokenUtxo(args.walletUtxos as unknown as UTxO[], args.authTokenId); @@ -262,7 +278,7 @@ export function selectDRepRegisterRefs(args: { `proxy DRep register requires ${formatAda(requiredLovelace)} in selected wallet inputs but only ${formatAda(selectedLovelace)} is available after reserving separate collateral. Fund or consolidate the CI wallet before running scenario.proxy-full-lifecycle.`, ); } - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy DRep register"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy DRep register", args.reservedCollateralRef); return { utxoRefs: selected.map((u) => toRef(u as unknown as ScriptUtxo)), collateralRef: toRef(collateral), @@ -277,6 +293,7 @@ export function selectAuthTokenRefsWithMinLovelace(args: { authTokenId: string; requiredLovelace: bigint; context: string; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef; selectedLovelace: bigint; requiredLovelace: bigint } { const authTokenUtxo = selectAuthTokenUtxo(args.walletUtxos as unknown as UTxO[], args.authTokenId); const selected = accumulateFundingUtxos(args.walletUtxos as unknown as UTxO[], authTokenUtxo, args.requiredLovelace); @@ -286,7 +303,7 @@ export function selectAuthTokenRefsWithMinLovelace(args: { `${args.context} requires ${formatAda(args.requiredLovelace)} in selected wallet inputs but only ${formatAda(selectedLovelace)} is available after reserving separate collateral. Fund or consolidate the CI wallet before running scenario.proxy-full-lifecycle.`, ); } - const collateral = selectSeparateCollateral(args.collateralUtxos, args.context); + const collateral = selectSeparateCollateral(args.collateralUtxos, args.context, args.reservedCollateralRef); return { utxoRefs: selected.map((u) => toRef(u as unknown as ScriptUtxo)), collateralRef: toRef(collateral), @@ -381,6 +398,7 @@ async function fetchProxyDRepInfo(args: { export async function runProxyFullLifecycleHygiene(args: { ctx: CIBootstrapContext; walletType: CIWalletType; + reservedCollateralRef?: UtxoRef; deps?: Partial; }): Promise<{ message: string; artifacts: Record }> { const deps = { ...defaultProxyLifecycleHygieneDeps, ...args.deps }; @@ -441,6 +459,7 @@ export async function runProxyFullLifecycleHygiene(args: { authTokenId: proxy.authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy hygiene DRep deregister", + reservedCollateralRef: args.reservedCollateralRef, }); const { requestRefs, selectionArtifacts } = splitProxyActionSelection(selection); const response = await deps.requestJson({ @@ -521,6 +540,7 @@ export async function runProxyFullLifecycleHygiene(args: { collateralUtxos, authTokenId: proxy.authTokenId, includeAllAuthTokens: true, + reservedCollateralRef: args.reservedCollateralRef, }); const response = await deps.requestJson({ url: `${args.ctx.apiBaseUrl}/api/v1/proxyCleanup`, @@ -813,6 +833,7 @@ export function requireSetupTxHash(runtime: { function createSetupLifecycleSteps(args: { walletType: CIWalletType; + getReservedCollateralRef?: () => UtxoRef | undefined; runtime: { setup?: ProxySetup; proxyId?: string; @@ -836,7 +857,11 @@ function createSetupLifecycleSteps(args: { fetchFreeUtxos({ ctx, walletId: wallet.walletId, token, address: bot.paymentAddress, fresh: true }), fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }), ]); - const refs = selectSetupRefs({ walletUtxos, collateralUtxos }); + const refs = selectSetupRefs({ + walletUtxos, + collateralUtxos, + reservedCollateralRef: args.getReservedCollateralRef?.(), + }); const response = await requestJson<{ transaction?: unknown; setup?: ProxySetup; error?: string }>({ url: `${ctx.apiBaseUrl}/api/v1/proxySetup`, method: "POST", @@ -964,7 +989,13 @@ function createProxyActionStep(args: { cleanupBurnTransactionId?: string; }; buildBody: (ctx: CIBootstrapContext, refs: ProxyActionRequestRefs) => Record | null; - selectRefs?: (args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; authTokenId: string }) => ProxyActionSelection; + selectRefs?: (args: { + walletUtxos: ScriptUtxo[]; + collateralUtxos: ScriptUtxo[]; + authTokenId: string; + reservedCollateralRef?: UtxoRef; + }) => ProxyActionSelection; + getReservedCollateralRef?: () => UtxoRef | undefined; includeAllAuthTokens?: boolean; shouldSkip?: () => boolean; onSkip?: () => void; @@ -990,12 +1021,18 @@ function createProxyActionStep(args: { fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }), ]); const selection = - args.selectRefs?.({ walletUtxos, collateralUtxos, authTokenId: args.runtime.setup.authTokenId }) ?? + args.selectRefs?.({ + walletUtxos, + collateralUtxos, + authTokenId: args.runtime.setup.authTokenId, + reservedCollateralRef: args.getReservedCollateralRef?.(), + }) ?? selectAuthTokenRefs({ walletUtxos, collateralUtxos, authTokenId: args.runtime.setup.authTokenId, includeAllAuthTokens: args.includeAllAuthTokens, + reservedCollateralRef: args.getReservedCollateralRef?.(), }); const { requestRefs, selectionArtifacts } = splitProxyActionSelection(selection); args.runtime.actionTransactionId = undefined; @@ -1114,12 +1151,20 @@ function createWaitForActionConfirmationStep(args: { }; } -function createProxyFullLifecycleHygieneStep(walletType: CIWalletType): RouteStep { +function createProxyFullLifecycleHygieneStep( + walletType: CIWalletType, + getReservedCollateralRef?: () => UtxoRef | undefined, +): RouteStep { return { id: `v1.proxy.full.hygiene.${walletType}`, description: "Clean stale active proxy lifecycle rows before starting", severity: "critical", - execute: async (ctx) => runProxyFullLifecycleHygiene({ ctx, walletType }), + execute: async (ctx) => + runProxyFullLifecycleHygiene({ + ctx, + walletType, + reservedCollateralRef: getReservedCollateralRef?.(), + }), }; } @@ -1157,7 +1202,10 @@ function createProxyFullLifecycleAdoptionStep(walletType: CIWalletType): RouteSt }; } -function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { +function createProxyClientBuildSmokeStep( + walletType: CIWalletType, + getReservedCollateralRef?: () => UtxoRef | undefined, +): RouteStep { return { id: `v1.proxy.client-build-smoke.${walletType}`, description: `Build proxy setup CBOR via client builder without submission (${walletType})`, @@ -1192,6 +1240,7 @@ function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { const collateralScriptUtxo = selectSeparateCollateral( collateralUtxos as unknown as ScriptUtxo[], "proxy client build smoke", + getReservedCollateralRef?.(), ); const txBuilder = new MeshTxBuilder({ @@ -1247,7 +1296,10 @@ async function getCachedProxyActiveProposals(ctx: CIBootstrapContext): Promise UtxoRef | undefined, +): RouteStep[] { const runtime: { setup?: ProxySetup; proxyId?: string; @@ -1267,7 +1319,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { return [ createProxyFullLifecycleChainRecoveryStep(walletType), createProxyFullLifecycleAdoptionStep(walletType), - createProxyFullLifecycleHygieneStep(walletType), + createProxyFullLifecycleHygieneStep(walletType, getReservedCollateralRef), { id: `v1.proxy.full.utxoShape.${walletType}`, description: "Ensure proxy full-lifecycle wallet has separate setup and collateral UTxOs", @@ -1279,7 +1331,10 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { result.status === "already-shaped" ? `proxy full lifecycle UTxO shape already satisfied for ${walletType}` : `proxy full lifecycle UTxO self-split confirmed for ${walletType}`, - artifacts: result as unknown as Record, + artifacts: { + ...(result as unknown as Record), + reservedCollateralRef: getReservedCollateralRef?.(), + }, }; }, }, @@ -1306,6 +1361,14 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletUtxos, collateralUtxos, }); + const reservedCollateralRef = getReservedCollateralRef?.(); + if (reservedCollateralRef) { + requireReservedCollateralUtxo({ + collateralUtxos, + reservedCollateralRef, + context: `proxy full lifecycle preflight (${walletType})`, + }); + } return { message: `proxy full lifecycle preflight passed with ${formatAda(result.totalLovelace)} available and ${formatAda(result.requiredTotalLovelace)} required`, artifacts: { @@ -1316,18 +1379,20 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { drepSelectableLovelace: result.drepSelectableLovelace.toString(), drepRequiredLovelace: result.drepRequiredLovelace.toString(), requiredTotalLovelace: result.requiredTotalLovelace.toString(), + reservedCollateralRef, }, }; }, }, - createProxyClientBuildSmokeStep(walletType), - ...createSetupLifecycleSteps({ walletType, runtime }), + createProxyClientBuildSmokeStep(walletType, getReservedCollateralRef), + ...createSetupLifecycleSteps({ walletType, runtime, getReservedCollateralRef }), createProxyActionStep({ id: `v1.proxy.full.spend.propose.${walletType}`, description: "Build proxy spend transaction", walletType, endpoint: "proxySpend", runtime, + getReservedCollateralRef, buildBody: (runCtx) => ({ outputs: [{ address: getWalletByType(runCtx, walletType)?.walletAddress ?? "", unit: "lovelace", amount: PROXY_SPEND_LOVELACE.toString() }], description: "CI proxy spend", @@ -1346,12 +1411,14 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyDRepCertificate", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => { + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => { return selectDRepRegisterRefs({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: DREP_REGISTER_REQUIRED_LOVELACE + FULL_LIFECYCLE_FEE_BUFFER_LOVELACE, + reservedCollateralRef, }); }, buildBody: () => ({ @@ -1386,13 +1453,15 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyVote", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => selectAuthTokenRefsWithMinLovelace({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy vote", + reservedCollateralRef, }), buildBody: () => { const proposal = runtime.activeProposals?.[0]; @@ -1417,13 +1486,15 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyDRepCertificate", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => selectAuthTokenRefsWithMinLovelace({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy DRep deregister", + reservedCollateralRef, }), buildBody: () => ({ action: "deregister", @@ -1443,6 +1514,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyCleanup", runtime, + getReservedCollateralRef, includeAllAuthTokens: true, buildBody: () => ({ deactivateProxy: true, @@ -1462,6 +1534,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyCleanup", runtime, + getReservedCollateralRef, includeAllAuthTokens: true, shouldSkip: () => shouldSkipCleanupBurnPropose(runtime), onSkip: () => { @@ -1546,6 +1619,73 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { ]; } +function validateUniqueProxyLifecycleWallets(ctx: CIBootstrapContext, walletTypes: CIWalletType[]): void { + const walletIds = new Map(); + const walletAddresses = new Map(); + + for (const walletType of walletTypes) { + const wallet = getWalletByType(ctx, walletType); + if (!wallet) throw new Error(`Missing ${walletType} wallet`); + + const existingWalletId = walletIds.get(wallet.walletId); + if (existingWalletId) { + throw new Error( + `Proxy full lifecycle parallel isolation failed: ${walletType} and ${existingWalletId} share walletId ${wallet.walletId}`, + ); + } + walletIds.set(wallet.walletId, walletType); + + const existingAddress = walletAddresses.get(wallet.walletAddress); + if (existingAddress) { + throw new Error( + `Proxy full lifecycle parallel isolation failed: ${walletType} and ${existingAddress} share walletAddress ${wallet.walletAddress}`, + ); + } + walletAddresses.set(wallet.walletAddress, walletType); + } +} + +function createProxyFullLifecycleIsolationStep(args: { + eligibleWalletTypes: CIWalletType[]; + reservations: Map; +}): RouteStep { + return { + id: "v1.proxy.full.parallelIsolation", + description: "Reserve distinct signer-0 collateral UTxOs for parallel proxy lifecycles", + severity: "critical", + execute: async (ctx) => { + validateUniqueProxyLifecycleWallets(ctx, args.eligibleWalletTypes); + + for (const walletType of args.eligibleWalletTypes) { + await ensureProxyLifecycleUtxoShape({ + ctx, + walletType, + minKeyCollateralCandidates: args.eligibleWalletTypes.length, + }); + } + + const bot = getDefaultBot(ctx); + const collateralUtxos = await fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }); + const reservations = reserveProxyLifecycleCollateral({ + walletTypes: args.eligibleWalletTypes, + collateralUtxos, + }); + args.reservations.clear(); + for (const [walletType, reservation] of reservations.entries()) { + args.reservations.set(walletType, reservation); + } + + return { + message: `reserved ${reservations.size} distinct signer-0 collateral UTxO(s) for proxy full lifecycle`, + artifacts: normalizeJsonArtifact({ + collateralAddress: bot.paymentAddress, + reservations: Array.from(reservations.values()), + }) as Record, + }; + }, + }; +} + export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scenario { const eligibleWalletTypes = PROXY_FULL_LIFECYCLE_WALLET_TYPES.filter( (walletType) => @@ -1553,8 +1693,23 @@ export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scena ctx.wallets.some((wallet) => wallet.type === walletType), ); + const reservations = new Map(); + const isParallelEnabled = boolFromEnv(process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL, true); + const getReservedCollateralRef = (walletType: CIWalletType) => + reservations.get(walletType)?.collateralRef; + const steps: RouteStep[] = eligibleWalletTypes.length - ? eligibleWalletTypes.flatMap((walletType) => createProxyFullLifecycleSteps(walletType)) + ? [ + createProxyFullLifecycleIsolationStep({ + eligibleWalletTypes, + reservations, + }), + ...(!isParallelEnabled + ? eligibleWalletTypes.flatMap((walletType) => + createProxyFullLifecycleSteps(walletType, () => getReservedCollateralRef(walletType)), + ) + : []), + ] : [ { id: "v1.proxy.full.precondition", @@ -1568,9 +1723,19 @@ export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scena }, ]; + const parallelBranches = + eligibleWalletTypes.length && isParallelEnabled + ? eligibleWalletTypes.map((walletType) => ({ + id: `proxy-full-lifecycle.${walletType}`, + description: `Proxy full lifecycle (${walletType})`, + steps: createProxyFullLifecycleSteps(walletType, () => getReservedCollateralRef(walletType)), + })) + : undefined; + return { id: "scenario.proxy-full-lifecycle", description: "Proxy spend, governance, and cleanup lifecycle for legacy, hierarchical, and SDK wallets", steps, + parallelBranches, }; } diff --git a/src/__tests__/ciRunner.test.ts b/src/__tests__/ciRunner.test.ts index ef06d47a..14b8d5cb 100644 --- a/src/__tests__/ciRunner.test.ts +++ b/src/__tests__/ciRunner.test.ts @@ -68,4 +68,124 @@ describe("route-chain runner", () => { expect(report.status).toBe("failed"); expect(report.scenarios[0]?.status).toBe("failed"); }); + + it("runs parallel branches while preserving serial order inside each branch", async () => { + const events: string[] = []; + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const scenarios: Scenario[] = [ + { + id: "scenario.parallel", + description: "parallel scenario", + steps: [ + { + id: "step.prepare", + description: "prepare", + execute: async () => { + events.push("prepare"); + return { message: "prepared" }; + }, + }, + ], + parallelBranches: [ + { + id: "branch.a", + description: "A", + steps: [ + { + id: "a.1", + description: "a1", + execute: async () => { + events.push("a.1.start"); + await delay(20); + events.push("a.1.end"); + return { message: "a1" }; + }, + }, + { + id: "a.2", + description: "a2", + execute: async () => { + events.push("a.2"); + return { message: "a2" }; + }, + }, + ], + }, + { + id: "branch.b", + description: "B", + steps: [ + { + id: "b.1", + description: "b1", + execute: async () => { + events.push("b.1"); + return { message: "b1" }; + }, + }, + ], + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("passed"); + expect(events.indexOf("prepare")).toBeLessThan(events.indexOf("a.1.start")); + expect(events.indexOf("prepare")).toBeLessThan(events.indexOf("b.1")); + expect(events.indexOf("b.1")).toBeLessThan(events.indexOf("a.1.end")); + expect(events.indexOf("a.1.end")).toBeLessThan(events.indexOf("a.2")); + expect(report.scenarios[0]?.steps.map((step) => step.id)).toEqual([ + "step.prepare", + "a.1", + "a.2", + "b.1", + ]); + expect(report.scenarios[0]?.steps.find((step) => step.id === "a.1")?.artifacts).toMatchObject({ + branchId: "branch.a", + }); + }); + + it("fails the run when a parallel branch has a critical failure but reports other branches", async () => { + const scenarios: Scenario[] = [ + { + id: "scenario.parallel-fail", + description: "parallel failure", + steps: [], + parallelBranches: [ + { + id: "branch.fail", + description: "fail", + steps: [ + { + id: "fail.1", + description: "fail", + execute: async () => { + throw new Error("branch exploded"); + }, + }, + ], + }, + { + id: "branch.pass", + description: "pass", + steps: [ + { + id: "pass.1", + description: "pass", + execute: async () => ({ message: "ok" }), + }, + ], + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("failed"); + expect(report.scenarios[0]?.status).toBe("failed"); + expect(report.scenarios[0]?.steps.map((step) => step.id)).toEqual(["fail.1", "pass.1"]); + }); }); diff --git a/src/__tests__/proxyBotSelection.test.ts b/src/__tests__/proxyBotSelection.test.ts index f746c6de..af1f8314 100644 --- a/src/__tests__/proxyBotSelection.test.ts +++ b/src/__tests__/proxyBotSelection.test.ts @@ -42,6 +42,29 @@ describe("proxy bot UTxO selection", () => { expect(refs.collateralRef).toEqual({ txHash: "collateral", outputIndex: 0 }); }); + it("uses the reserved collateral ref instead of the first available collateral", () => { + const refs = selectSetupRefs({ + walletUtxos: [mkUtxo("20000000", "setup")], + collateralUtxos: [ + mkUtxo("6000000", "collateral-a", 0, undefined, "addr_test_signer_1"), + mkUtxo("6000000", "collateral-b", 1, undefined, "addr_test_signer_1"), + ], + reservedCollateralRef: { txHash: "collateral-b", outputIndex: 1 }, + }); + + expect(refs.collateralRef).toEqual({ txHash: "collateral-b", outputIndex: 1 }); + }); + + it("fails instead of falling back when the reserved collateral ref is unavailable", () => { + expect(() => + selectSetupRefs({ + walletUtxos: [mkUtxo("20000000", "setup")], + collateralUtxos: [mkUtxo("6000000", "collateral-a", 0, undefined, "addr_test_signer_1")], + reservedCollateralRef: { txHash: "collateral-b", outputIndex: 1 }, + }), + ).toThrow(/reserved signer-0 collateral UTxO collateral-b:1 is not currently available/); + }); + it("rejects setup when only wallet script UTxOs could act as collateral", () => { expect(() => selectSetupRefs({ diff --git a/src/__tests__/proxyCiPreflight.test.ts b/src/__tests__/proxyCiPreflight.test.ts index c83879c2..1d606988 100644 --- a/src/__tests__/proxyCiPreflight.test.ts +++ b/src/__tests__/proxyCiPreflight.test.ts @@ -14,6 +14,7 @@ import { } from "../../scripts/ci/scenarios/steps/proxyBot"; import type { CIBootstrapContext, CIWalletType } from "../../scripts/ci/framework/types"; import type { requestJson } from "../../scripts/ci/framework/http"; +import { reserveProxyLifecycleCollateral } from "../../scripts/ci/scenarios/proxyCollateralReservations"; type TestUtxo = Parameters[0]["walletUtxos"][number]; @@ -69,6 +70,16 @@ const mkContext = (walletTypes: CIWalletType[]): CIBootstrapContext => ({ signerStakeAddresses: ["stake_test_1", "stake_test_2", "stake_test_3"], }); +const scenarioStepIds = (scenario: ReturnType): string[] => [ + ...scenario.steps.map((step) => step.id), + ...(scenario.parallelBranches ?? []).flatMap((branch) => branch.steps.map((step) => step.id)), +]; + +const scenarioBranchStepIds = ( + scenario: ReturnType, + branchId: string, +): string[] => scenario.parallelBranches?.find((branch) => branch.id === branchId)?.steps.map((step) => step.id) ?? []; + describe("proxy full lifecycle preflight", () => { it("classifies an already usable UTxO shape as pass", () => { const analysis = analyzeProxyFullLifecycleUtxoShape({ @@ -208,9 +219,15 @@ describe("proxy scenario composition", () => { it("runs full lifecycle for legacy, hierarchical, and SDK wallets", () => { const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "hierarchical", "sdk"])); - const stepIds = scenario.steps.map((step) => step.id); + const stepIds = scenarioStepIds(scenario); expect(PROXY_FULL_LIFECYCLE_WALLET_TYPES).toEqual(["legacy", "hierarchical", "sdk"]); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.parallelIsolation"); + expect(scenario.parallelBranches?.map((branch) => branch.id)).toEqual([ + "proxy-full-lifecycle.legacy", + "proxy-full-lifecycle.hierarchical", + "proxy-full-lifecycle.sdk", + ]); expect(stepIds).toContain("v1.proxy.full.recoverFromChain.legacy"); expect(stepIds).toContain("v1.proxy.full.adoptOrphans.legacy"); expect(stepIds).toContain("v1.proxy.full.hygiene.legacy"); @@ -266,7 +283,7 @@ describe("proxy scenario composition", () => { it("signs proxy lifecycle transactions with signer index 0 before the broadcaster", () => { const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy"])); - const stepIds = scenario.steps.map((step) => step.id); + const stepIds = scenarioStepIds(scenario); const setupProposeIndex = stepIds.indexOf("v1.proxy.lifecycle.setup.propose.legacy"); expect(stepIds.slice(setupProposeIndex + 1, setupProposeIndex + 3)).toEqual([ @@ -283,6 +300,79 @@ describe("proxy scenario composition", () => { expect(stepIds).not.toContain("v1.proxy.full.spend.legacy.sign1"); }); + it("can disable proxy full lifecycle branch parallelism with an env flag", () => { + const previous = process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL; + process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL = "false"; + try { + const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "sdk"])); + + expect(scenario.parallelBranches).toBeUndefined(); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.parallelIsolation"); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.recoverFromChain.legacy"); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.recoverFromChain.sdk"); + } finally { + if (previous === undefined) { + delete process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL; + } else { + process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL = previous; + } + } + }); + + it("keeps each proxy lifecycle branch internally ordered", () => { + const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "sdk"])); + const stepIds = scenarioBranchStepIds(scenario, "proxy-full-lifecycle.legacy"); + + expect(stepIds).toEqual(expect.arrayContaining([ + "v1.proxy.full.recoverFromChain.legacy", + "v1.proxy.full.adoptOrphans.legacy", + "v1.proxy.full.hygiene.legacy", + "v1.proxy.full.utxoShape.legacy", + "v1.proxy.full.preflight.legacy", + ])); + expect(stepIds.indexOf("v1.proxy.full.recoverFromChain.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.adoptOrphans.legacy"), + ); + expect(stepIds.indexOf("v1.proxy.full.adoptOrphans.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.hygiene.legacy"), + ); + expect(stepIds.indexOf("v1.proxy.full.hygiene.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.utxoShape.legacy"), + ); + }); + + it("reserves distinct signer-0 collateral UTxOs in deterministic order", () => { + const reservations = reserveProxyLifecycleCollateral({ + walletTypes: ["legacy", "hierarchical", "sdk"], + collateralUtxos: [ + mkCollateralUtxo("8000000", "b", 0), + mkCollateralUtxo("6000000", "a", 2), + mkCollateralUtxo("6000000", "a", 1), + ], + }); + + expect(reservations.get("legacy")?.collateralRef).toEqual({ txHash: "a", outputIndex: 1 }); + expect(reservations.get("hierarchical")?.collateralRef).toEqual({ txHash: "a", outputIndex: 2 }); + expect(reservations.get("sdk")?.collateralRef).toEqual({ txHash: "b", outputIndex: 0 }); + }); + + it("fails collateral reservation when not enough distinct candidates exist", () => { + expect(() => + reserveProxyLifecycleCollateral({ + walletTypes: ["legacy", "sdk"], + collateralUtxos: [mkCollateralUtxo("6000000", "a", 1)], + }), + ).toThrow(/requires 2 distinct ADA-only signer-0 collateral UTxO/); + }); + + it("detects duplicate wallet addresses before parallel execution", async () => { + const ctx = mkContext(["legacy", "sdk"]); + ctx.wallets[1]!.walletAddress = ctx.wallets[0]!.walletAddress; + const scenario = createScenarioProxyFullLifecycle(ctx); + + await expect(scenario.steps[0]?.execute(ctx)).rejects.toThrow(/share walletAddress/); + }); + it("fails clearly instead of using a setup transaction id as a txHash", () => { expect(() => requireSetupTxHash({ From 3926f2875709c34a0634a9118b9e2daa5b08321c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:56:46 +0200 Subject: [PATCH 3/7] Add end-to-end tests for transaction validation, notification settings, proxy UI, rejected signing, responsive design, staking UI, and wallet access control - Implement new transaction validation tests to ensure proper recipient and amount validation. - Create notification settings UI tests to verify email preferences and persistence. - Add proxy UI tests to check the usability of the proxy control panel. - Introduce tests for handling rejected wallet signing during transaction proposals and approvals. - Develop responsive smoke tests for critical screens to catch layout regressions on mobile viewports. - Implement staking UI tests to validate staking actions based on account state. - Add wallet access control tests to ensure proper authorization for wallet pages. --- Dockerfile.playwright | 2 +- docker-compose.playwright.yml | 5 + e2e/PLAYWRIGHT_TEST_PLAN.md | 349 ++++++++++++++++++ e2e/RUNNING_LOCALLY.md | 80 +++- e2e/helpers/apiHelpers.ts | 165 +++++++++ e2e/helpers/phase3Mocks.ts | 255 +++++++++++++ e2e/playwright-report/index.html | 90 +++++ e2e/tests/bot-management-ui.spec.ts | 130 +++++++ e2e/tests/create-wallet-ui.spec.ts | 154 ++++++++ e2e/tests/governance-drep-ui.spec.ts | 261 +++++++++++++ e2e/tests/new-transaction-validation.spec.ts | 83 +++++ e2e/tests/notification-settings-ui.spec.ts | 101 +++++ e2e/tests/proxy-ui.spec.ts | 133 +++++++ e2e/tests/rejected-signing.spec.ts | 253 +++++++++++++ e2e/tests/responsive-smoke.spec.ts | 160 ++++++++ e2e/tests/ring-transfer.spec.ts | 86 ++++- e2e/tests/staking-ui.spec.ts | 168 +++++++++ e2e/tests/wallet-access-control.spec.ts | 223 +++++++++++ src/components/common/page-header.tsx | 6 +- .../create/ReviewSignersCard.tsx | 2 +- .../shared/useWalletFlowState.tsx | 6 +- .../pages/wallet/new-transaction/index.tsx | 123 +++++- 22 files changed, 2819 insertions(+), 16 deletions(-) create mode 100644 e2e/PLAYWRIGHT_TEST_PLAN.md create mode 100644 e2e/helpers/apiHelpers.ts create mode 100644 e2e/helpers/phase3Mocks.ts create mode 100644 e2e/playwright-report/index.html create mode 100644 e2e/tests/bot-management-ui.spec.ts create mode 100644 e2e/tests/create-wallet-ui.spec.ts create mode 100644 e2e/tests/governance-drep-ui.spec.ts create mode 100644 e2e/tests/new-transaction-validation.spec.ts create mode 100644 e2e/tests/notification-settings-ui.spec.ts create mode 100644 e2e/tests/proxy-ui.spec.ts create mode 100644 e2e/tests/rejected-signing.spec.ts create mode 100644 e2e/tests/responsive-smoke.spec.ts create mode 100644 e2e/tests/staking-ui.spec.ts create mode 100644 e2e/tests/wallet-access-control.spec.ts diff --git a/Dockerfile.playwright b/Dockerfile.playwright index 5c24ed35..9265204b 100644 --- a/Dockerfile.playwright +++ b/Dockerfile.playwright @@ -5,7 +5,7 @@ WORKDIR /app # Install app dependencies (needed for @meshsdk/* imports in Phase 2 helpers). COPY package.json package-lock.json* ./ COPY prisma ./prisma -RUN npm ci +RUN npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 # Install Chromium and its system dependencies. RUN npx playwright install chromium --with-deps diff --git a/docker-compose.playwright.yml b/docker-compose.playwright.yml index 31783837..1bdcf814 100644 --- a/docker-compose.playwright.yml +++ b/docker-compose.playwright.yml @@ -94,6 +94,7 @@ services: CI_MNEMONIC_3: ${CI_MNEMONIC_3:-} CI_BLOCKFROST_PREPROD_API_KEY: ${CI_BLOCKFROST_PREPROD_API_KEY:-} CI_WALLET_TYPES: ${CI_WALLET_TYPES:-legacy,hierarchical,sdk} + CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} CI_CONTEXT_PATH: /artifacts/ci-wallet-context.json depends_on: app: @@ -123,6 +124,10 @@ services: CI_MNEMONIC_3: ${CI_MNEMONIC_3:-} CI_BLOCKFROST_PREPROD_API_KEY: ${CI_BLOCKFROST_PREPROD_API_KEY:-} CI_TRANSFER_LOVELACE: ${CI_TRANSFER_LOVELACE:-2000000} + # Fallback for specs when the bootstrap context predates stakePoolIdHex. + CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} + CI_DREP_ANCHOR_URL: ${CI_DREP_ANCHOR_URL:-} + CI_DREP_ANCHOR_JSON: ${CI_DREP_ANCHOR_JSON:-} PLAYWRIGHT_HTML_REPORT: /artifacts/playwright-report PLAYWRIGHT_OUTPUT_DIR: /artifacts/playwright-traces depends_on: diff --git a/e2e/PLAYWRIGHT_TEST_PLAN.md b/e2e/PLAYWRIGHT_TEST_PLAN.md new file mode 100644 index 00000000..82095bf3 --- /dev/null +++ b/e2e/PLAYWRIGHT_TEST_PLAN.md @@ -0,0 +1,349 @@ +# Playwright Test Plan + +This is the browser E2E coverage tracker for `e2e/tests`. The Docker Playwright +runner in `docker-compose.playwright.yml` runs every spec in that folder by default. + +## Live Now + +### `e2e/tests/ring-transfer.spec.ts` + +Status: live. + +The current suite covers the real preprod ring-transfer path: + +- CIP-0030 wallet injection +- signer wallet authentication +- transaction proposal through the UI +- pending transaction intermediate state +- second-signer approval +- threshold broadcast +- pending transaction cleanup +- `legacy`, `hierarchical`, and `sdk` wallet script types + +Live assertions: + +- Signer 0 proposes a transaction from `/wallets/{id}/transactions/new`. +- Pending transaction card is visible after creation. +- `/api/v1/pendingTransactions` shows the transaction is still pending. +- Only signer 0 is listed in `signedAddresses` after proposal. +- `rejectedAddresses` is empty after proposal. +- The proposer does not see a duplicate `Approve & Sign` action. +- Signer 1 sees the pending transaction and the `Approve & Sign` action. +- Before signer 1 signs, the pending API still shows only signer 0 has signed. +- Signer 1 signs, reaches the 2-of-3 threshold, and broadcasts on-chain. +- The test waits for `[data-testid="tx-broadcast-success"]`. +- The pending card is removed and the pending API no longer returns the transaction. + +The ring runs these legs in parallel: + +- `legacy -> hierarchical` +- `hierarchical -> sdk` +- `sdk -> legacy` + +This means hierarchical wallets are still covered as both a recipient wallet and a +source/spending wallet, even though Summon import UI coverage is out of scope. + +## Backlog + +Route-chain and unit tests already cover many API and transaction-builder paths, so +new Playwright tests should focus on flows where the browser, wallet connector, page +guards, form state, and user-facing UI can regress. + +## Important Wallet-Type Constraint + +Hierarchical wallets are Summon platform wallets that users import. They should not be covered by "create wallet from UI" tests, and the Summon import path is intentionally out of scope because it is already done and is not expected to receive future updates. + +Playwright coverage should treat wallet types as follows: + +| Wallet type | UI creation coverage | Additional Playwright coverage | Notes | +|---|---:|---:|---| +| `legacy` | Yes | Transaction/signing flows | Native multisig wallet created by this app | +| `sdk` | Yes | Transaction/signing, staking, governance flows | App-created SDK multisig wallet | +| `hierarchical` | No | Existing ring-transfer coverage only | Summon platform wallet; import-only in this app | + +## Phase 1: Highest-Value Browser Coverage + +### 1. Create Wallet UI + +Status: live in `e2e/tests/create-wallet-ui.spec.ts`. + +Goal: prove users can create app-native multisig wallets from the browser. + +Coverage: + +- Create a `legacy` wallet with three signers and a 2-of-3 threshold. +- Create an `sdk` wallet with three signers and a 2-of-3 threshold. +- Validate signer rows, threshold controls, and review screen. +- Confirm native script summary renders. +- Save wallet and confirm it appears on `/wallets`. + +Out of scope: + +- Do not create `hierarchical` wallets through the UI. Those are Summon import wallets. + +### 2. New Transaction Form Validation + +Status: live in `e2e/tests/new-transaction-validation.spec.ts`. + +Goal: prove transaction creation errors are caught before users submit broken transactions. + +Coverage: + +- Invalid recipient address. +- Empty recipient address. +- Zero amount. +- Negative amount. +- Amount greater than selected UTxO balance. +- Add and remove recipient rows. +- Selected UTxO count updates before submit. +- Create button disabled or guarded when required fields are invalid. + +Optional coverage: + +- CSV recipient import. +- "Send all" behavior. +- Multiple-recipient transaction proposal. + +## Phase 2: Failure And Access-Control Coverage + +### 3. Rejected Wallet Signing + +Status: live in `e2e/tests/rejected-signing.spec.ts`. + +Goal: prove wallet rejection is handled cleanly. + +Coverage: + +- Mock `signTx` rejection during transaction proposal. +- Confirm no pending transaction is created (no `createTransaction` request, + empty pending API, no pending card). +- Mock `signTx` rejection during transaction approval. +- Confirm no signature is added (no `updateTransaction` request, pending API + still shows only the proposer, `rejectedAddresses` stays empty). +- Confirm visible error feedback appears and the form/card recovers. + +Notes: + +- Both tests run against a throwaway 2-of-3 wallet created via tRPC so the + intentionally-stranded pending transaction can never trip ring-transfer's + clean-pending precondition on the bootstrap wallets. The throwaway wallet is + unfunded; the UTxO fetch is mocked and nothing is broadcast. + +### 4. Wallet Access Control + +Status: live in `e2e/tests/wallet-access-control.spec.ts`. + +Goal: prove page guards match wallet authorization. + +Coverage: + +- Authenticated signer can open wallets they belong to. +- Authenticated signer cannot open a wallet they do not belong to. +- Direct navigation to protected wallet pages redirects or shows access denied. +- Protected routes remain protected after browser reload. + +Candidate pages: + +- `/wallets/{walletId}` +- `/wallets/{walletId}/transactions` +- `/wallets/{walletId}/transactions/new` +- `/wallets/{walletId}/info` +- `/wallets/{walletId}/staking` +- `/wallets/{walletId}/governance` + +Notes: + +- The non-member is a real derived preprod address (all-zero-entropy test + mnemonic) with a valid injected session cookie, so the test exercises the + authenticated-but-unauthorized path: `wallet.getWallet` returns FORBIDDEN, + no wallet content renders on any candidate page, and the REST + `pendingTransactions` endpoint also denies the address. +- Unauthenticated direct navigation asserts the layout's public-landing + fallback (Connect Wallet visible, no wallet content). + +### 5. Responsive Smoke Tests + +Status: live in `e2e/tests/responsive-smoke.spec.ts`. + +Goal: catch browser layout regressions on common mobile sizes. + +Coverage (at 375x667 and 412x915): + +- Wallet list. +- Wallet detail. +- Transaction list. +- New transaction page (mobile card layout, reachable create button). +- Wallet connect entry point (connect button + wallet dropdown). + +Assertions: + +- Critical controls are visible. +- No horizontal document overflow. +- Primary action buttons are reachable. + +Not covered on mobile: + +- The auth modal and the signing flow modal/card. Both need either a live + handshake or a pending transaction; their behavior is exercised at desktop + size by ring-transfer and rejected-signing. Add mobile variants only if a + mobile-specific layout bug shows up there. + +## Phase 3: Product-Area Browser Coverage + +### 6. Staking UI + +Status: live in `e2e/tests/staking-ui.spec.ts`. + +Goal: prove the SDK staking pages work in the browser. + +Coverage: + +- Staking page loads staking info from the (mocked) account state. +- No staking actions are offered until a pool ID is entered (register/delegate + and deregister gating). +- Inactive stake exposes `RegisterAndDelegate`; active stake exposes + `Delegate` + `Deregister`. +- Register+delegate certificate proposal creates a pending transaction + (verified via `/api/v1/pendingTransactions`, then deleted). + +Notes: + +- Runs against a throwaway 2-of-3 wallet created with the CI signers' stake + keys, so the app classifies it as `sdk` and the staking page can derive a + stake address. UTxOs and `/accounts/{stake}` are mocked; propose-only, no + broadcast (full certificate broadcast stays in route-chain). +- Requires `CI_STAKE_POOL_ID_HEX` (hex pool ID; a bech32 `pool1...` value is + normalized in-test). Read from the bootstrap context when present, else from + the env var forwarded by `docker-compose.playwright.yml`. + +### 7. DRep And Governance UI + +Status: live in `e2e/tests/governance-drep-ui.spec.ts`. + +Goal: prove governance actions can be initiated from the browser. + +Coverage: + +- Governance page loads for an eligible (throwaway SDK) wallet with a derived + DRep ID. +- DRep management actions are gated on registration state: Register enabled, + Update and Retire disabled while the (mocked) DRep is unregistered. +- DRep register form validation (submit disabled until name, objectives, + motivations, and qualifications are filled from `CI_DREP_ANCHOR_JSON`). +- DRep update form validation (same gating on the update page). +- Active proposals list loads (mocked Blockfrost `/governance/proposals` + + metadata) and renders the proposal title. +- Ballot modal opens (via the `New` toggle), creates a ballot via + `ballot.create`, and lists it (deleted afterwards via tRPC). + +Notes: + +- Retire is exercised as a gated action only: an actually-retirable DRep needs + a real on-chain registration, which is route-chain territory. +- The DRep form values come from `CI_DREP_ANCHOR_JSON`; keep it as single-line + CIP-119 JSON so Docker Compose can forward it to the Playwright runner. +- Submitting a DRep registration certificate from the browser is intentionally + not covered. That path canonicalizes the CIP-119 anchor with jsonld + (URDNA2015), which calls `crypto.subtle`; the Web Crypto API is only exposed + in a secure context, and the Docker app is served over plain + `http://webapp:3000`, so the build throws `crypto.subtle not found`. Staking + certificate proposals do not use jsonld (hence staking-ui covers + propose-to-pending), and DRep certificate building/broadcast is covered in + route-chain (`scenario.drep-certificates`). + +### 8. Proxy UI + +Status: live in `e2e/tests/proxy-ui.spec.ts`. + +Goal: prove proxy controls are usable from the browser. + +Coverage: + +- Proxy control panel loads on the wallet info page and expands. +- Empty state offers first-proxy setup; the setup modal opens with the step + indicator, collateral notice, description field, and an enabled + `Start Proxy Setup` action for a connected wallet. +- Existing proxy state is displayed: a proxy row seeded via + `proxy.createProxy` renders with its description and the panel's proxy + count reflects it (deleted afterwards via tRPC). + +Notes: + +- Setup proposal creation is intentionally not driven from the browser: the + auth-token mint is a Plutus transaction needing real collateral and funded + inputs, and the full lifecycle already has broad route-chain coverage + (`scenario.proxy-full-lifecycle`). + +### 9. Bot Management UI + +Status: live in `e2e/tests/bot-management-ui.spec.ts`. + +Goal: prove users can manage bot credentials through the UI. + +Coverage: + +- Register a pending bot over REST (`/api/v1/botRegister`) and claim it in + the UI with the one-time claim code (enter code → review → success). +- Requested scopes are shown at review time; unrequested scopes cannot be + approved. +- Claimed bot appears in the user bot list with name, key ID, scopes, and the + bot payment address. +- Edit scopes through the dialog and confirm the badge list updates. +- Revoke the bot (native confirm accepted) and confirm it disappears. + +Notes: + +- The app's bot model is claim-based; the bot's API secret is delivered via + `botPickupSecret` to the bot itself and never rendered in the UI, so the + original "generated secret is shown once" item maps to the one-time claim + code flow. + +### 10. Notification Center + +Status: live in `e2e/tests/notification-settings-ui.spec.ts`. + +Goal: prove signature notifications help users find pending work. + +Coverage: + +- Email Notifications card loads for a wallet signer on the wallet info page. +- Saving an email persists it and flips the badge from `No email` to + `Not verified`. +- `Send verification email` unlocks once an email is saved and reports + queued/prepared depending on whether delivery is enabled in the + environment. +- Preference toggles (transaction signatures) persist across a full reload. + +Notes: + +- Signature notifications in this app are email-based (per-signer settings + + server-side outbox/worker); there is no in-app notification inbox, so the + original "click notification → land on transaction" item has no browser + surface. The email pipeline itself (outbox rows, worker, verify link) is + server-side coverage outside this suite. + +## Suggested Implementation Order + +1. Create wallet UI for `legacy` and `sdk`. +2. New transaction form validation. +3. Rejected wallet signing. +4. Wallet access control. +5. Responsive smoke tests. +6. Staking UI. +7. DRep and governance UI. +8. Proxy UI. +9. Bot management UI. +10. Notification center. + +Optional diagnostic coverage: + +- Add a small wallet connect/auth-only spec if wallet auth becomes flaky or hard to diagnose through ring-transfer failures. Ring transfer already exercises wallet injection and signer auth, so this is not required for baseline coverage. + +## Test Design Notes + +- Prefer browser tests for user-visible behavior, wallet connector behavior, page guards, and form state. +- Prefer route-chain tests for real-chain API coverage, full certificate broadcast flows, and expensive proxy lifecycle checks. +- Keep real-chain Playwright tests narrow. Use mocked or intercepted browser responses for validation-heavy UI tests when the chain itself is not the subject. +- Reuse the existing wallet fixture and bootstrap context where possible. +- Avoid adding new funded-wallet requirements unless the test truly needs real preprod UTxOs. +- Do not add new Summon/hierarchical import tests unless that flow starts receiving product changes again. diff --git a/e2e/RUNNING_LOCALLY.md b/e2e/RUNNING_LOCALLY.md index 5748266b..824ad6db 100644 --- a/e2e/RUNNING_LOCALLY.md +++ b/e2e/RUNNING_LOCALLY.md @@ -1,11 +1,13 @@ # Running the Playwright E2E Tests Locally -The ring-transfer suite drives a real Cardano preprod browser flow: +The Playwright runner is the single local entry point for browser E2E tests in +`e2e/tests`. It currently includes the ring-transfer suite, which drives a real +Cardano preprod browser flow: CIP-0030 wallet injection -> transaction propose -> multi-sign -> on-chain broadcast. Use the Docker flow below when you want the local run to match CI. It starts Postgres, -starts the app, bootstraps the three CI wallets, then runs Playwright against the app -container. +starts the app, bootstraps the three CI wallets, then runs the full Playwright suite +against the app container. ## Prerequisites @@ -30,8 +32,15 @@ CI_NETWORK_ID=0 CI_NUM_REQUIRED_SIGNERS=2 CI_WALLET_TYPES=legacy,hierarchical,sdk CI_TRANSFER_LOVELACE=2000000 +# Hex (28-byte) preprod pool id — required by the staking-ui spec. +CI_STAKE_POOL_ID_HEX=f9c8e7275348d3b1a3596c94095f43307990cc5f800bbbb256298658 ``` +Keep every value on a single line: `docker compose --env-file` cannot parse +multi-line values, and one malformed entry breaks the whole file. The browser +governance spec and route-chain runner both expect `CI_DREP_ANCHOR_JSON` to stay +single-line minified JSON if you keep it in this file. + ## 2. First Clean Run Run these commands in order from the repo root. @@ -39,7 +48,8 @@ Run these commands in order from the repo root. ### PowerShell ```powershell -docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps ``` @@ -58,7 +68,8 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` ### Bash ```bash -docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps ``` @@ -77,9 +88,19 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ Bootstrap creates `ci-artifacts/ci-wallet-context.json`. The Playwright runner reads that file, so bootstrap must run before the test runner. +Do not continue after a failed or canceled image build. In particular, if the +`playwright-runner` image fails during `npm ci`, rebuild it after the registry +recovers before running tests; if the `app` build is canceled, rebuild `app` before +`up -d postgres app`. Otherwise Docker may start an older app image whose browser +bundle is missing the `.env.playwright` `NEXT_PUBLIC_*` values. + Use `--no-deps` when running `playwright-runner` after bootstrap. Without it, Docker Compose may try to run dependency services again, including bootstrap. +By default, `playwright-runner` executes all specs under `e2e/tests` using +`e2e/playwright.config.ts`. As new Playwright specs are added, they should be runnable +through this same command unless they intentionally require a different setup. + ## 3. Rerun After Changes Pick the smallest path that matches what changed. @@ -93,6 +114,8 @@ Pick the smallest path that matches what changed. ### Only test/framework changes +Runs the full Playwright suite: + ```powershell docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` --profile playwright run --rm --no-deps playwright-runner @@ -103,6 +126,29 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ --profile playwright run --rm --no-deps playwright-runner ``` +### Run a focused spec + +Use this when iterating on one Playwright file while keeping the same Docker app, +database, wallet context, and artifact paths. + +PowerShell: + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner ` + npx playwright test --config=e2e/playwright.config.ts e2e/tests/ring-transfer.spec.ts +``` + +Bash: + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner \ + npx playwright test --config=e2e/playwright.config.ts e2e/tests/ring-transfer.spec.ts +``` + +Replace `e2e/tests/ring-transfer.spec.ts` with any spec path under `e2e/tests`. + ### App code changes ```powershell @@ -197,12 +243,15 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down | `CI_CONTEXT_PATH` | Yes in containers | Path where bootstrap writes and tests read `ci-wallet-context.json`; provided by Docker Compose. | | `APP_URL` | No | Base URL of the running app; provided by Docker Compose for the runner. | | `CI_TRANSFER_LOVELACE` | No | Lovelace sent per ring-transfer leg. Defaults to `2000000` (2 ADA). | +| `CI_STAKE_POOL_ID_HEX` | Yes for `staking-ui.spec.ts` | Hex (28-byte) preprod stake pool id. A bech32 `pool1...` value is normalized in-test, but bootstrap and route-chain expect hex. Forwarded to both the bootstrap and Playwright runners. | +| `CI_DREP_ANCHOR_URL` | No | Used as the mocked anchor URL in `governance-drep-ui.spec.ts` (a fallback URL is used when unset). Required separately by the route-chain CI runner. | +| `CI_DREP_ANCHOR_JSON` | Yes for `governance-drep-ui.spec.ts` | CIP-119 anchor JSON used to fill the DRep register/update validation forms. It must stay single-line JSON because `docker compose --env-file` cannot parse multi-line values. | | `CI_NETWORK_ID` | No | `0` for preprod. Defaults to `0`. | | `CI_NUM_REQUIRED_SIGNERS` | No | Signing threshold. Defaults to `2`. | | `CI_WALLET_TYPES` | No | Comma-separated wallet types. Defaults to `legacy,hierarchical,sdk`. | | `PLAYWRIGHT_WORKERS` | No | Number of parallel Playwright workers. Defaults to `3` (one per ring-transfer leg). Set to `1` for serial execution. | -## How the Test Works +## How the Suite Works 1. Bootstrap creates three multisig wallets (`legacy`, `hierarchical`, and `sdk`) in the app DB and writes their wallet IDs, script addresses, and signer addresses to @@ -210,9 +259,15 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down 2. `global-setup.ts` validates env vars and caches the context JSON for the test run. -3. `ring-transfer.spec.ts` runs three legs in parallel, one Playwright worker +3. Playwright runs every spec in `e2e/tests` unless you pass a focused spec path. + Specs should reuse the existing fixtures and bootstrap context when possible so the + Docker runner remains the one local place to exercise browser coverage. + +4. `ring-transfer.spec.ts` runs three legs in parallel, one Playwright worker per leg. For each leg: - Signer 0 proposes a transaction from `/wallets/{id}/transactions/new`. + - The test verifies the pending transaction is still below threshold and only the + proposer has signed before the second signer acts. - The `window.cardano.meshci` mock intercepts `signTx` and bridges to `MeshWallet.signTx` in Node.js using the corresponding mnemonic. - Signer 1 signs from `/wallets/{id}/transactions`, reaching the 2-of-3 threshold @@ -220,13 +275,18 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down - The test waits for `[data-testid="tx-broadcast-success"]` and confirms the pending transaction is cleared via `/api/v1/pendingTransactions`. -4. Legs run in parallel. Each leg spends from a different multisig wallet +5. Ring-transfer legs run in parallel. Each leg spends from a different multisig wallet (legacy, hierarchical, sdk), so the legs never compete for the same UTxOs. Each source wallet must independently hold enough ADA for its transfer plus fees; if a previous run left a wallet short, the leg waits up to 5 minutes for the concurrently running leg that refills it. Set `PLAYWRIGHT_WORKERS=1` to fall back to serial execution. +When adding new specs, keep their state isolated from ring-transfer where possible. +The default worker count is `3` because the ring legs are designed to run in parallel; +set `PLAYWRIGHT_WORKERS=1` for serial debugging or for a new spec that is not yet +parallel-safe. + ## Troubleshooting **`CI_CONTEXT_PATH must be set`** - bootstrap did not run before the Playwright runner. @@ -257,6 +317,10 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright build docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d app ``` +This can happen after a build log containing `RUN npm ci ... exit code: 146` followed +by `RUN npm run build CANCELED`: the runner image had a network install failure and +the app image build was canceled, so the next `up` reused an older app image. + **`net::ERR_SSL_PROTOCOL_ERROR`** - `.app` is on Chromium's HSTS preload list, so `http://app:*` is upgraded to HTTPS. The Compose file uses the `webapp` network alias and the runner uses `http://webapp:3000`. Confirm both are still configured. diff --git a/e2e/helpers/apiHelpers.ts b/e2e/helpers/apiHelpers.ts new file mode 100644 index 00000000..f603c834 --- /dev/null +++ b/e2e/helpers/apiHelpers.ts @@ -0,0 +1,165 @@ +// Phase 2 (failure/access coverage): API-level helpers shared by the +// rejected-signing and wallet-access-control specs. +// +// - trpcMutate(): calls a tRPC mutation directly through the page's request +// context (shares the mesh_wallet_session cookie), bypassing the UI. Used to +// create throwaway wallets and clean up pending transactions. +// - createThrowawayWallet(): registers a fresh 2-of-3 wallet over the CI signer +// addresses. Rejection tests create pending transactions and must never do +// that on the bootstrap ring wallets — a leftover pending row would trip +// ring-transfer's expectNoPendingTransactions() on a parallel worker. +// - getPendingTransactionsRest(): REST read of pending transactions, mirroring +// the ring-transfer spec's verification path. + +import type { Page } from "@playwright/test"; +import jwt from "jsonwebtoken"; +import type { CIBootstrapContext } from "./contextLoader"; +const { sign } = jwt; + +export type PendingTransaction = { + id: string; + signedAddresses?: string[]; + rejectedAddresses?: string[]; + state?: number; +}; + +export type ThrowawayWallet = { + walletId: string; + /** Script (enterprise) address the app derives for this wallet. */ + address: string; +}; + +/** + * Calls a tRPC mutation via the page's cookie-authenticated request context. + * Input is wrapped in the superjson batch envelope the app's tRPC client uses. + */ +export async function trpcMutate( + page: Page, + procedure: string, + input: unknown, +): Promise { + const response = await page.request.post(`/api/trpc/${procedure}?batch=1`, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ "0": { json: input } }), + }); + const body = await response.text(); + if (!response.ok()) { + throw new Error(`${procedure} failed ${response.status()}: ${body}`); + } + type TrpcResultItem = { + result?: { data?: { json?: T } }; + error?: unknown; + }; + const parsed = JSON.parse(body) as TrpcResultItem | TrpcResultItem[]; + const item = Array.isArray(parsed) ? parsed[0] : parsed; + if (item?.error) { + throw new Error(`${procedure} error: ${JSON.stringify(item.error)}`); + } + return item?.result?.data?.json as T; +} + +/** + * Builds the same 2-of-3 atLeast native script buildWallet derives for a + * legacy wallet (payment key hashes in signer order, no stake credential), + * so the returned address matches what the app will display and query. + */ +export async function buildTwoOfThreeScript( + signerAddresses: string[], +): Promise<{ scriptCbor: string; address: string }> { + const { deserializeAddress, serializeNativeScript } = await import( + "@meshsdk/core" + ); + const nativeScript = { + type: "atLeast" as const, + required: 2, + scripts: signerAddresses.map((address) => ({ + type: "sig" as const, + keyHash: deserializeAddress(address).pubKeyHash, + })), + }; + const { scriptCbor, address } = serializeNativeScript( + nativeScript, + undefined, + 0, + ); + if (!scriptCbor) { + throw new Error("serializeNativeScript returned no scriptCbor"); + } + return { scriptCbor, address }; +} + +/** + * Creates an isolated legacy 2-of-3 wallet over the CI signer addresses via + * tRPC. The wallet needs no funding: specs that use it mock the UTxO fetch, + * and nothing is ever broadcast (threshold is never reached). + * + * With `withStakeKeys` the wallet also registers the CI signers' stake + * addresses, which classifies it as an SDK wallet in the app (role-2 keys): + * the staking and governance pages can then derive a stake address and + * staking script for it. The returned `address` is still the payment-only + * enterprise address; SDK wallets render their canonical stakeable address + * instead, so UTxO mocks for those specs must match any address. + */ +export async function createThrowawayWallet( + page: Page, + ctx: CIBootstrapContext, + name: string, + options: { withStakeKeys?: boolean } = {}, +): Promise { + const signers = ctx.signerAddresses.slice(0, 3); + if (signers.length < 3) { + throw new Error("Bootstrap context must provide at least 3 signer addresses"); + } + let signersStakeKeys: string[] | null = null; + if (options.withStakeKeys) { + const stakeAddresses = (ctx.signerStakeAddresses ?? []).slice(0, 3); + if (stakeAddresses.length < 3) { + throw new Error( + "Bootstrap context must provide 3 signer stake addresses (schemaVersion 3) for withStakeKeys", + ); + } + signersStakeKeys = stakeAddresses; + } + const { scriptCbor, address } = await buildTwoOfThreeScript(signers); + const wallet = await trpcMutate<{ id: string }>(page, "wallet.createWallet", { + name, + description: "Throwaway wallet for Playwright failure-path coverage", + signersAddresses: signers, + signersDescriptions: ["Signer 1", "Signer 2", "Signer 3"], + signersStakeKeys, + signersDRepKeys: null, + numRequiredSigners: 2, + scriptCbor, + type: "atLeast", + }); + if (!wallet?.id) { + throw new Error(`createWallet returned no wallet id: ${JSON.stringify(wallet)}`); + } + return { walletId: wallet.id, address }; +} + +/** Signs a short-lived REST bearer token for /api/v1 endpoints. */ +export function buildRestToken(address: string): string { + const jwtSecret = process.env.CI_JWT_SECRET; + if (!jwtSecret) { + throw new Error("CI_JWT_SECRET must be set to call /api/v1 endpoints"); + } + return sign({ address }, jwtSecret, { expiresIn: "1h" }); +} + +export async function getPendingTransactionsRest( + page: Page, + walletId: string, + signerAddress: string, +): Promise { + const response = await page.request.get( + `/api/v1/pendingTransactions?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(signerAddress)}`, + { headers: { Authorization: `Bearer ${buildRestToken(signerAddress)}` } }, + ); + if (!response.ok()) { + throw new Error( + `pendingTransactions failed ${response.status()}: ${await response.text().catch(() => "")}`, + ); + } + return (await response.json()) as PendingTransaction[]; +} diff --git a/e2e/helpers/phase3Mocks.ts b/e2e/helpers/phase3Mocks.ts new file mode 100644 index 00000000..c7e6cc4b --- /dev/null +++ b/e2e/helpers/phase3Mocks.ts @@ -0,0 +1,255 @@ +// Phase 3 (product-area browser coverage): shared browser-side mocks for the +// staking, governance, proxy, bot, and notification specs. +// +// These specs run against throwaway wallets that are never funded, so every +// chain read the pages perform is intercepted in the browser: +// - address UTxOs -> one large fake UTxO, echoing whatever address the +// page asked about (SDK wallets render their canonical +// stakeable address, which the specs never compute) +// - account state -> deterministic staking state (active / inactive) +// - governance -> fixed proposal list + metadata, no registered DRep +// - stake pool list -> empty (specs enter the pool id manually) +// +// Nothing is ever broadcast: the throwaway wallets have a 2-of-3 threshold and +// only signer 0 ever signs, so proposals stay pending and are deleted in +// cleanup. + +import type { Page } from "@playwright/test"; +import { loadContext, type CIBootstrapContext } from "./contextLoader"; +import { + getPendingTransactionsRest, + type PendingTransaction, +} from "./apiHelpers"; + +export const FAKE_UTXO_TX_HASH = "3".repeat(64); + +/** + * Serves one fake UTxO for every address the page asks about, echoing the + * requested address back so the UTxO always matches the address the app + * queried (throwaway SDK wallets display a stakeable address the spec never + * derives). Page 2+ returns [] so BlockfrostProvider pagination terminates. + * + * The default 600 ADA covers the DRep registration deposit (500 ADA), the + * stake registration deposit (2 ADA), and fees for every Phase 3 proposal. + */ +export async function mockWalletUtxos( + page: Page, + options: { lovelace?: string } = {}, +): Promise { + const lovelace = options.lovelace ?? "600000000"; + await page.route(/\/addresses\/[^/]+\/utxos/, async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + const segments = url.pathname.split("/"); + const address = decodeURIComponent( + segments[segments.indexOf("addresses") + 1] ?? "", + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + address, + tx_hash: FAKE_UTXO_TX_HASH, + output_index: 0, + amount: [{ unit: "lovelace", quantity: lovelace }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, + }, + ] + : [], + ), + }); + }); +} + +/** + * Mocks the Blockfrost `/accounts/{stakeAddress}` read the staking page uses. + * `active: false` exposes the RegisterAndDelegate action; `active: true` + * exposes Delegate + Deregister instead. + */ +export async function mockAccountState( + page: Page, + options: { active: boolean; poolId?: string | null; rewards?: string }, +): Promise { + await page.route(/\/accounts\/[^/]+$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + stake_address: "mocked", + active: options.active, + pool_id: options.poolId ?? null, + controlled_amount: "600000000", + rewards_sum: options.rewards ?? "0", + withdrawals_sum: "0", + reserves_sum: "0", + treasury_sum: "0", + drep_id: null, + }), + }); + }); +} + +/** Mocks `/pools/extended` so PoolSelector renders without live Blockfrost. */ +export async function mockPoolList(page: Page): Promise { + await page.route(/\/pools\/extended/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); +} + +export const MOCK_PROPOSAL_TX_HASH = "4".repeat(64); +export const MOCK_PROPOSAL_TITLE = "CI Mock Proposal"; + +/** + * Mocks the governance reads the governance page performs: + * - `/governance/proposals?...` -> a single mock info action + * - `/governance/proposals/{tx}/{idx}` -> minimal details + * - `.../metadata` -> metadata carrying MOCK_PROPOSAL_TITLE + * - `/governance/dreps/...` -> 404, i.e. the wallet DRep is not + * registered (Register enabled, + * Update/Retire disabled) + */ +export async function mockGovernanceState(page: Page): Promise { + await page.route(/\/governance\/(proposals|dreps)/, async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + + if (path.includes("/governance/dreps")) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ status_code: 404, error: "Not Found", message: "" }), + }); + return; + } + + if (path.endsWith("/metadata")) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + hash: "0".repeat(64), + url: "https://example.com/ci-mock-proposal.jsonld", + bytes: "", + json_metadata: { + body: { + title: MOCK_PROPOSAL_TITLE, + abstract: "Mock proposal served by the Playwright governance spec.", + motivation: "", + rationale: "", + references: [], + }, + authors: [], + }, + }), + }); + return; + } + + // Details for a single proposal: /governance/proposals/{tx_hash}/{cert_index} + if (/\/governance\/proposals\/[^/]+\/\d+$/.test(path)) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + governance_description: { tag: "InfoAction" }, + deposit: "100000000", + return_address: "stake_test1mocked", + expiration: 999, + id: `gov_action1${"q".repeat(50)}`, + }), + }); + return; + } + + // Proposal list: page 1 has the mock proposal, later pages are empty. + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + ratified_epoch: null, + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + expiration: 999, + }, + ] + : [], + ), + }); + }); +} + +/** + * Resolves the stake pool id (hex) for staking specs: prefer the bootstrap + * context (written by scripts/ci bootstrap from CI_STAKE_POOL_ID_HEX), fall + * back to the env var directly, and normalize a bech32 `pool1...` value to + * hex so either form works. + */ +export async function resolveStakePoolHex( + ctx: CIBootstrapContext, +): Promise { + const raw = (ctx.stakePoolIdHex ?? process.env.CI_STAKE_POOL_ID_HEX ?? "").trim(); + if (!raw) { + throw new Error( + "CI_STAKE_POOL_ID_HEX must be set (env or bootstrap context) for the staking spec", + ); + } + if (raw.startsWith("pool1")) { + const { deserializePoolId } = await import("@meshsdk/core"); + return deserializePoolId(raw); + } + return raw; +} + +/** + * Polls the REST pending-transactions endpoint until exactly one transaction + * is pending for the wallet, then returns it. Mirrors the rejected-signing + * spec: right after creation the rendered card may still be the optimistic + * `temp-...` React Query entry, so the DB id must come from the API. + */ +export async function waitForSinglePendingTransaction( + page: Page, + walletId: string, + signerAddress: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + // Transient request failures (socket hang up under load) just mean + // "poll again" — only the deadline is fatal. + const pending = await getPendingTransactionsRest( + page, + walletId, + signerAddress, + ).catch(() => null); + if (pending?.length === 1) return pending[0]!; + await page.waitForTimeout(2_000); + } + throw new Error( + "Proposed transaction never appeared in /api/v1/pendingTransactions", + ); +} + +export { loadContext }; diff --git a/e2e/playwright-report/index.html b/e2e/playwright-report/index.html new file mode 100644 index 00000000..375eb98d --- /dev/null +++ b/e2e/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/e2e/tests/bot-management-ui.spec.ts b/e2e/tests/bot-management-ui.spec.ts new file mode 100644 index 00000000..23470dc7 --- /dev/null +++ b/e2e/tests/bot-management-ui.spec.ts @@ -0,0 +1,130 @@ +// Phase 3 item 9: Bot management UI. +// +// Proves users can manage bot credentials through the /user page. The app's +// bot model is claim-based: a bot registers itself over REST +// (POST /api/v1/botRegister -> pendingBotId + one-time claim code) and the +// user claims it in the UI, approving its requested scopes. The bot's API +// secret is delivered to the bot via botPickupSecret, never shown in the UI, +// so "generated secret shown once" from the original plan maps to the +// one-time claim code + claim flow here. +// +// Coverage: +// - claim a freshly registered bot (ID + claim code -> review -> success) +// - the claimed bot appears in the bot list with name, key id, and scopes +// - the bot's payment address is visible after claiming +// - edit scopes through the dialog +// - revoke the bot and confirm it disappears +// +// The spec registers its own pending bot with a unique fake payment address, +// so it never collides with route-chain bots or other workers. + +import { test, expect } from "../fixtures/authFixture"; + +test.describe("bot management UI", () => { + test("claim, inspect, edit scopes, and revoke a bot", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + + await authenticateAs(page, 0); + + // Register a pending bot the way a real bot would, over REST. + const botName = `ci-playwright-bot-${Date.now()}-${test.info().workerIndex}`; + const paymentAddress = `addr_test1ciplaywrightbot${Date.now()}${test.info().workerIndex}`; + const registerResponse = await page.request.post("/api/v1/botRegister", { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ + name: botName, + paymentAddress, + requestedScopes: ["multisig:read", "multisig:sign"], + }), + }); + expect( + registerResponse.ok(), + `botRegister failed ${registerResponse.status()}: ${await registerResponse.text().catch(() => "")}`, + ).toBe(true); + const { pendingBotId, claimCode } = (await registerResponse.json()) as { + pendingBotId: string; + claimCode: string; + }; + expect(pendingBotId).toBeTruthy(); + expect(claimCode).toBeTruthy(); + + await page.goto("/user"); + await expect(page.getByRole("heading", { name: "Bot accounts" })).toBeVisible({ + timeout: 60_000, + }); + + // Step 1: enter the bot id and claim code. + await page.getByRole("button", { name: "Claim a bot" }).click(); + await page.getByLabel("Bot ID").fill(pendingBotId); + await page.getByLabel("Claim code").fill(claimCode); + await page.getByRole("button", { name: "Next" }).click(); + + // Step 2: review shows the bot's identity and requested scopes. + await expect(page.getByText(botName)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Requested scopes")).toBeVisible(); + await expect(page.locator("#claim-scope-multisig\\:read")).toBeChecked(); + await expect(page.locator("#claim-scope-multisig\\:sign")).toBeChecked(); + // Scopes the bot did not request cannot be approved. + await expect(page.locator("#claim-scope-ballot\\:write")).toBeDisabled(); + + await page.getByRole("button", { name: "Claim bot" }).click(); + + // Step 3: success confirmation, then the bot shows up in the list. + await expect(page.getByText("Bot claimed successfully")).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Done" }).click(); + + // Toasts also render as
  • and the claim toast contains the bot name, + // so anchor the row on its Edit scopes action. + const botRow = page + .locator("li", { hasText: botName }) + .filter({ has: page.getByRole("button", { name: "Edit scopes" }) }); + await expect(botRow).toBeVisible({ timeout: 30_000 }); + await expect(botRow.getByText("multisig:read")).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow.getByText("multisig:sign")).toBeVisible(); + await expect(botRow.getByText("Key ID")).toBeVisible(); + // The payment address row appears once the bot user record is linked. + await expect(botRow.getByText("Bot address")).toBeVisible(); + + // Edit scopes: drop the sign scope, keep read. + await botRow.getByRole("button", { name: "Edit scopes" }).click(); + await expect( + page.getByRole("heading", { name: "Edit scopes" }), + ).toBeVisible(); + await page.locator("#edit-scope-multisig\\:sign").click(); + const updateScopesResponsePromise = page.waitForResponse( + (response) => + response.url().includes("bot.updateBotKeyScopes") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const updateScopesResponse = await updateScopesResponsePromise; + expect( + updateScopesResponse.ok(), + `bot.updateBotKeyScopes failed ${updateScopesResponse.status()}`, + ).toBe(true); + + await expect(botRow.getByText("multisig:read")).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow.getByText("multisig:sign")).toHaveCount(0, { + timeout: 30_000, + }); + + // Revoke: the delete action asks for a native confirm() first. + page.once("dialog", (dialog) => void dialog.accept()); + // The delete button is icon-only; it is the row's only destructive action. + await botRow.locator("button.text-destructive").click(); + await expect(page.getByText("Bot revoked").first()).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow).toHaveCount(0, { timeout: 30_000 }); + }); +}); diff --git a/e2e/tests/create-wallet-ui.spec.ts b/e2e/tests/create-wallet-ui.spec.ts new file mode 100644 index 00000000..580b5381 --- /dev/null +++ b/e2e/tests/create-wallet-ui.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +type WalletCreationMode = "legacy" | "sdk"; + +async function waitForTrpc(page: Page, procedure: string): Promise { + const response = await page.waitForResponse( + (r) => r.url().includes(`/api/trpc/${procedure}`) && r.request().method() === "POST", + { timeout: 30_000 }, + ); + expect( + response.ok(), + `${procedure} failed ${response.status()}: ${await response.text().catch(() => "")}`, + ).toBe(true); +} + +async function saveSigner( + page: Page, + signer: { + address?: string; + name: string; + stakeKey?: string; + drepKey?: string; + clearStakeKey?: boolean; + }, +): Promise { + if (signer.address !== undefined) { + await page.getByLabel("Address").fill(signer.address); + } + await page.getByLabel(/Signer name/i).fill(signer.name); + if (signer.stakeKey !== undefined) { + await page.getByLabel(/Stake Key/i).fill(signer.stakeKey); + } + if (signer.drepKey !== undefined) { + await page.getByLabel(/DRep Key/i).fill(signer.drepKey); + } + if (signer.clearStakeKey) { + await page.getByLabel(/Stake Key/i).fill(""); + await page.getByLabel(/DRep Key/i).fill(""); + } + + const saveButton = page.getByRole("button", { name: /^Save$/ }); + await expect(saveButton).toBeEnabled({ timeout: 10_000 }); + const updatePromise = waitForTrpc(page, "wallet.updateNewWallet"); + await saveButton.click(); + await updatePromise; +} + +async function addSigner( + page: Page, + signer: { + address: string; + name: string; + stakeKey?: string; + drepKey?: string; + clearStakeKey?: boolean; + }, +): Promise { + await page.getByRole("button", { name: /add signer/i }).click(); + await saveSigner(page, signer); +} + +async function setThresholdToTwoOfThree(page: Page): Promise { + await page.getByRole("button", { name: "Edit" }).last().click(); + await page.getByRole("radio", { name: "2" }).click(); + const updatePromise = waitForTrpc(page, "wallet.updateNewWallet"); + await page.getByRole("button", { name: /^Save$/ }).click(); + await updatePromise; + await expect(page.getByText("2 of 3 signers must approve")).toBeVisible(); +} + +async function createWalletThroughUi( + page: Page, + mode: WalletCreationMode, +): Promise { + const ctx = loadContext(); + const suffix = `${mode}-${Date.now()}-${test.info().workerIndex}`; + const walletName = `E2E ${mode.toUpperCase()} ${suffix}`; + const clearStakeKey = mode === "legacy"; + + await page.goto("/wallets/new-wallet-flow/save"); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + + await page.getByLabel("Name", { exact: true }).fill(walletName); + await page.getByLabel(/Description/i).fill(`Playwright ${mode} wallet creation coverage`); + await page.getByLabel(/Your name/i).fill("Signer 1"); + + const createDraftPromise = waitForTrpc(page, "wallet.createNewWallet"); + await page.getByRole("button", { name: /save & continue/i }).click(); + await createDraftPromise; + await expect(page).toHaveURL(/\/wallets\/new-wallet-flow\/create\/[^/]+$/); + + await expect(page.locator("tbody tr").first()).toBeVisible(); + await page.locator("tbody tr").first().locator("button").first().click(); + await saveSigner(page, { + name: "Signer 1", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[0], + clearStakeKey, + }); + + await addSigner(page, { + address: ctx.signerAddresses[1]!, + name: "Signer 2", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[1], + clearStakeKey, + }); + await addSigner(page, { + address: ctx.signerAddresses[2]!, + name: "Signer 3", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[2], + clearStakeKey, + }); + + await expect(page.locator("tbody tr")).toHaveCount(3); + + await setThresholdToTwoOfThree(page); + + await page.getByRole("button", { name: /advanced/i }).click(); + await expect(page.getByRole("heading", { name: "Native Script" })).toBeVisible(); + await expect(page.getByText("Payment Script")).toBeVisible(); + + const createWalletPromise = waitForTrpc(page, "wallet.createWallet"); + await page.getByRole("button", { name: /^Create$/ }).click(); + await createWalletPromise; + await expect(page).toHaveURL(/\/wallets\/new-wallet-flow\/ready\/[^/]+$/); + await expect(page.getByText("Wallet created successfully")).toBeVisible(); + + await page.getByRole("button", { name: /view all wallets/i }).click(); + await expect(page).toHaveURL(/\/wallets$/); + await expect(page.getByText(walletName)).toBeVisible({ timeout: 30_000 }); + + return walletName; +} + +test.describe("create wallet UI", () => { + test.describe.configure({ mode: "serial" }); + + test("creates a legacy 2-of-3 wallet from the browser", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await createWalletThroughUi(page, "legacy"); + }); + + test("creates an SDK 2-of-3 wallet from the browser", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await createWalletThroughUi(page, "sdk"); + }); +}); diff --git a/e2e/tests/governance-drep-ui.spec.ts b/e2e/tests/governance-drep-ui.spec.ts new file mode 100644 index 00000000..6ff83dc1 --- /dev/null +++ b/e2e/tests/governance-drep-ui.spec.ts @@ -0,0 +1,261 @@ +// Phase 3 item 7: DRep and governance UI. +// +// Proves governance actions can be initiated from the browser without +// touching the chain: +// - the governance page loads for an eligible wallet: DRep info card, +// DRep management actions gated on registration state, mocked proposal +// list renders +// - DRep register/update forms validate their required fields +// - the ballot modal opens, creates a ballot through tRPC, and lists it +// +// Runs against throwaway 2-of-3 SDK wallets (CI signer stake keys) so any +// created row can never trip ring-transfer's clean-pending precondition. +// UTxOs, account state, DRep status, and the proposal list are all mocked. +// +// Not covered here: actually submitting a DRep registration certificate from +// the browser. That path canonicalizes the CIP-119 anchor with jsonld +// (URDNA2015), which calls `crypto.subtle` — the Web Crypto API is only +// exposed in a secure context, and the Docker app is served over plain +// http://webapp:3000, so `crypto.subtle` is undefined and the build throws +// "crypto.subtle not found". Staking certificate proposals do not hit jsonld, +// which is why staking-ui covers propose-to-pending and this spec does not. +// DRep certificate building/broadcast stays in route-chain coverage +// (scripts/ci drep-certificates). + +import { test, expect } from "../fixtures/authFixture"; +import type { Page } from "@playwright/test"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { mockWalletUtxos, mockGovernanceState, MOCK_PROPOSAL_TITLE } from "../helpers/phase3Mocks"; + +// DRepForm textareas in DOM order; the fields have no placeholders or testids. +const TEXTAREA_INDEX = { bio: 0, objectives: 1, motivations: 2, qualifications: 3 }; + +type CiDrepAnchorJson = { + body?: { + givenName?: unknown; + objectives?: unknown; + motivations?: unknown; + qualifications?: unknown; + }; +}; + +function requiredString(value: unknown, fieldName: string): string { + if (typeof value === "string" && value.trim()) return value; + throw new Error(`CI_DREP_ANCHOR_JSON body.${fieldName} must be a non-empty string`); +} + +function getCiDrepFormValues(): { + givenName: string; + objectives: string; + motivations: string; + qualifications: string; +} { + const raw = process.env.CI_DREP_ANCHOR_JSON; + if (!raw) { + throw new Error("CI_DREP_ANCHOR_JSON must be set for governance-drep-ui.spec.ts"); + } + + let parsed: CiDrepAnchorJson; + try { + parsed = JSON.parse(raw) as CiDrepAnchorJson; + } catch (error) { + throw new Error( + `CI_DREP_ANCHOR_JSON must be valid single-line JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const body = parsed.body; + if (!body) { + throw new Error("CI_DREP_ANCHOR_JSON must include a body object"); + } + + const givenName = requiredString(body.givenName, "givenName").replace(/\s+/g, ""); + if (!givenName) { + throw new Error("CI_DREP_ANCHOR_JSON body.givenName must contain non-whitespace characters"); + } + + return { + givenName, + objectives: requiredString(body.objectives, "objectives"), + motivations: requiredString(body.motivations, "motivations"), + qualifications: requiredString(body.qualifications, "qualifications"), + }; +} + +async function fillRequiredDrepFields(page: Page): Promise { + const drep = getCiDrepFormValues(); + await page.getByPlaceholder("name must be without spaces").fill(drep.givenName); + await page.locator("textarea").nth(TEXTAREA_INDEX.objectives).fill(drep.objectives); + await page.locator("textarea").nth(TEXTAREA_INDEX.motivations).fill(drep.motivations); + await page + .locator("textarea") + .nth(TEXTAREA_INDEX.qualifications) + .fill(drep.qualifications); +} + +test.describe("governance and DRep UI", () => { + test("governance page loads with DRep info, gated management actions, and proposals", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-page ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/governance`); + + // DRep info card renders with a derived DRep ID. + await expect(page.getByRole("heading", { name: "DRep Information" })).toBeVisible({ + timeout: 60_000, + }); + await expect(page.locator("code", { hasText: /^drep/ })).toBeVisible({ + timeout: 30_000, + }); + + // Management actions are gated on registration state: the mocked DRep + // status is "not registered", so only Register is actionable. + await page.getByText("DRep Management").click(); + await expect( + page.getByRole("button", { name: "Register DRep", exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Update DRep", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: /^Retire DRep/ }), + ).toBeDisabled(); + + // The mocked active-proposals list hydrates and renders. + await expect(page.getByText(MOCK_PROPOSAL_TITLE).first()).toBeVisible({ + timeout: 60_000, + }); + }); + + test("ballot modal opens, creates a ballot, and lists it", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-ballot ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/governance`); + await expect(page.getByRole("heading", { name: "DRep Information" })).toBeVisible({ + timeout: 60_000, + }); + + await page.getByRole("button", { name: "Manage Ballots" }).click(); + await expect( + page.getByRole("heading", { name: "Manage Ballots" }), + ).toBeVisible(); + + // The create input is revealed by the "New" toggle. + await page.getByRole("button", { name: "New" }).click(); + + const ballotName = `CI ballot ${Date.now()}`; + const createResponsePromise = page.waitForResponse( + (response) => + response.url().includes("ballot.create") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); + await page.getByPlaceholder("Enter ballot name...").fill(ballotName); + await page.getByRole("button", { name: "Create", exact: true }).click(); + const createResponse = await createResponsePromise; + expect( + createResponse.ok(), + `ballot.create failed ${createResponse.status()}`, + ).toBe(true); + + // Extract the created ballot id from the tRPC envelope for cleanup. + type TrpcEnvelope = { result?: { data?: { json?: { id?: string } } } }; + const createBody = (await createResponse.json()) as + | TrpcEnvelope + | TrpcEnvelope[]; + const createItem = Array.isArray(createBody) ? createBody[0] : createBody; + const ballotId = createItem?.result?.data?.json?.id; + + try { + await expect(page.getByText(ballotName).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + if (ballotId) { + await trpcMutate(page, "ballot.delete", { ballotId }).catch(() => {}); + } + } + }); + + test("DRep register and update forms validate required fields", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-validate ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + const drep = getCiDrepFormValues(); + + // Register form: submit stays disabled until every required field is set. + await page.goto(`/wallets/${wallet.walletId}/governance/register`); + const registerButton = page.getByRole("button", { + name: "Register DRep", + exact: true, + }); + await expect(registerButton).toBeVisible({ timeout: 60_000 }); + await expect(registerButton).toBeDisabled(); + + await page.getByPlaceholder("name must be without spaces").fill(drep.givenName); + await expect(registerButton).toBeDisabled(); + + await page.locator("textarea").nth(TEXTAREA_INDEX.objectives).fill(drep.objectives); + await page.locator("textarea").nth(TEXTAREA_INDEX.motivations).fill(drep.motivations); + await expect(registerButton).toBeDisabled(); + + await page + .locator("textarea") + .nth(TEXTAREA_INDEX.qualifications) + .fill(drep.qualifications); + await expect(registerButton).toBeEnabled(); + + // Update form: same required-field gating with the update action. + await page.goto(`/wallets/${wallet.walletId}/governance/update`); + const updateButton = page.getByRole("button", { + name: "Update DRep", + exact: true, + }); + await expect(updateButton).toBeVisible({ timeout: 60_000 }); + await expect(updateButton).toBeDisabled(); + await fillRequiredDrepFields(page); + await expect(updateButton).toBeEnabled(); + }); +}); diff --git a/e2e/tests/new-transaction-validation.spec.ts b/e2e/tests/new-transaction-validation.spec.ts new file mode 100644 index 00000000..04e09234 --- /dev/null +++ b/e2e/tests/new-transaction-validation.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "../fixtures/authFixture"; +import { getWallet, loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const SMALL_UTXO = { + tx_hash: "1".repeat(64), + output_index: 0, + amount: [{ unit: "lovelace", quantity: "3000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, +}; + +async function mockUtxos(page: Page, address: string): Promise { + const encodedAddress = encodeURIComponent(address); + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname.includes(`/addresses/${encodedAddress}/utxos`) || + url.pathname.includes(`/addresses/${address}/utxos`) + ) { + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pageNumber === 1 ? [SMALL_UTXO] : []), + }); + return; + } + + await route.fallback(); + }); +} + +async function expectValidation(page: Page, message: RegExp): Promise { + await expect(page.getByText(message).first()).toBeVisible(); + await expect(page.getByTestId("create-transaction-button")).toBeDisabled(); +} + +test("new transaction form validates recipients, amounts, and selected UTxOs", async ({ + page, + authenticateAs, +}) => { + const ctx = loadContext(); + const sourceWallet = getWallet(ctx, "legacy"); + const destinationWallet = getWallet(ctx, "sdk"); + + await authenticateAs(page, 0); + await mockUtxos(page, sourceWallet.walletAddress); + + await page.goto(`/wallets/${sourceWallet.walletId}/transactions/new`); + await page.waitForSelector('[data-testid="utxo-selector"][data-loaded="true"]', { + timeout: 30_000, + }); + + await expectValidation(page, /address is required/i); + + await page.getByTestId("recipient-address-input-0").fill("not-a-cardano-address"); + await page.getByTestId("amount-input-0").fill("1"); + await expectValidation(page, /address is invalid/i); + + await page.getByTestId("recipient-address-input-0").fill(destinationWallet.walletAddress); + await page.getByTestId("amount-input-0").fill("0"); + await expectValidation(page, /amount must be greater than zero/i); + + await page.getByTestId("amount-input-0").fill("-1"); + await expectValidation(page, /amount must be greater than zero/i); + + await page.getByTestId("amount-input-0").fill("999"); + await expectValidation(page, /exceeds the selected UTxO balance/i); + + await page.getByTestId("amount-input-0").fill("1"); + await expect(page.getByText("Input UTxOs (1)")).toBeVisible(); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); + + await page.getByRole("button", { name: /add recipient/i }).click(); + await expect(page.locator('[data-testid^="recipient-address-input-"]:not([data-testid*="mobile"])')).toHaveCount(2); + await expectValidation(page, /Recipient 2: address is required/i); + + await page.locator("tbody tr").nth(1).locator("button").last().click(); + await expect(page.locator('[data-testid^="recipient-address-input-"]:not([data-testid*="mobile"])')).toHaveCount(1); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); +}); diff --git a/e2e/tests/notification-settings-ui.spec.ts b/e2e/tests/notification-settings-ui.spec.ts new file mode 100644 index 00000000..f9cb20aa --- /dev/null +++ b/e2e/tests/notification-settings-ui.spec.ts @@ -0,0 +1,101 @@ +// Phase 3 item 10: Notification center. +// +// The app's signature notifications are email-based: each signer manages +// per-wallet email preferences on the wallet info page, and a server-side +// outbox/worker sends "signature required" emails whose links land on the +// wallet's transactions page. There is no in-app notification inbox, so the +// browser coverage targets the notification settings surface: +// - the Email Notifications card loads for a wallet signer +// - saving an email persists it and flips the badge to "Not verified" +// - the verification email action is offered once an email is saved +// (in test environments delivery is disabled and the API reports the +// email as "prepared" instead of "queued") +// - preference toggles persist across a reload +// +// The email delivery pipeline itself (outbox rows, worker, verify link) is +// server-side and covered outside the browser suite. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet } from "../helpers/apiHelpers"; +import { mockWalletUtxos } from "../helpers/phase3Mocks"; + +test.describe("notification settings UI", () => { + test("signer saves an email, sees verification state, and toggles persist", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E notifications ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect( + page.getByRole("heading", { name: "Email Notifications" }), + ).toBeVisible({ timeout: 60_000 }); + + // Fresh wallet+signer pair: no email is stored yet. + await expect(page.getByText("No email", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // Save an email address. + const email = `ci-playwright-${Date.now()}@example.com`; + const emailInput = page.getByLabel("Email address"); + await expect(emailInput).toBeEnabled({ timeout: 30_000 }); + await emailInput.fill(email); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Notification settings saved").first()).toBeVisible({ + timeout: 30_000, + }); + + // Saved but unverified: badge flips and the verification action unlocks. + await expect(page.getByText("Not verified", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(email)).toBeVisible(); + const sendVerification = page.getByRole("button", { + name: "Send verification email", + }); + await expect(sendVerification).toBeEnabled({ timeout: 30_000 }); + await sendVerification.click(); + await expect( + page.getByText(/Verification email (queued|prepared)/).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Turn off transaction-signature emails; the change persists server-side. + const transactionsToggle = page.getByRole("switch", { + name: "Toggle transaction signature notifications", + }); + await expect(transactionsToggle).toBeEnabled({ timeout: 30_000 }); + await expect(transactionsToggle).toHaveAttribute("aria-checked", "true"); + await transactionsToggle.click(); + await expect(page.getByText("Notification settings saved").first()).toBeVisible({ + timeout: 30_000, + }); + + // Everything survives a full reload: email, badge, and the toggle. + await page.reload(); + await expect( + page.getByRole("heading", { name: "Email Notifications" }), + ).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Not verified", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByLabel("Email address")).toHaveValue(email, { + timeout: 30_000, + }); + await expect( + page.getByRole("switch", { + name: "Toggle transaction signature notifications", + }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + }); +}); diff --git a/e2e/tests/proxy-ui.spec.ts b/e2e/tests/proxy-ui.spec.ts new file mode 100644 index 00000000..a7dd929e --- /dev/null +++ b/e2e/tests/proxy-ui.spec.ts @@ -0,0 +1,133 @@ +// Phase 3 item 8: Proxy UI. +// +// Proves the proxy control panel is usable from the browser: +// - the panel loads on the wallet info page and expands +// - with no proxies, the empty state offers first-proxy setup and the setup +// modal opens with its step flow and description field +// - an existing proxy row (seeded via tRPC) is displayed with its +// description and reflected in the panel's proxy count +// +// Full proxy setup (auth-token mint) is a Plutus transaction that needs real +// collateral and funded inputs; that lifecycle has broad route-chain coverage +// (scripts/ci proxy-full-lifecycle), so this spec stays with panel state and +// setup-flow visibility per the test-plan note. The seeded proxy uses fake +// chain identifiers — balance and DRep lookups are mocked. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { mockWalletUtxos, mockGovernanceState } from "../helpers/phase3Mocks"; + +const FAKE_PARAM_UTXO = JSON.stringify({ txHash: "5".repeat(64), outputIndex: 0 }); +const FAKE_AUTH_TOKEN_ID = "ab".repeat(28); + +test.describe("proxy UI", () => { + test("proxy panel shows empty state and opens the setup flow", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E proxy-empty ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByRole("heading", { name: "Proxy Control" })).toBeVisible({ + timeout: 60_000, + }); + + // Expand the collapsed panel. + await page + .getByRole("button", { name: /Expand proxy control panel/ }) + .click(); + await expect(page.getByText("No Proxies Found")).toBeVisible({ + timeout: 30_000, + }); + + // The empty state leads into the setup modal with its step flow. + await page + .getByRole("button", { name: "Create Your First Proxy" }) + .click(); + await expect(page.getByText("Setup New Proxy")).toBeVisible(); + await expect(page.getByText("Ready to Setup Proxy")).toBeVisible(); + await expect(page.getByText("Collateral Required:")).toBeVisible(); + + const descriptionInput = page.getByPlaceholder( + "Enter a description for this proxy...", + ); + await descriptionInput.fill("CI proxy description"); + await expect(descriptionInput).toHaveValue("CI proxy description"); + + // With a connected wallet the setup action is available. + await expect( + page.getByRole("button", { name: "Start Proxy Setup" }), + ).toBeEnabled(); + + await page.keyboard.press("Escape"); + await expect(page.getByText("Ready to Setup Proxy")).toHaveCount(0); + }); + + test("existing proxy state is displayed in the panel", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E proxy-state ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + const description = `CI seeded proxy ${Date.now()}`; + const proxy = await trpcMutate<{ id: string }>(page, "proxy.createProxy", { + walletId: wallet.walletId, + proxyAddress: `addr_test1ciproxymock${Date.now()}`, + authTokenId: FAKE_AUTH_TOKEN_ID, + paramUtxo: FAKE_PARAM_UTXO, + description, + }); + + try { + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByRole("heading", { name: "Proxy Control" })).toBeVisible({ + timeout: 60_000, + }); + + // The collapsed header already summarizes the proxy count + // (rendered as "1 proxy • N assets"). + await expect(page.getByText(/1 proxy •/).first()).toBeVisible({ + timeout: 30_000, + }); + + await page + .getByRole("button", { name: /Expand proxy control panel/ }) + .click(); + + // The seeded proxy renders with its description, and the panel still + // offers adding another proxy. + await expect(page.getByText(description)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("No Proxies Found")).toHaveCount(0); + await expect(page.getByText("Add New Proxy")).toBeVisible(); + } finally { + if (proxy?.id) { + await trpcMutate(page, "proxy.deleteProxy", { id: proxy.id }).catch( + () => {}, + ); + } + } + }); +}); diff --git a/e2e/tests/rejected-signing.spec.ts b/e2e/tests/rejected-signing.spec.ts new file mode 100644 index 00000000..1e04d210 --- /dev/null +++ b/e2e/tests/rejected-signing.spec.ts @@ -0,0 +1,253 @@ +// Phase 2 item 3: Rejected wallet signing. +// +// Proves the app handles a wallet that refuses to sign: +// - signTx rejection during proposal → no pending transaction is created, +// the user sees an error toast, and the form stays usable. +// - signTx rejection during approval → no signature is added, the pending +// transaction is untouched, and the user sees an error toast. +// +// Both tests run against a throwaway 2-of-3 wallet created via tRPC over the +// CI signer addresses, never against the bootstrap ring wallets: the approval +// test intentionally leaves a transaction pending mid-test, and ring-transfer +// legs fail fast when their wallet has pending rows. The throwaway wallet is +// unfunded — the UTxO fetch is mocked and nothing ever reaches the chain +// (threshold is never met, so no broadcast path executes). + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { + createThrowawayWallet, + getPendingTransactionsRest, + trpcMutate, + type PendingTransaction, +} from "../helpers/apiHelpers"; +import type { Page } from "@playwright/test"; + +const FAKE_UTXO_TX_HASH = "1".repeat(64); + +// Serve one fake 5 ADA UTxO for every address the page asks about. The specs +// never broadcast, so the UTxO does not need to exist on-chain; page 2+ must +// return [] so BlockfrostProvider's pagination loop terminates. +async function mockAllUtxos(page: Page, walletAddress: string): Promise { + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + address: walletAddress, + tx_hash: FAKE_UTXO_TX_HASH, + output_index: 0, + amount: [{ unit: "lovelace", quantity: "5000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, + }, + ] + : [], + ), + }); + }); +} + +// Replaces the CIP-30 bridge's signTx with a rejecting stub on the live page. +// The injected wallet mock resolves window.__ci_signTx at call time, so this +// takes effect for the next signing attempt without a reload. +async function makeSignTxReject(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as Record).__ci_signTx = () => + Promise.reject( + new Error("MeshCI mock: user declined to sign the transaction"), + ); + }); +} + +async function openNewTransactionForm( + page: Page, + walletId: string, + recipientAddress: string, +): Promise { + await page.goto(`/wallets/${walletId}/transactions/new`); + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + await page.getByTestId("recipient-address-input-0").fill(recipientAddress); + await page.getByTestId("amount-input-0").fill("1"); + await expect(page.getByText("Input UTxOs (1)")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); +} + +function countRequests(page: Page, urlFragment: string): () => number { + let count = 0; + page.on("request", (request) => { + if (request.url().includes(urlFragment)) count += 1; + }); + return () => count; +} + +test.describe("rejected wallet signing", () => { + test("signTx rejection during proposal creates no pending transaction", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E reject-propose ${Date.now()}-${test.info().workerIndex}`, + ); + await mockAllUtxos(page, wallet.address); + + const createTransactionRequests = countRequests( + page, + "transaction.createTransaction", + ); + + await openNewTransactionForm(page, wallet.walletId, ctx.signerAddresses[2]!); + await makeSignTxReject(page); + await page.getByTestId("create-transaction-button").click(); + + // The rejection surfaces as a destructive toast titled "Error" and the + // form recovers: loading clears, no redirect to the transactions page. + await expect(page.getByText("Error", { exact: true }).first()).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled({ + timeout: 30_000, + }); + await expect(page).toHaveURL( + new RegExp(`/wallets/${wallet.walletId}/transactions/new$`), + ); + + // signTx rejected before the mutation, so it must never have been sent + // and nothing may be pending — in the UI or the database. + expect(createTransactionRequests()).toBe(0); + expect( + await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ), + ).toEqual([]); + + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + await expect(page.locator('[data-testid^="tx-card-"]')).toHaveCount(0); + }); + + test("signTx rejection during approval adds no signature", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E reject-approve ${Date.now()}-${test.info().workerIndex}`, + ); + await mockAllUtxos(page, wallet.address); + + // Signer 0 proposes for real (bridge signs with the mnemonic). Threshold + // is 2-of-3, so the transaction stays pending and nothing is broadcast. + await openNewTransactionForm(page, wallet.walletId, ctx.signerAddresses[2]!); + const createTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("transaction.createTransaction") && + response.request().method() === "POST", + { timeout: 90_000 }, + ); + await page.getByTestId("create-transaction-button").click(); + const createTransactionResponse = await createTransactionResponsePromise; + expect( + createTransactionResponse.ok(), + `createTransaction failed ${createTransactionResponse.status()}: ${await createTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + await expect(page).toHaveURL( + new RegExp(`/wallets/${wallet.walletId}/transactions$`), + { timeout: 30_000 }, + ); + + // Resolve the transaction ID from the REST API, not the rendered card: + // right after creation the card may still show the optimistic React Query + // entry whose ID is a client-side `temp-...` placeholder. + let pendingBefore: PendingTransaction | undefined; + const restDeadline = Date.now() + 30_000; + while (Date.now() < restDeadline) { + const pending = await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ); + if (pending.length === 1) { + pendingBefore = pending[0]; + break; + } + await page.waitForTimeout(2_000); + } + if (!pendingBefore) { + throw new Error( + "Proposed transaction never appeared in /api/v1/pendingTransactions", + ); + } + const transactionId = pendingBefore.id; + + try { + expect(pendingBefore.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pendingBefore.state).toBe(0); + + // Signer 1 opens the pending transaction but their wallet refuses to sign. + await authenticateAs(page, 1); + const updateTransactionRequests = countRequests( + page, + "transaction.updateTransaction", + ); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + const signButton = page.locator( + `[data-testid="sign-button-${transactionId}"]`, + ); + await expect(signButton).toBeVisible({ timeout: 30_000 }); + + await makeSignTxReject(page); + await signButton.click(); + + await expect( + page.getByText("Error", { exact: true }).first(), + ).toBeVisible({ timeout: 60_000 }); + // The card recovers: the signing action is still offered, not consumed. + await expect(signButton).toBeEnabled({ timeout: 30_000 }); + + // No update mutation fired and the DB record is unchanged: still only + // the proposer's signature, nobody marked as rejected, still pending. + expect(updateTransactionRequests()).toBe(0); + const afterReject = await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[1]!, + ); + const pendingAfter = afterReject.find((tx) => tx.id === transactionId); + expect(pendingAfter?.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pendingAfter?.rejectedAddresses ?? []).toEqual([]); + expect(pendingAfter?.state).toBe(0); + } finally { + // Remove the intentionally-stranded pending transaction so reruns start + // clean even though the wallet itself is throwaway. + await trpcMutate(page, "transaction.deleteTransaction", { + transactionId, + }).catch(() => {}); + } + }); +}); diff --git a/e2e/tests/responsive-smoke.spec.ts b/e2e/tests/responsive-smoke.spec.ts new file mode 100644 index 00000000..eb4199d1 --- /dev/null +++ b/e2e/tests/responsive-smoke.spec.ts @@ -0,0 +1,160 @@ +// Phase 2 item 5: Responsive smoke tests. +// +// Catches layout regressions on common mobile viewports for the core screens: +// wallet list, wallet detail, transaction list, new transaction form, and the +// wallet connect entry point. Each test asserts that the critical controls +// are visible/reachable and that the document does not overflow horizontally +// (wide content must scroll inside its own container, not the page). +// +// These are pure-UI checks against the bootstrap legacy wallet — no signing, +// no transaction creation, no chain writes. The new-transaction test mocks +// the UTxO fetch so it stays deterministic and off-chain. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext, getWallet } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const VIEWPORTS = [ + { name: "iPhone SE (375x667)", width: 375, height: 667 }, + { name: "Pixel 7 (412x915)", width: 412, height: 915 }, +]; + +const SMALL_UTXO = { + tx_hash: "1".repeat(64), + output_index: 0, + amount: [{ unit: "lovelace", quantity: "3000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, +}; + +async function mockUtxos(page: Page): Promise { + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pageNumber === 1 ? [SMALL_UTXO] : []), + }); + }); +} + +async function expectNoHorizontalOverflow( + page: Page, + label: string, +): Promise { + const { scrollWidth, clientWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect( + scrollWidth, + `${label}: document overflows horizontally (scrollWidth ${scrollWidth}px > viewport ${clientWidth}px)`, + ).toBeLessThanOrEqual(clientWidth + 1); +} + +for (const viewport of VIEWPORTS) { + test.describe(`responsive smoke @ ${viewport.name}`, () => { + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + test("wallet list shows primary actions without overflow", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await page.goto("/wallets"); + await expect( + page.getByRole("link", { name: "New Wallet" }), + ).toBeVisible({ timeout: 60_000 }); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + await expectNoHorizontalOverflow(page, "wallet list"); + }); + + test("wallet detail shows signer content without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await page.goto(`/wallets/${wallet.walletId}`); + await expect( + page.getByText("Signers", { exact: true }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoHorizontalOverflow(page, "wallet detail"); + }); + + test("transaction list shows balance actions without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + const depositButton = page + .getByRole("button", { name: "Deposit Funds" }) + .first(); + await expect(depositButton).toBeVisible({ timeout: 60_000 }); + // Primary actions must be reachable, though New Transaction may be + // disabled until the balance loads. + await expect( + page.getByRole("button", { name: "New Transaction" }).first(), + ).toBeVisible(); + await expectNoHorizontalOverflow(page, "transaction list"); + }); + + test("new transaction form is usable without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await mockUtxos(page); + await page.goto(`/wallets/${wallet.walletId}/transactions/new`); + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + + // Below the sm breakpoint the desktop recipient table is hidden and the + // mobile card layout must expose the address input instead. + await expect( + page.locator('input[placeholder="addr1... or $handle"]:visible').first(), + ).toBeVisible(); + + // The primary action is reachable by scrolling the page vertically. + const createButton = page.getByTestId("create-transaction-button"); + await createButton.scrollIntoViewIfNeeded(); + await expect(createButton).toBeVisible(); + + await expectNoHorizontalOverflow(page, "new transaction form"); + }); + + test("wallet connect entry point works without overflow", async ({ + page, + injectWallet, + }) => { + // Unauthenticated: no session cookie, only the injected CIP-30 mock. + await injectWallet(page, 0); + await page.goto("/"); + const connectButton = page + .getByRole("button", { name: /connect wallet/i }) + .first(); + await expect(connectButton).toBeVisible({ timeout: 60_000 }); + await expectNoHorizontalOverflow(page, "landing page"); + + // The connect dropdown opens and lists the injected wallet. + await connectButton.click(); + await expect(page.locator('[role="menu"]')).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByRole("menuitem", { name: "MeshCI" }), + ).toBeVisible({ timeout: 10_000 }); + await expectNoHorizontalOverflow(page, "connect wallet menu"); + }); + }); +} diff --git a/e2e/tests/ring-transfer.spec.ts b/e2e/tests/ring-transfer.spec.ts index 0224d7bc..943fddbb 100644 --- a/e2e/tests/ring-transfer.spec.ts +++ b/e2e/tests/ring-transfer.spec.ts @@ -41,6 +41,13 @@ type BlockfrostUtxo = { reference_script_hash?: string | null; }; +type PendingTransaction = { + id: string; + signedAddresses?: string[]; + rejectedAddresses?: string[]; + state?: number; +}; + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -227,7 +234,7 @@ async function getPendingTransactions( page: Page, walletId: string, signerAddress: string, -): Promise> { +): Promise { const jwtSecret = process.env.CI_JWT_SECRET; if (!jwtSecret) { throw new Error("CI_JWT_SECRET must be set to verify pending transactions"); @@ -246,7 +253,7 @@ async function getPendingTransactions( `pendingTransactions failed ${resp.status()}: ${await resp.text().catch(() => "")}`, ); } - return (await resp.json()) as Array<{ id: string }>; + return (await resp.json()) as PendingTransaction[]; } async function expectNoPendingTransactions( @@ -264,6 +271,46 @@ async function expectNoPendingTransactions( } } +async function expectPendingTransactionState( + page: Page, + walletId: string, + signerAddress: string, + txId: string, + expected: { + signedAddresses: string[]; + rejectedAddresses?: string[]; + state?: number; + }, +): Promise { + const transactions = await getPendingTransactions(page, walletId, signerAddress); + const transaction = transactions.find((pending) => pending.id === txId); + + expect( + transaction, + `Expected pending transaction ${txId} in wallet ${walletId}`, + ).toBeTruthy(); + + const signedAddresses = transaction!.signedAddresses ?? []; + expect( + signedAddresses.sort(), + `Unexpected signed addresses for pending transaction ${txId}`, + ).toEqual([...expected.signedAddresses].sort()); + + if (expected.rejectedAddresses) { + const rejectedAddresses = transaction!.rejectedAddresses ?? []; + expect( + rejectedAddresses.sort(), + `Unexpected rejected addresses for pending transaction ${txId}`, + ).toEqual([...expected.rejectedAddresses].sort()); + } + + if (expected.state !== undefined) { + expect(transaction!.state).toBe(expected.state); + } + + return transaction!; +} + // Poll /api/v1/pendingTransactions until the given tx ID is no longer listed, // indicating the broadcast was accepted and the DB record updated. async function waitForTxCleared( @@ -376,6 +423,27 @@ test.describe("ring transfer", () => { const transactionId = cardTestId!.replace("tx-card-", ""); expect(transactionId).toBeTruthy(); + await expectPendingTransactionState( + page, + srcWallet.walletId, + ctx.signerAddresses[0]!, + transactionId, + { + signedAddresses: [ctx.signerAddresses[0]!], + rejectedAddresses: [], + state: 0, + }, + ); + + // The proposer auto-signed while creating the transaction, so they should + // see the pending card but no duplicate signing action. + await expect( + page.locator(`[data-testid="tx-card-${transactionId}"]`), + ).toBeVisible(); + await expect( + page.locator(`[data-testid="sign-button-${transactionId}"]`), + ).toHaveCount(0); + // ── Step 2: Signer 1 signs → broadcast (threshold 2-of-3 now met) ───── await authenticateAs(page, 1); @@ -386,6 +454,20 @@ test.describe("ring transfer", () => { await page.waitForSelector(`[data-testid="tx-card-${transactionId}"]`, { timeout: 20_000, }); + await expectPendingTransactionState( + page, + srcWallet.walletId, + ctx.signerAddresses[1]!, + transactionId, + { + signedAddresses: [ctx.signerAddresses[0]!], + rejectedAddresses: [], + state: 0, + }, + ); + await expect( + page.locator(`[data-testid="sign-button-${transactionId}"]`), + ).toBeVisible(); // Sign — this will call signTx(), reach the 2-of-3 threshold, submit on-chain, // and set broadcastDone=true which renders the tx-broadcast-success indicator. diff --git a/e2e/tests/staking-ui.spec.ts b/e2e/tests/staking-ui.spec.ts new file mode 100644 index 00000000..cefe294a --- /dev/null +++ b/e2e/tests/staking-ui.spec.ts @@ -0,0 +1,168 @@ +// Phase 3 item 6: Staking UI. +// +// Proves the SDK staking page works in the browser without touching the chain: +// - the page loads staking state and gates actions on pool selection +// - inactive stake exposes RegisterAndDelegate; active stake exposes +// Delegate + Deregister +// - proposing a register+delegate certificate creates a pending transaction +// +// Runs against a throwaway 2-of-3 wallet created with the CI signers' stake +// keys (the app classifies it as an SDK wallet, so the staking page can derive +// a stake address and staking script). The wallet is unfunded: UTxOs and the +// account state are mocked, and the 2-of-3 threshold means the certificate +// proposal stays pending — nothing is broadcast. The stranded pending row is +// deleted in cleanup so reruns and parallel workers stay clean. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { + mockWalletUtxos, + mockAccountState, + mockPoolList, + resolveStakePoolHex, + waitForSinglePendingTransaction, +} from "../helpers/phase3Mocks"; + +test.describe("staking UI", () => { + test("staking page gates actions on pool selection and account state", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-gates ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: false }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + + // Staking info loads from the mocked account state. + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Inactive", { exact: true })).toBeVisible(); + + // No staking actions are offered until a pool is chosen. + await expect(page.getByRole("heading", { name: "Staking Actions" })).toHaveCount(0); + + const poolInput = page.getByPlaceholder("Enter pool ID"); + await poolInput.fill(poolHex); + await expect(page.getByText("Selected Pool ID:")).toBeVisible(); + + // Inactive stake: only the combined register+delegate action is offered. + await expect(page.getByRole("heading", { name: "Staking Actions" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "RegisterAndDelegate" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Deregister" })).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Delegate", exact: true }), + ).toHaveCount(0); + }); + + test("active stake exposes delegate and deregister actions", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-active ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: true, poolId: poolHex }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Active", { exact: true })).toBeVisible(); + + await page.getByPlaceholder("Enter pool ID").fill(poolHex); + + await expect(page.getByRole("heading", { name: "Staking Actions" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Delegate", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Deregister" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "RegisterAndDelegate" }), + ).toHaveCount(0); + }); + + test("register & delegate certificate proposal creates a pending transaction", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-propose ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: false }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await page.getByPlaceholder("Enter pool ID").fill(poolHex); + + // The action card's UTxO selector must auto-select the mocked UTxO before + // the certificate transaction can be built. + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + + const createTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("transaction.createTransaction") && + response.request().method() === "POST", + { timeout: 120_000 }, + ); + await page.getByRole("button", { name: "RegisterAndDelegate" }).click(); + const createTransactionResponse = await createTransactionResponsePromise; + expect( + createTransactionResponse.ok(), + `createTransaction failed ${createTransactionResponse.status()}: ${await createTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + await expect(page.getByText("Stake Registered & Delegated").first()).toBeVisible({ + timeout: 30_000, + }); + + const pending = await waitForSinglePendingTransaction( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ); + try { + // Only the proposer signed; 2-of-3 keeps the certificate pending. + expect(pending.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pending.state).toBe(0); + } finally { + await trpcMutate(page, "transaction.deleteTransaction", { + transactionId: pending.id, + }).catch(() => {}); + } + }); +}); diff --git a/e2e/tests/wallet-access-control.spec.ts b/e2e/tests/wallet-access-control.spec.ts new file mode 100644 index 00000000..4a705808 --- /dev/null +++ b/e2e/tests/wallet-access-control.spec.ts @@ -0,0 +1,223 @@ +// Phase 2 item 4: Wallet access control. +// +// Proves page guards match wallet authorization: +// - A signer can open the wallet pages of a wallet they belong to. +// - An authenticated non-member gets FORBIDDEN from wallet.getWallet and the +// wallet pages never render wallet content (the app keeps the loading +// skeleton and exposes nothing). +// - Unauthenticated direct navigation falls back to the public landing page. +// - The denial survives a browser reload. +// +// The non-member is a real derived preprod address (the standard all-zero +// entropy test mnemonic) with a valid injected session cookie — i.e. a fully +// authenticated user who simply is not a signer of the target wallet. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext, getWallet } from "../helpers/contextLoader"; +import { buildCip30MockScript } from "../helpers/cip30Mock"; +import { + buildWalletSessionToken, + WALLET_SESSION_COOKIE, +} from "../helpers/authSession"; +import { buildRestToken } from "../helpers/apiHelpers"; +import type { Page, Response } from "@playwright/test"; + +// Standard BIP-39 test vector (all-zero entropy): valid checksum, derives a +// real preprod address that is not a signer of any bootstrap wallet. +const NON_MEMBER_MNEMONIC = [...Array(23).fill("abandon"), "art"].join(" "); + +async function deriveNonMemberAddress(): Promise { + const { MeshWallet } = await import("@meshsdk/core"); + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: NON_MEMBER_MNEMONIC.split(" ") }, + }); + await wallet.init(); + return wallet.getChangeAddress(); +} + +// Mirrors authFixture's CI path for an arbitrary address: injects the CIP-30 +// mock (with inert bridge stubs — no signing ever happens here), seeds Mesh's +// persisted connection so auto-connect resolves useAddress on every load, and +// installs a valid non-Secure session cookie for the address. +async function setupSessionFor(page: Page, address: string): Promise { + const jwtSecret = process.env.CI_JWT_SECRET!; + const { Address } = await import("@meshsdk/core-cst"); + const addressHex = Address.fromBech32(address).toBytes().toString(); + + await page.addInitScript({ + content: + ` + window.__ci_getUtxos = async function() { return []; }; + window.__ci_signTx = function() { return Promise.reject(new Error("signing not supported in access-control spec")); }; + window.__ci_signData = function() { return Promise.reject(new Error("signing not supported in access-control spec")); }; + window.__ci_submitTx = async function() { return "${"0".repeat(64)}"; }; + try { localStorage.setItem("mesh-wallet-persist", JSON.stringify({ walletName: "meshci" })); } catch (e) {} + ` + + buildCip30MockScript({ + walletName: "meshci", + usedAddresses: [addressHex], + changeAddress: addressHex, + rewardAddresses: [], + }), + }); + + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + await page.context().clearCookies({ name: WALLET_SESSION_COOKIE }); + await page.context().addCookies([ + { + name: WALLET_SESSION_COOKIE, + value: buildWalletSessionToken(address, jwtSecret), + url: appUrl, + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }, + ]); +} + +function waitForGetWallet(page: Page): Promise { + return page.waitForResponse( + (response) => response.url().includes("wallet.getWallet"), + { timeout: 60_000 }, + ); +} + +// Navigates and waits for the wallet.getWallet round-trip so absence +// assertions run after the app has actually resolved (and denied) the query. +async function gotoAndAwaitGetWallet(page: Page, path: string): Promise { + const responsePromise = waitForGetWallet(page); + await page.goto(path); + const response = await responsePromise; + return response.text().catch(() => ""); +} + +async function expectNoWalletContent(page: Page): Promise { + await expect(page.getByText("Signers", { exact: true })).toHaveCount(0); + await expectNoWalletActions(page); +} + +// Narrower variant for the public landing fallback, whose marketing copy +// legitimately contains the word "Signers". +async function expectNoWalletActions(page: Page): Promise { + await expect(page.getByRole("button", { name: "Deposit Funds" })).toHaveCount(0); + await expect(page.getByTestId("create-transaction-button")).toHaveCount(0); + await expect(page.locator('[data-testid^="tx-card-"]')).toHaveCount(0); +} + +test.describe("wallet access control", () => { + test("authenticated signer can open wallet pages they belong to", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + + await authenticateAs(page, 0); + + // Wallet detail (info) page renders the signer list. + await page.goto(`/wallets/${wallet.walletId}`); + await expect(page.getByText("Signers", { exact: true }).first()).toBeVisible( + { timeout: 60_000 }, + ); + + // Transactions page renders the balance card actions. + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await expect( + page.getByRole("button", { name: "Deposit Funds" }).first(), + ).toBeVisible({ timeout: 60_000 }); + + // New transaction page renders the form for a member. + await page.goto(`/wallets/${wallet.walletId}/transactions/new`); + await expect(page.getByTestId("create-transaction-button")).toBeVisible({ + timeout: 60_000, + }); + + // Info route renders the same guarded content. + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByText("Signers", { exact: true }).first()).toBeVisible( + { timeout: 60_000 }, + ); + }); + + test("authenticated non-member cannot open wallet pages", async ({ page }) => { + test.skip( + !process.env.CI_JWT_SECRET, + "CI_JWT_SECRET is required to mint a session for a non-member address", + ); + test.setTimeout(240_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + const nonMember = await deriveNonMemberAddress(); + expect(ctx.signerAddresses).not.toContain(nonMember); + + await setupSessionFor(page, nonMember); + + // Wallet detail: the server denies the wallet query and the page never + // shows wallet content. + const getWalletBody = await gotoAndAwaitGetWallet( + page, + `/wallets/${wallet.walletId}`, + ); + expect(getWalletBody).toMatch(/FORBIDDEN|Not a signer|UNAUTHORIZED/); + await expectNoWalletContent(page); + + // The denial holds across every guarded wallet route. + for (const path of [ + `/wallets/${wallet.walletId}/transactions`, + `/wallets/${wallet.walletId}/transactions/new`, + `/wallets/${wallet.walletId}/info`, + `/wallets/${wallet.walletId}/staking`, + `/wallets/${wallet.walletId}/governance`, + ]) { + await gotoAndAwaitGetWallet(page, path); + await expectNoWalletContent(page); + } + + // REST surface agrees: pending transactions are not readable either. + const restResponse = await page.request.get( + `/api/v1/pendingTransactions?walletId=${encodeURIComponent(wallet.walletId)}&address=${encodeURIComponent(nonMember)}`, + { headers: { Authorization: `Bearer ${buildRestToken(nonMember)}` } }, + ); + expect( + restResponse.ok(), + `expected pendingTransactions to deny non-member, got ${restResponse.status()}`, + ).toBe(false); + + // Protection persists after a reload. + const reloadResponsePromise = waitForGetWallet(page); + await page.reload(); + await reloadResponsePromise; + await expectNoWalletContent(page); + }); + + test("unauthenticated direct navigation does not expose wallet pages", async ({ + page, + injectWallet, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + + // Wallet extension present but no session cookie and no persisted + // connection: the layout falls back to the public landing view (with the + // Connect Wallet entry point) instead of rendering the guarded wallet + // page. Without any injected wallet the header shows the UTXOS onboarding + // button instead, so inject the mock to model a logged-out extension user. + await injectWallet(page, 0); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await expect( + page.getByRole("button", { name: /connect wallet/i }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoWalletActions(page); + + // Still protected after a reload. + await page.reload(); + await expect( + page.getByRole("button", { name: /connect wallet/i }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoWalletActions(page); + }); +}); diff --git a/src/components/common/page-header.tsx b/src/components/common/page-header.tsx index a5378ed0..c6e8e462 100644 --- a/src/components/common/page-header.tsx +++ b/src/components/common/page-header.tsx @@ -12,7 +12,7 @@ export default function PageHeader({ backUrl?: string | undefined; }) { return ( -
    +
    {backUrl && (