-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add e2e testing suite for AKM-OSO pipeline #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+436
−0
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <service>`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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).`, | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| export interface HttpResult<T = unknown> { | ||
| status: number; | ||
| body: T; | ||
| } | ||
|
|
||
| export interface RequestOptions { | ||
| body?: unknown; | ||
| headers?: Record<string, string>; | ||
| 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<T = unknown>( | ||
| method: 'GET' | 'POST' | 'PATCH', | ||
| url: string, | ||
| options: RequestOptions = {}, | ||
| ): Promise<HttpResult<T>> { | ||
| 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<string, string> { | ||
| return { Authorization: `Bearer ${token}` }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export * from './config'; | ||
| export * from './httpClient'; | ||
| export * from './pollJob'; | ||
| export * from './testnet'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> => 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<T>( | ||
| fn: () => Promise<T>, | ||
| done: (result: T) => boolean, | ||
| options: PollOptions = {}, | ||
| ): Promise<T> { | ||
| 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<MbeJobPollResponse['status']> = 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<MbeJobPollResponse> { | ||
| 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<MbeJobPollResponse>('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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why can't we use the httpClient already available?