Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .mocharc.e2e.js
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,
};
3 changes: 3 additions & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
55 changes: 55 additions & 0 deletions src/__tests__/e2e/README.md
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>`.
24 changes: 24 additions & 0 deletions src/__tests__/e2e/harness.e2e.test.ts
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/);
});
});
102 changes: 102 additions & 0 deletions src/__tests__/e2e/helpers/config.ts
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).`,
);
}
}
54 changes: 54 additions & 0 deletions src/__tests__/e2e/helpers/httpClient.ts
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>(

Copy link
Copy Markdown
Contributor

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?

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}` };
}
4 changes: 4 additions & 0 deletions src/__tests__/e2e/helpers/index.ts
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';
34 changes: 34 additions & 0 deletions src/__tests__/e2e/helpers/pollJob.test.ts
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/);
});
});
78 changes: 78 additions & 0 deletions src/__tests__/e2e/helpers/pollJob.ts
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;
}
Loading
Loading