diff --git a/.mocharc.e2e.js b/.mocharc.e2e.js new file mode 100644 index 0000000..8a78ae8 --- /dev/null +++ b/.mocharc.e2e.js @@ -0,0 +1,10 @@ +module.exports = { + require: ['ts-node/register'], + extension: ['ts'], + // Testnet confirmations are slow; allow generous per-test budgets. + timeout: 120000, + ui: 'bdd', + spec: 'src/**/__tests__/e2e/**/*.e2e.test.ts', + recursive: true, + exit: true, +}; diff --git a/.mocharc.js b/.mocharc.js index 5d45fcc..18d8343 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -4,6 +4,9 @@ module.exports = { timeout: 0, ui: 'bdd', spec: 'src/**/__tests__/**/*.test.ts', + // E2E scenarios require live external services + WP testnet; they run via + // `npm run test:e2e` (.mocharc.e2e.js), not under `npm test`. + ignore: ['src/**/*.e2e.test.ts'], recursive: true, exit: true }; diff --git a/package.json b/package.json index ef88a88..f51bf24 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "generate-test-ssl": "openssl req -x509 -newkey rsa:2048 -keyout demo.key -out demo.crt -days 365 -nodes -subj '/CN=localhost'", "test:integration": "NODE_ENV=test TS_NODE_PROJECT=tsconfig.integ.json mocha --config .mocharc.integ.js", "docker:test:integration": "bash scripts/run-integration-tests.sh", + "test:e2e": "NODE_ENV=test TS_NODE_PROJECT=tsconfig.e2e.json mocha --config .mocharc.e2e.js", + "test:e2e:signing": "NODE_ENV=test TS_NODE_PROJECT=tsconfig.e2e.json mocha --config .mocharc.e2e.js --grep signing", + "test:e2e:keygen": "NODE_ENV=test TS_NODE_PROJECT=tsconfig.e2e.json mocha --config .mocharc.e2e.js --grep keygen", "generate:openapi:masterExpress": "npx @api-ts/openapi-generator --name @bitgo/master-bitgo-express ./src/masterBitgoExpress/routers/index.ts > masterBitgoExpress.json", "check:openapi:masterExpress": "bash scripts/check-openapi-masterExpress.sh", "container:build:master-bitgo-express": "podman build --build-arg PORT=3081 -t master-bitgo-express .", diff --git a/src/__tests__/e2e/README.md b/src/__tests__/e2e/README.md new file mode 100644 index 0000000..758a3a8 --- /dev/null +++ b/src/__tests__/e2e/README.md @@ -0,0 +1,55 @@ +# AKM-OSO E2E Test Suite + +End-to-end tests that drive the **full AKM-OSO pipeline** through real services and a real BitGo WP testnet. + +Unlike the integration suite (`src/__tests__/integration/`, which boots **mock** servers in-process), this suite points at **already-running** services and verifies results on **testnet**. + +## What needs to be running + +The suite expects the local docker-compose env to be up (MBE, bridge, FE/BE plugins, mock conductor, AWM user+backup, mock key provider). Bring it up first, then run the suite against it. The signing/keygen/recovery scenarios additionally talk to WP testnet over the internet. + +## Configuration + +All config comes from the environment (defaults target local compose). See [`helpers/config.ts`](./helpers/config.ts). + +| Env var | Default | Purpose | +| --- | --- | --- | +| `E2E_MBE_URL` | `http://localhost:3081` | Master BitGo Express (submit/poll) | +| `E2E_BRIDGE_URL` | `http://localhost:3082` | OSO bridge (resilience scenarios) | +| `E2E_FE_PLUGIN_URL` | `http://localhost:4001` | Frontend plugin | +| `E2E_BE_PLUGIN_URL` | `http://localhost:4002` | Backend plugin | +| `E2E_AWM_URL` | `http://localhost:3080` | AWM (user key) | +| `E2E_AWM_BACKUP_URL` | `http://localhost:3083` | AWM (backup key) | +| `E2E_KEY_PROVIDER_URL` | `http://localhost:3000` | Mock key provider | +| `E2E_COIN` | `tbtc` | Coin under test | +| `E2E_BITGO_ENV` | `test` | BitGo env for testnet verification | +| `BITGO_ACCESS_TOKEN` | — | **Required** for testnet scenarios | +| `E2E_ENTERPRISE` | — | Enterprise id (keygen) | +| `E2E_WALLET_ID` | — | Pre-funded wallet id (signing) | +| `E2E_WALLET_PASSPHRASE` | — | Passphrase for the wallet | +| `E2E_REQUEST_TIMEOUT_MS` | `30000` | Per-request timeout | +| `E2E_POLL_INTERVAL_MS` | `2000` | Delay between job polls | +| `E2E_POLL_TIMEOUT_MS` | `180000` | Budget for a job to reach terminal state | + +## Running + +```bash +npm run test:e2e # full suite +npm run test:e2e:signing # signing scenarios only +npm run test:e2e:keygen # keygen scenarios only +``` + +The suite uses `.mocharc.e2e.js` + `tsconfig.e2e.json`. Scenario specs are named `*.e2e.test.ts` and are **excluded from `npm test`** (they require live services). Helper unit tests (`*.test.ts`, e.g. `helpers/pollJob.test.ts`) run under `npm test` as usual. + +With no services up, `npm run test:e2e` still runs the harness sanity check (`harness.e2e.test.ts`), which only validates config/runner wiring. + +## Helpers + +- [`config.ts`](./helpers/config.ts) — env-driven config; `loadE2EConfig()` and `requireConfig(cfg, keys)` (call the latter from a scenario `before()` hook). +- [`httpClient.ts`](./helpers/httpClient.ts) — `request()` returns `{ status, body }` for any response (assert on status codes); `authHeaders(token)`. +- [`pollJob.ts`](./helpers/pollJob.ts) — `pollUntil(fn, done, opts)` and `pollJobToTerminal(cfg, jobId)` (polls MBE until `complete`/`failed`). +- [`testnet.ts`](./helpers/testnet.ts) — `getBitGo()`, `getWallet()`, `getWalletKeychainPubs()`, `getTransfer()` for WP testnet verification. + +## Logs + +Service logs come from the compose env: `docker compose logs -f `. diff --git a/src/__tests__/e2e/harness.e2e.test.ts b/src/__tests__/e2e/harness.e2e.test.ts new file mode 100644 index 0000000..c06c154 --- /dev/null +++ b/src/__tests__/e2e/harness.e2e.test.ts @@ -0,0 +1,24 @@ +import 'should'; +import { loadE2EConfig, requireConfig } from './helpers'; + +/** + * Harness sanity check (no network). Proves the runner, ts-node wiring, and + * config loading are healthy so `npm run test:e2e` is green before any service + * scenarios exist. Real pipeline scenarios live in *.e2e.test.ts siblings. + */ +describe('E2E harness', () => { + it('loads config with local-compose defaults', () => { + const cfg = loadE2EConfig(); + cfg.mbeUrl.should.be.a.String(); + cfg.bridgeUrl.should.be.a.String(); + cfg.coin.should.be.a.String(); + cfg.pollIntervalMs.should.be.a.Number(); + cfg.pollTimeoutMs.should.be.above(0); + }); + + it('requireConfig throws when a needed value is missing', () => { + const cfg = loadE2EConfig(); + delete cfg.accessToken; + (() => requireConfig(cfg, ['accessToken'])).should.throw(/missing required values/); + }); +}); diff --git a/src/__tests__/e2e/helpers/config.ts b/src/__tests__/e2e/helpers/config.ts new file mode 100644 index 0000000..b7f3344 --- /dev/null +++ b/src/__tests__/e2e/helpers/config.ts @@ -0,0 +1,102 @@ +import { EnvironmentName } from '@bitgo-beta/sdk-core'; + +/** + * Resolved configuration for the E2E suite. + * + * Unlike the integration suite (which boots mock servers in-process on random + * ports), the E2E suite points at real, already-running services — typically + * the local docker-compose env — and a real WP testnet. Everything is + * therefore sourced from the environment, with local-compose defaults. + */ +export interface E2EConfig { + /** Master BitGo Express — the client-facing entry point for submit/poll. */ + mbeUrl: string; + /** OSO bridge — used directly by resilience scenarios. */ + bridgeUrl: string; + /** Frontend plugin (conductor-facing pull/ack). */ + fePluginUrl: string; + /** Backend plugin (AWM-facing). */ + bePluginUrl: string; + /** Advanced Wallet Manager (user key). */ + awmUrl: string; + /** Advanced Wallet Manager (backup key). */ + awmBackupUrl: string; + /** Mock Key Provider (HSM stand-in). */ + keyProviderUrl: string; + + /** Coin to exercise. */ + coin: string; + /** BitGo environment for testnet verification. */ + bitgoEnv: EnvironmentName; + /** Access token for WP testnet calls. Required by scenarios, not by the harness itself. */ + accessToken?: string; + /** Enterprise id for keygen scenarios. */ + enterprise?: string; + /** Pre-funded wallet id for signing scenarios (optional — scenarios may create one). */ + walletId?: string; + /** Passphrase for the pre-funded wallet. */ + walletPassphrase?: string; + + /** Per-request timeout for HTTP calls. */ + requestTimeoutMs: number; + /** Delay between job polls. */ + pollIntervalMs: number; + /** Overall budget for a job to reach a terminal state. */ + pollTimeoutMs: number; +} + +function env(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value === '' ? fallback : value; +} + +function num(name: string, fallback: number): number { + const value = process.env[name]; + if (value === undefined || value === '') { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`E2E config: ${name} must be a number, got "${value}"`); + } + return parsed; +} + +/** Build the E2E config from the environment. Never throws on missing credentials. */ +export function loadE2EConfig(): E2EConfig { + return { + mbeUrl: env('E2E_MBE_URL', 'http://localhost:3081'), + bridgeUrl: env('E2E_BRIDGE_URL', 'http://localhost:3082'), + fePluginUrl: env('E2E_FE_PLUGIN_URL', 'http://localhost:4001'), + bePluginUrl: env('E2E_BE_PLUGIN_URL', 'http://localhost:4002'), + awmUrl: env('E2E_AWM_URL', 'http://localhost:3080'), + awmBackupUrl: env('E2E_AWM_BACKUP_URL', 'http://localhost:3083'), + keyProviderUrl: env('E2E_KEY_PROVIDER_URL', 'http://localhost:3000'), + + coin: env('E2E_COIN', 'tbtc'), + bitgoEnv: env('E2E_BITGO_ENV', 'test') as EnvironmentName, + accessToken: process.env.BITGO_ACCESS_TOKEN, + enterprise: process.env.E2E_ENTERPRISE, + walletId: process.env.E2E_WALLET_ID, + walletPassphrase: process.env.E2E_WALLET_PASSPHRASE, + + requestTimeoutMs: num('E2E_REQUEST_TIMEOUT_MS', 30000), + pollIntervalMs: num('E2E_POLL_INTERVAL_MS', 2000), + pollTimeoutMs: num('E2E_POLL_TIMEOUT_MS', 180000), + }; +} + +/** + * Assert that the fields a scenario needs are present. Call from a scenario's + * `before()` hook so the harness itself (and the smoke test) can load config + * without credentials. + */ +export function requireConfig(cfg: E2EConfig, keys: (keyof E2EConfig)[]): void { + const missing = keys.filter((key) => cfg[key] === undefined || cfg[key] === ''); + if (missing.length > 0) { + throw new Error( + `E2E config missing required values: ${missing.join(', ')}. ` + + `Set the corresponding env vars (see src/__tests__/e2e/README.md).`, + ); + } +} diff --git a/src/__tests__/e2e/helpers/httpClient.ts b/src/__tests__/e2e/helpers/httpClient.ts new file mode 100644 index 0000000..7101edc --- /dev/null +++ b/src/__tests__/e2e/helpers/httpClient.ts @@ -0,0 +1,54 @@ +export interface HttpResult { + status: number; + body: T; +} + +export interface RequestOptions { + body?: unknown; + headers?: Record; + timeoutMs?: number; +} + +/** + * Minimal fetch-based HTTP helper for E2E scenarios. + * + * Unlike `src/shared/httpClient.ts` (which throws on non-2xx so production code + * can fail fast), this returns `{ status, body }` for any response so scenarios + * can assert on status codes (202, 404, ...) directly. Mirrors the raw `fetch` + * style already used by the integration tests. + */ +export async function request( + method: 'GET' | 'POST' | 'PATCH', + url: string, + options: RequestOptions = {}, +): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 30000); + + try { + const res = await fetch(url, { + method, + signal: controller.signal, + headers: { + ...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...options.headers, + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + const text = await res.text(); + const body = (text && isJson(res) ? JSON.parse(text) : text) as T; + return { status: res.status, body }; + } finally { + clearTimeout(timeout); + } +} + +function isJson(res: Response): boolean { + return (res.headers.get('content-type') ?? '').includes('application/json'); +} + +/** Bearer auth header for MBE / WP calls. */ +export function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} diff --git a/src/__tests__/e2e/helpers/index.ts b/src/__tests__/e2e/helpers/index.ts new file mode 100644 index 0000000..de9405e --- /dev/null +++ b/src/__tests__/e2e/helpers/index.ts @@ -0,0 +1,4 @@ +export * from './config'; +export * from './httpClient'; +export * from './pollJob'; +export * from './testnet'; diff --git a/src/__tests__/e2e/helpers/pollJob.test.ts b/src/__tests__/e2e/helpers/pollJob.test.ts new file mode 100644 index 0000000..c1e2f25 --- /dev/null +++ b/src/__tests__/e2e/helpers/pollJob.test.ts @@ -0,0 +1,34 @@ +import 'should'; +import { pollUntil } from './pollJob'; + +describe('pollUntil', () => { + it('returns the first result that satisfies the predicate', async () => { + let calls = 0; + const result = await pollUntil( + async () => ++calls, + (n) => n >= 3, + { intervalMs: 1, timeoutMs: 1000 }, + ); + result.should.equal(3); + calls.should.equal(3); + }); + + it('returns immediately when already done', async () => { + let calls = 0; + const result = await pollUntil( + async () => ++calls, + () => true, + { intervalMs: 1, timeoutMs: 1000 }, + ); + result.should.equal(1); + calls.should.equal(1); + }); + + it('throws when the timeout elapses before done', async () => { + await pollUntil( + async () => 'pending', + (v) => v === 'done', + { intervalMs: 1, timeoutMs: 10 }, + ).should.be.rejectedWith(/timed out/); + }); +}); diff --git a/src/__tests__/e2e/helpers/pollJob.ts b/src/__tests__/e2e/helpers/pollJob.ts new file mode 100644 index 0000000..d5cac76 --- /dev/null +++ b/src/__tests__/e2e/helpers/pollJob.ts @@ -0,0 +1,78 @@ +import { MbeJobPollResponse } from '../../../masterBitgoExpress/clients/bridgeClient.types'; +import { E2EConfig } from './config'; +import { authHeaders, request } from './httpClient'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export interface PollOptions { + intervalMs?: number; + timeoutMs?: number; +} + +/** + * Poll `fn` until `done(result)` is true, then return that result. + * Throws once `timeoutMs` elapses. + */ +export async function pollUntil( + fn: () => Promise, + done: (result: T) => boolean, + options: PollOptions = {}, +): Promise { + const intervalMs = options.intervalMs ?? 2000; + const timeoutMs = options.timeoutMs ?? 180000; + const deadline = Date.now() + timeoutMs; + + for (;;) { + const result = await fn(); + if (done(result)) { + return result; + } + if (Date.now() >= deadline) { + throw new Error(`pollUntil: timed out after ${timeoutMs}ms`); + } + await sleep(intervalMs); + } +} + +/** MBE client-facing terminal job states (the bridge maps `expired` -> `failed`). */ +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'complete', + 'failed', +]); + +/** + * Poll MBE's client-facing job endpoint until the job reaches a terminal state + * (`complete` or `failed`). Returns the final poll response — callers assert on + * `status`/`result`/`error`. + */ +export async function pollJobToTerminal( + cfg: E2EConfig, + jobId: string, + options: PollOptions = {}, +): Promise { + const { accessToken } = cfg; + if (!accessToken) { + throw new Error('pollJobToTerminal: accessToken is required (set BITGO_ACCESS_TOKEN)'); + } + const url = `${cfg.mbeUrl}/api/v1/advancedwallet/job/${jobId}`; + + const result = await pollUntil( + async () => { + const res = await request('GET', url, { + headers: authHeaders(accessToken), + timeoutMs: cfg.requestTimeoutMs, + }); + if (res.status !== 200) { + throw new Error(`pollJobToTerminal: GET ${url} returned ${res.status}`); + } + return res.body; + }, + (job) => TERMINAL_STATUSES.has(job.status), + { + intervalMs: options.intervalMs ?? cfg.pollIntervalMs, + timeoutMs: options.timeoutMs ?? cfg.pollTimeoutMs, + }, + ); + + return result; +} diff --git a/src/__tests__/e2e/helpers/testnet.ts b/src/__tests__/e2e/helpers/testnet.ts new file mode 100644 index 0000000..392b067 --- /dev/null +++ b/src/__tests__/e2e/helpers/testnet.ts @@ -0,0 +1,61 @@ +import { BitGoAPI } from '@bitgo-beta/sdk-api'; +import { Wallet } from '@bitgo-beta/sdk-core'; +import { E2EConfig } from './config'; + +/** + * Build a BitGoAPI client pointed at WP testnet and register the coin module. + * + * Construction mirrors the async job worker (`workers/asyncJobWorker.ts`). + * Only btc/tbtc is registered today; add modules here as scenarios cover + * more coins. + */ +export async function getBitGo(cfg: E2EConfig): Promise { + if (!cfg.accessToken) { + throw new Error('getBitGo: accessToken is required (set BITGO_ACCESS_TOKEN)'); + } + const bitgo = new BitGoAPI({ env: cfg.bitgoEnv, accessToken: cfg.accessToken }); + + const { register } = await import('@bitgo-beta/sdk-coin-btc'); + register(bitgo); + + return bitgo; +} + +/** Fetch a wallet from WP (throws if it does not exist). */ +export async function getWallet( + bitgo: BitGoAPI, + cfg: E2EConfig, + walletId: string, +): Promise { + return bitgo.coin(cfg.coin).wallets().get({ id: walletId }); +} + +/** Resolve the public keys of a wallet's keychains, in keyId order (user, backup, bitgo). */ +export async function getWalletKeychainPubs( + bitgo: BitGoAPI, + cfg: E2EConfig, + wallet: Wallet, +): Promise { + const coin = bitgo.coin(cfg.coin); + const keychains = await Promise.all(wallet.keyIds().map((id) => coin.keychains().get({ id }))); + return keychains.map((keychain) => keychain.pub ?? ''); +} + +/** The subset of a WP transfer the scenarios assert on. */ +export interface TransferSummary { + id: string; + txid: string; + state: string; +} + +/** Look up a transfer by txid to confirm a transaction landed on testnet. */ +export async function getTransfer( + bitgo: BitGoAPI, + cfg: E2EConfig, + walletId: string, + txid: string, +): Promise { + const wallet = await getWallet(bitgo, cfg, walletId); + // SDK types getTransfer() as Promise; narrow to the fields we read. + return wallet.getTransfer({ id: txid }); +} diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..4821fcb --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2020", "DOM"], + "types": ["mocha", "node"] + }, + "include": ["src/__tests__/e2e/**/*"] +}