diff --git a/.gitignore b/.gitignore index cdcb8c41c..4407023fa 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ junit.xml .yarnrc.yml docker-compose.local.yml + +# Planning scratch files +findings.md +progress.md +task_plan.md diff --git a/dev/erpnext/backup.sh b/dev/erpnext/backup.sh index 230d68296..7fce5091e 100755 --- a/dev/erpnext/backup.sh +++ b/dev/erpnext/backup.sh @@ -1,17 +1,26 @@ #!/bin/bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) # Create backups directory on host if it doesn't exist -BACKUP_DIR="$(dirname "$0")/backups" +BACKUP_DIR="$SCRIPT_DIR/backups" mkdir -p "$BACKUP_DIR" -docker exec -it flash-frappe-frontend-1 mkdir -p /tmp/backups +cd "$REPO_ROOT" + +FRAPPE_FRONTEND_SERVICE="frappe-frontend" +BACKUP_DIR_IN_CONTAINER="/tmp/backups" + +docker compose exec -T "$FRAPPE_FRONTEND_SERVICE" mkdir -p "$BACKUP_DIR_IN_CONTAINER" # Run the backup inside the container and capture output -BACKUP_OUTPUT=$(docker exec flash-frappe-frontend-1 bench --site frontend backup --backup-path /tmp/backups) +BACKUP_OUTPUT=$(docker compose exec -T "$FRAPPE_FRONTEND_SERVICE" bench --site frontend backup --backup-path "$BACKUP_DIR_IN_CONTAINER") # Extract the database path from the output line containing "Database:" -BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | grep "Database:" | awk '{print $2}') -echo $BACKUP_FILE -docker cp flash-frappe-frontend-1:$BACKUP_FILE "$BACKUP_DIR/" +BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | grep "Database:" | awk '{print $2}') +echo "$BACKUP_FILE" +docker compose cp "$FRAPPE_FRONTEND_SERVICE:$BACKUP_FILE" "$BACKUP_DIR/" -echo "Backups saved to: $BACKUP_DIR" +echo "Backups saved to: $BACKUP_DIR" diff --git a/dev/erpnext/backups/20260608_113333-frontend-database.sql.gz b/dev/erpnext/backups/20260608_113333-frontend-database.sql.gz new file mode 100644 index 000000000..0dc5979e0 Binary files /dev/null and b/dev/erpnext/backups/20260608_113333-frontend-database.sql.gz differ diff --git a/dev/erpnext/backups/clean-snapshot.sql.gz b/dev/erpnext/backups/clean-snapshot.sql.gz deleted file mode 100644 index 8905a1563..000000000 Binary files a/dev/erpnext/backups/clean-snapshot.sql.gz and /dev/null differ diff --git a/dev/setup-bridge-webhooks.js b/dev/setup-bridge-webhooks.js new file mode 100644 index 000000000..91d59fe05 --- /dev/null +++ b/dev/setup-bridge-webhooks.js @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/no-var-requires, import/order */ + +const fs = require("fs") +const path = require("path") +const { spawn } = require("child_process") +const yaml = require("js-yaml") + +const ROUTES = { + kyc: { + path: "/kyc", + eventCategories: ["customer", "kyc_link"], + }, + deposit: { + path: "/deposit", + eventCategories: ["virtual_account.activity", "bridge_wallet.activity"], + }, + transfer: { + path: "/transfer", + eventCategories: ["transfer"], + }, + external_account: { + path: "/external-account", + eventCategories: ["external_account"], + }, +} + +const DEFAULT_BRIDGE_BASE_URL = "https://api.sandbox.bridge.xyz/v0" +const DEFAULT_PORT = 4009 + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const trimTrailingSlash = (value) => value.replace(/\/+$/, "") + +const buildWebhookDefinitions = (baseUrl) => { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + return Object.fromEntries( + Object.entries(ROUTES).map(([key, route]) => [ + key, + { + url: `${normalizedBaseUrl}${route.path}`, + eventCategories: route.eventCategories, + }, + ]), + ) +} + +const extractNgrokHttpsUrl = (response) => { + const tunnel = response?.tunnels?.find( + (candidate) => + candidate?.proto === "https" && typeof candidate.public_url === "string", + ) + if (!tunnel) { + throw new Error("No HTTPS ngrok tunnel found on local ngrok API") + } + return tunnel.public_url +} + +const isObject = (value) => + value !== null && typeof value === "object" && !Array.isArray(value) + +const mergeDeep = (base, override) => { + const merged = { ...(isObject(base) ? base : {}) } + for (const [key, value] of Object.entries(override)) { + if (isObject(value) && isObject(merged[key])) { + merged[key] = mergeDeep(merged[key], value) + } else { + merged[key] = value + } + } + return merged +} + +const mergeDevOverrides = (existing, generated) => + mergeDeep(existing, { + bridge: { + apiKey: generated.apiKey, + baseUrl: generated.baseUrl, + webhook: { + uri: generated.webhookBaseUrl, + publicKeys: generated.publicKeys, + }, + }, + }) + +const reconcileBridgeWebhooks = async (api, definitions) => { + const existingWebhooks = await api.listWebhooks() + const webhooksToDelete = existingWebhooks.filter( + (webhook) => webhook.status !== "deleted", + ) + + for (const webhook of webhooksToDelete) { + await api.deleteWebhook(webhook.id) + } + + const publicKeys = {} + const created = {} + + for (const [key, definition] of Object.entries(definitions)) { + const webhook = await api.createWebhook({ key, ...definition }) + created[key] = webhook + publicKeys[key] = webhook.public_key + await api.enableWebhook(webhook.id, definition) + } + + return { + publicKeys, + created, + existingWebhookCount: existingWebhooks.length, + deletedWebhookCount: webhooksToDelete.length, + } +} + +const readYamlFile = (filePath) => { + if (!fs.existsSync(filePath)) return {} + return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {} +} + +const writeYamlFile = (filePath, data) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), "utf8") +} + +const defaultOverridesPath = () => { + const configDir = + process.env.CONFIG_PATH || path.join(process.env.HOME ?? ".", ".config/flash") + return path.join(configDir, "dev-overrides.yaml") +} + +const loadMergedConfig = ({ baseConfigPath, overridesPath }) => + mergeDeep(readYamlFile(baseConfigPath), readYamlFile(overridesPath)) + +const fetchJson = async ({ method, url, apiKey, body, idempotencyKey }) => { + const headers = { + "Api-Key": apiKey, + "Content-Type": "application/json", + } + if (idempotencyKey) { + headers["Idempotency-Key"] = idempotencyKey + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + const text = await response.text() + const parsed = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new Error( + `Bridge ${method} ${url} failed (${response.status}): ${JSON.stringify(parsed)}`, + ) + } + return parsed +} + +const createBridgeApi = ({ apiKey, baseUrl }) => { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + return { + listWebhooks: async () => { + const response = await fetchJson({ + method: "GET", + url: `${normalizedBaseUrl}/webhooks`, + apiKey, + }) + return response.data ?? [] + }, + deleteWebhook: async (id) => + fetchJson({ + method: "DELETE", + url: `${normalizedBaseUrl}/webhooks/${id}`, + apiKey, + }), + createWebhook: async ({ key, url, eventCategories }) => + fetchJson({ + method: "POST", + url: `${normalizedBaseUrl}/webhooks`, + apiKey, + idempotencyKey: `flash-dev-${key}-${Date.now()}`, + body: { + url, + event_epoch: "webhook_creation", + event_categories: eventCategories, + }, + }), + enableWebhook: async (id, definition) => + fetchJson({ + method: "PUT", + url: `${normalizedBaseUrl}/webhooks/${id}`, + apiKey, + body: { + url: definition.url, + status: "active", + event_categories: definition.eventCategories, + }, + }), + } +} + +const getNgrokTunnels = async () => { + const response = await fetch("http://127.0.0.1:4040/api/tunnels") + if (!response.ok) { + throw new Error(`ngrok API returned ${response.status}`) + } + return response.json() +} + +const hasNgrok = () => { + const paths = (process.env.PATH ?? "").split(path.delimiter) + return paths.some((candidate) => fs.existsSync(path.join(candidate, "ngrok"))) +} + +const startNgrok = ({ port }) => { + if (!hasNgrok()) { + throw new Error("ngrok is not installed or not on PATH") + } + + const logPath = path.join( + process.env.TMPDIR ?? "/tmp", + `flash-bridge-ngrok-${port}.log`, + ) + const logFd = fs.openSync(logPath, "a") + const child = spawn("ngrok", ["http", String(port), "--log", "stdout"], { + detached: true, + stdio: ["ignore", logFd, logFd], + }) + child.unref() + return { pid: child.pid, logPath } +} + +const ensureNgrokTunnel = async ({ port, retries = 20, intervalMs = 500 }) => { + try { + return extractNgrokHttpsUrl(await getNgrokTunnels()) + } catch { + startNgrok({ port }) + } + + for (let attempt = 0; attempt < retries; attempt += 1) { + await sleep(intervalMs) + try { + return extractNgrokHttpsUrl(await getNgrokTunnels()) + } catch { + // ngrok is still starting; keep polling until retries are exhausted. + } + } + + throw new Error("ngrok did not expose an HTTPS tunnel before timeout") +} + +const parseArgs = (argv) => { + const args = { + baseConfigPath: "dev/config/base-config.yaml", + overridesPath: defaultOverridesPath(), + port: DEFAULT_PORT, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === "--help" || arg === "-h") args.help = true + else if (arg === "--base-config") args.baseConfigPath = argv[++index] + else if (arg === "--overrides") args.overridesPath = argv[++index] + else if (arg === "--port") args.port = Number(argv[++index]) + else if (arg === "--api-key") args.apiKey = argv[++index] + else if (arg === "--base-url") args.baseUrl = argv[++index] + else throw new Error(`Unknown argument: ${arg}`) + } + + return args +} + +const usage = () => `Usage: node dev/setup-bridge-webhooks.js [options] + +Options: + --base-config Base YAML config path (default: dev/config/base-config.yaml) + --overrides Local override YAML path (default: $CONFIG_PATH/dev-overrides.yaml or ~/.config/flash/dev-overrides.yaml) + --port Local Bridge webhook port (default: 4009) + --api-key Bridge sandbox API key (default: existing config/env) + --base-url Bridge API base URL (default: existing config/env or sandbox) + --help Show this help message +` + +const run = async (argv = process.argv.slice(2)) => { + const args = parseArgs(argv) + if (args.help) { + console.log(usage()) + return + } + + const config = loadMergedConfig({ + baseConfigPath: args.baseConfigPath, + overridesPath: args.overridesPath, + }) + const apiKey = args.apiKey || process.env.BRIDGE_API_KEY || config.bridge?.apiKey + if (!apiKey) { + throw new Error( + "Bridge API key is required. Set bridge.apiKey in dev-overrides.yaml or pass --api-key.", + ) + } + + const baseUrl = + args.baseUrl || + process.env.BRIDGE_BASE_URL || + config.bridge?.baseUrl || + DEFAULT_BRIDGE_BASE_URL + + console.log(`Starting/using ngrok tunnel for localhost:${args.port}...`) + const webhookBaseUrl = await ensureNgrokTunnel({ port: args.port }) + console.log(`ngrok HTTPS URL: ${webhookBaseUrl}`) + + const definitions = buildWebhookDefinitions(webhookBaseUrl) + const bridgeApi = createBridgeApi({ apiKey, baseUrl }) + + console.log("Fetching and removing old Bridge sandbox webhooks...") + const { publicKeys, created, existingWebhookCount, deletedWebhookCount } = + await reconcileBridgeWebhooks(bridgeApi, definitions) + console.log(`Bridge reported ${existingWebhookCount} existing webhooks.`) + console.log(`Deleted ${deletedWebhookCount} old active/disabled webhooks.`) + + const existingOverrides = readYamlFile(args.overridesPath) + const merged = mergeDevOverrides(existingOverrides, { + apiKey, + baseUrl, + webhookBaseUrl, + publicKeys, + }) + writeYamlFile(args.overridesPath, merged) + + const activeCount = Object.keys(created).length + console.log(`Created and enabled ${activeCount} Bridge sandbox webhooks.`) + console.log("Smoke check passed: webhook public keys were returned and saved locally.") + console.log(`Updated local overrides: ${args.overridesPath}`) + console.log("") + console.log("Next steps:") + console.log(" 1. Start the Bridge webhook server:") + console.log( + ` yarn bridge-webhook --configPath ${args.baseConfigPath} --configPath ${args.overridesPath}`, + ) + console.log(" 2. Run the sandbox E2E suite:") + console.log(" IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e:ci") +} + +if (require.main === module) { + run().catch((error) => { + console.error(`Bridge webhook setup failed: ${error.message}`) + process.exit(1) + }) +} + +module.exports = { + buildWebhookDefinitions, + createBridgeApi, + extractNgrokHttpsUrl, + mergeDevOverrides, + reconcileBridgeWebhooks, + run, +} diff --git a/dev/setup.sh b/dev/setup.sh index 3b001bef7..6ee3011e8 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -1,6 +1,6 @@ #!/bin/bash # Flash Dev Environment Setup -# Usage: ./dev/setup.sh +# Usage: ./dev/setup.sh [--dev|--webhook] # # This script validates your environment, installs dependencies, # configures credentials, and starts the development server. @@ -16,6 +16,52 @@ info() { echo -e "${GREEN}✓${NC} $1"; } warn() { echo -e "${YELLOW}⚠${NC} $1"; } fail() { echo -e "${RED}✗${NC} $1"; exit 1; } +RUN_WEBHOOK_SETUP=false +WEBHOOK_ONLY=false + +usage() { + cat << EOF +Usage: ./dev/setup.sh [--dev|--webhook] + +Options: + --dev Run normal dev setup, then configure Bridge sandbox webhooks. + --webhook Only configure Bridge sandbox webhooks and local dev overrides. + --help Show this help message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dev) + RUN_WEBHOOK_SETUP=true + shift + ;; + --webhook) + RUN_WEBHOOK_SETUP=true + WEBHOOK_ONLY=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +run_bridge_webhook_setup() { + echo "" + echo "Configuring Bridge sandbox webhooks..." + node dev/setup-bridge-webhooks.js +} + +if [ "$WEBHOOK_ONLY" = true ]; then + run_bridge_webhook_setup + exit 0 +fi + echo "" echo "═══════════════════════════════════════════════════" echo " Flash Backend — Development Environment Setup" @@ -135,6 +181,10 @@ echo "Starting Docker dependencies..." docker compose up bats-deps -d 2>&1 | grep -E '(Created|Started|Running)' || true info "Docker dependencies running" +if [ "$RUN_WEBHOOK_SETUP" = true ]; then + run_bridge_webhook_setup +fi + echo "" echo "═══════════════════════════════════════════════════" echo " ✅ Setup complete!" diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index 6ead13714..65211ab37 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -1,6 +1,6 @@ # Bridge.xyz GraphQL API Reference -All Bridge-related operations require the user to be authenticated and have an **Account Level 2** or higher. +All Bridge-related operations require the user to be authenticated and have an **Account Level 1** or higher. ## Mutations @@ -289,7 +289,7 @@ query BridgeWithdrawals { | Code | Description | | --- | --- | | `BRIDGE_DISABLED` | Bridge integration is disabled in configuration. | -| `BRIDGE_ACCOUNT_LEVEL_ERROR` | User account level is below the required Bridge access level. | +| `BRIDGE_ACCOUNT_LEVEL_ERROR` | User account level is below 1. | | `BRIDGE_INVALID_AMOUNT` | Withdrawal amount is malformed or not positive. | | `BRIDGE_BELOW_MINIMUM_WITHDRAWAL` | Withdrawal amount is below the configured minimum. | | `BRIDGE_KYC_PENDING` | Operation requires approved KYC, but it is still pending. | diff --git a/docs/bridge-integration/ARCHITECTURE.md b/docs/bridge-integration/ARCHITECTURE.md index 0ec665227..5a927736c 100644 --- a/docs/bridge-integration/ARCHITECTURE.md +++ b/docs/bridge-integration/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## System Overview -The Bridge.xyz integration enables USD on-ramp and off-ramp functionality for Flash users. It allows users to convert between USD (via bank transfers) and USDT (on the Tron network), which is then integrated into the Flash ecosystem via IBEX. +The Bridge.xyz integration enables USD on-ramp and off-ramp functionality for Flash users. It allows users to convert between USD (via bank transfers) and USDT (Ethereum), which is then integrated into the Flash ecosystem via IBEX. ## Component Architecture @@ -10,7 +10,7 @@ The integration consists of three main components: 1. **Flash Backend**: The core service that orchestrates the flow between users, Bridge.xyz, and IBEX. It exposes a GraphQL API for the mobile app and handles webhooks from Bridge.xyz. 2. **Bridge.xyz API**: An external service that provides virtual bank accounts, KYC processing, and USD/USDT conversion. -3. **IBEX**: An external service used by Flash to manage Bitcoin and Lightning wallets, and in this context, to provide Tron USDT receive addresses and handle USDT deposits. +3. **IBEX**: An external service used by Flash to manage Bitcoin and Lightning wallets, and in this context, to provide USDT receive addresses and handle USDT deposits. ### Component Diagram @@ -29,7 +29,7 @@ The integration consists of three main components: | USD/USDT | USDT v v +----------------------------+ - | Tron Network | + | Ethereum Network | +----------------------------+ ``` @@ -38,16 +38,16 @@ The integration consists of three main components: ### On-Ramp (USD -> USDT) 1. **KYC**: User initiates KYC via Flash, which creates a Bridge customer and returns a KYC link (Persona). -2. **Virtual Account**: Once KYC is approved, Flash creates a Tron USDT address via IBEX and a Bridge virtual account pointing to that address. +2. **Virtual Account**: Once KYC is approved, Flash creates a Bridge virtual account for receiving USD deposits. 3. **Deposit**: User sends USD to the virtual account. -4. **Conversion**: Bridge converts USD to USDT and sends it to the Tron address. +4. **Conversion**: Bridge converts USD to USDT and sends it to the user's on-chain address. 5. **Credit**: IBEX detects the USDT deposit and notifies Flash via webhook, which credits the user's wallet. ### Off-Ramp (USDT -> USD) 1. **Link Bank**: User links an external bank account via Bridge's hosted UI. 2. **Withdrawal**: User initiates a withdrawal in Flash. -3. **Transfer**: Flash creates a Bridge transfer from the user's Tron address to the linked bank account. +3. **Transfer**: Flash initiates a Bridge transfer from the user's Bridge balance to the linked external bank account. 4. **Conversion**: Bridge converts USDT to USD and sends it to the bank via ACH. ## Technology Stack @@ -60,7 +60,7 @@ The integration consists of three main components: ## Security Model -- **Account Level**: Bridge functionality is restricted to users with Account Level 2 or higher. +- **Account Level**: Bridge functionality is restricted to users with Account Level 1 or higher. - **KYC**: All users must pass Bridge's KYC process (powered by Persona). - **Webhook Verification**: All incoming webhooks from Bridge.xyz are verified using asymmetric RSA-SHA256 signatures. - **Idempotency**: All critical API calls to Bridge include an `Idempotency-Key` to prevent duplicate transactions. diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index f147d3c4b..b1b8e7a40 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -24,7 +24,7 @@ User Flash App Flash Backend Bridge.xyz | (Persona Flow) | | | | | | | 7. kyc.approved | | | | |<--------------------| | - | | | 8. Create Tron Addr | | + | | | 8. Create USDT Addr | | | | |---------------------------------------->| | | | 9. Create Virt Acc | | | | |-------------------->| | @@ -51,12 +51,12 @@ User Flash App Flash Backend Bridge.xyz 5. **Redirect**: App opens the KYC link (Persona). 6. **Verification**: User completes identity verification. 7. **KYC Webhook**: Bridge sends `kyc.approved` webhook to Flash. -8. **Tron Address**: Flash requests a unique Tron USDT receive address from IBEX. -9. **Virtual Account**: Flash creates a Bridge virtual account linked to the Tron address. +8. **USDT Address**: Flash requests a unique USDT receive address from IBEX. +9. **Virtual Account**: Flash creates a Bridge virtual account linked to the receive address. 10. **Display Details**: User sees bank name, routing number, and account number in the app. 11. **Bank Transfer**: User initiates a transfer from their banking app. 12. **Conversion**: Bridge receives USD and converts it to USDT. -13. **Settlement**: Bridge sends USDT to the user's Tron address. +13. **Settlement**: Bridge sends USDT to the user's on-chain address. 14. **IBEX Webhook**: IBEX detects the incoming USDT and notifies Flash. 15. **Credit**: Flash credits the user's USDT wallet and sends a push notification. diff --git a/docs/bridge-integration/WEBHOOKS.md b/docs/bridge-integration/WEBHOOKS.md index 404d64c8d..f11308bd6 100644 --- a/docs/bridge-integration/WEBHOOKS.md +++ b/docs/bridge-integration/WEBHOOKS.md @@ -41,7 +41,7 @@ Sent when a user's KYC application is rejected. ### Deposit Events #### `deposit.completed` -Sent when a USD deposit to a virtual account is successfully converted to USDT and sent to the destination Tron address. +Sent when a USD deposit to a virtual account is successfully converted to USDT and sent to the user's on-chain address. - **Action**: This event is primarily for tracking. The actual crediting of the user's wallet is handled by the IBEX webhook when the USDT arrives. ### Transfer Events diff --git a/package.json b/package.json index 8f7903bd9..30ba998c0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test:unit": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/unit/jest.config.js --bail --verbose $TEST", "test:legacy-integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/legacy-integration/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", "test:integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/integration/jest.config.js --bail --runInBand --verbose $TEST", + "test:bridge-sandbox-e2e": ". ./.env && { [ ! -f ./.env.local ] || . ./.env.local; } && RUN_BRIDGE_SANDBOX_E2E=true LOGLEVEL=warn jest --config ./test/flash/bridge-sandbox-e2e/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", + "test:bridge-sandbox-e2e:ci": ". ./.env && { [ ! -f ./.env.local ] || . ./.env.local; } && RUN_BRIDGE_SANDBOX_E2E=true LOGLEVEL=warn jest --config ./test/flash/bridge-sandbox-e2e/jest.config.js --bail --runInBand --verbose $TEST", "build-docs": "npx spectaql spectaql-config.yml -1", "fix-yaml": "prettier --write '**/*.(yaml|yml)'", "check-yaml": "prettier --check '**/*.(yaml|yml)'", @@ -212,4 +214,4 @@ "**/**/mongoose": "~7.5.1" }, "private": true -} \ No newline at end of file +} diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index b2c19fe57..052947ad7 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -98,7 +98,18 @@ type WithdrawalResult = { createdAt: string } -type KycStatusResult = "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" | null +type KycStatusResult = + | "open" + | "not_started" + | "incomplete" + | "awaiting_questionnaire" + | "awaiting_ubo" + | "under_review" + | "paused" + | "approved" + | "rejected" + | "offboarded" + | null type VirtualAccountResult = { bridgeVirtualAccountId: string @@ -166,9 +177,15 @@ const checkAccountLevel = async ( ): Promise => { const account = await AccountsRepository().findById(accountId) if (account instanceof Error) return account - if (account.level < 2) { - return new BridgeAccountLevelError() + if (account.level < 1) { + const err = new BridgeAccountLevelError() + baseLogger.warn( + { accountId, level: account.level, requiredLevel: 1 }, + "Bridge account level too low", + ) + return err } + return account } @@ -241,12 +258,18 @@ const initiateKyc = async ({ return result } catch (error) { - const bridgeError = error as { statusCode?: number; response?: { existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } } } + const bridgeError = error as { + statusCode?: number + response?: { + existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } + } + } if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { - - // store the customer id and the kyc status - const customerId = toBridgeCustomerId(bridgeError.response.existing_kyc_link.customer_id) + // store the customer id and the kyc status + const customerId = toBridgeCustomerId( + bridgeError.response.existing_kyc_link.customer_id, + ) await AccountsRepository().updateBridgeFields(accountId, { bridgeCustomerId: customerId, bridgeKycStatus: "not_started", @@ -288,7 +311,6 @@ const createVirtualAccount = async ( const account = await checkAccountLevel(accountId) if (account instanceof Error) return account - const PENDING_BRIDGE_STATUSES = new Set([ "incomplete", "awaiting_questionnaire", @@ -298,7 +320,6 @@ const createVirtualAccount = async ( ]) try { - if (!account.bridgeCustomerId) { return new BridgeCustomerNotFoundError( "Account has no Bridge customer ID. Complete KYC first.", @@ -311,19 +332,18 @@ const createVirtualAccount = async ( ) } - const customer = await BridgeApiClient.getCustomer(customerId); + const customer = await BridgeApiClient.getCustomer(customerId) if (customer instanceof Error) { baseLogger.error( { accountId, error: customer }, - "Failed to retrieve Bridge customer status" + "Failed to retrieve Bridge customer status", ) return customer } const kycStatus = customer.status - // Check KYC status if (kycStatus === "offboarded") { return new BridgeKycOffboardedError() @@ -331,10 +351,10 @@ const createVirtualAccount = async ( if (kycStatus === "rejected") { return new BridgeKycRejectedError() } - if (PENDING_BRIDGE_STATUSES.has(kycStatus!) || kycStatus as string === "open") { + if (PENDING_BRIDGE_STATUSES.has(kycStatus!) || (kycStatus as string) === "open") { return new BridgeKycPendingError() } - if (kycStatus !== "active" && kycStatus as string !== "approved") { + if (kycStatus !== "active" && (kycStatus as string) !== "approved") { return new BridgeKycPendingError("KYC not yet completed") } @@ -806,8 +826,6 @@ const getKycStatus = async (accountId: AccountId): Promise va.destination.address === account.bridgeEthereumAddress) + const relatedVa = bridgeVirtualAccounts.find( + (va) => va.destination.address === account.bridgeEthereumAddress, + ) if (relatedVa?.status === "activated") { - // if there's a related VA on Bridge side but it's not in our repo, create it in our repo to keep them in sync if (existingVa instanceof RepositoryError) { const repoResult = await BridgeAccountsRepo.createVirtualAccount({ accountId: accountId as string, bridgeVirtualAccountId: relatedVa.id, bankName: relatedVa.source_deposit_instructions.bank_name || "", - routingNumber: relatedVa.source_deposit_instructions.bank_routing_number || "", - accountNumber: relatedVa.source_deposit_instructions.bank_account_number || "", - accountNumberLast4: relatedVa.source_deposit_instructions.bank_account_number?.slice(-4) || "", + routingNumber: + relatedVa.source_deposit_instructions.bank_routing_number || "", + accountNumber: + relatedVa.source_deposit_instructions.bank_account_number || "", + accountNumberLast4: + relatedVa.source_deposit_instructions.bank_account_number?.slice(-4) || "", }) if (repoResult instanceof Error) { baseLogger.error( @@ -892,7 +913,6 @@ const getKycStatus = async (accountId: AccountId): Promise { + const date = value ? new Date(value) : new Date() + + if (Number.isNaN(date.getTime())) return value ?? "" + + const pad = (part: number) => String(part).padStart(2, "0") + + return [ + `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`, + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`, + ].join(" ") +} + export class BridgeTransferRequest { static doctype = "Bridge Transfer Request" readonly input: BridgeTransferRequestInput @@ -73,8 +86,10 @@ export class BridgeTransferRequest { source_event_id: this.input.sourceEventId, source_event_type: this.input.sourceEventType, source_systems_seen: sourceSystemsSeen || undefined, - first_seen_at: this.input.firstSeenAt, - last_seen_at: this.input.lastSeenAt ?? new Date().toISOString(), + first_seen_at: this.input.firstSeenAt + ? toFrappeDatetime(this.input.firstSeenAt) + : undefined, + last_seen_at: toFrappeDatetime(this.input.lastSeenAt), raw_payload_json: this.input.rawPayload === undefined ? undefined diff --git a/test/flash/bridge-sandbox-e2e/README.md b/test/flash/bridge-sandbox-e2e/README.md new file mode 100644 index 000000000..f43f89b0c --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/README.md @@ -0,0 +1,208 @@ +# Bridge Sandbox E2E Tests + +This suite exercises Bridge sandbox flows through the public GraphQL schema and local webhook handlers. It is opt-in by design: normal unit and integration test runs do not execute these specs. + +## What It Covers + +- Bridge KYC initiation +- Optional Bridge virtual account creation +- Optional external account link URL generation +- External-account webhook handling +- Deposit webhook handling and idempotency +- Withdrawal validation error paths +- Optional cash wallet cutover smoke checks +- Optional ETH-USDT Lightning parity smoke checks + +The suite uses local MongoDB for test user setup, real Bridge/IBEX sandbox configuration for Bridge mutations, and direct Express handler injection for webhook tests. Webhook injection avoids requiring a public tunnel while still exercising the production route handlers. + +## Prerequisites + +Run from the repository root: + +```bash +cd /path/to/your/repo +``` + +### `.env` Setup (First Run) + +The package scripts source `.env` from the project root, then source `.env.local` when it exists. Create or update `.env` with at minimum: + +```bash +# Required +export IBEX_ENVIRONMENT=sandbox +export MONGODB_CON=mongodb://localhost:27017/flash + +# Bridge sandbox — fill from Bridge dashboard +# These are the API key and webhook secret, not stored in .env directly in production: +export BRIDGE_BASE_URL=https://api.sandbox.bridge.xyz/v0 +export BRIDGE_WEBHOOK_URL=http://localhost:4009 +``` + +### Bridge Webhook Setup + +For localhost testing, use ngrok and the setup helper: + +```bash +./dev/setup.sh --webhook +``` + +The helper: + +1. Starts or reuses `ngrok http 4009`. +2. Lists existing Bridge sandbox webhooks. +3. Deletes old active/disabled Bridge sandbox webhooks. +4. Creates fresh `kyc`, `deposit`, `transfer`, and `external_account` webhooks. +5. Copies the returned Bridge webhook public keys into `~/.config/flash/dev-overrides.yaml`. +6. Prints the command to start the local Bridge webhook server. + +The helper writes local secrets and public keys to `~/.config/flash/dev-overrides.yaml`; do not hard-code them in `dev/config/base-config.yaml`. + +### Required Setup + +- Node dependencies installed or available in the worktree (`yarn install`). +- `.env` present and sourceable by the package script. +- MongoDB available using the repo's normal test configuration. +- `IBEX_ENVIRONMENT=sandbox` in `.env`. +- Bridge sandbox webhook public keys populated in `~/.config/flash/dev-overrides.yaml`: + + ```yaml + bridge: + webhook: + publicKeys: + kyc: "" + deposit: "" + transfer: "" + external_account: "" + ``` + +- `src/services/bridge/index.ts` service guard allowing Level 1 accounts (✅ already applied in this PR). + +The setup file enforces the two safety gates: + +- `RUN_BRIDGE_SANDBOX_E2E=true` +- `IBEX_ENVIRONMENT=sandbox` + +The package scripts set `RUN_BRIDGE_SANDBOX_E2E=true` automatically, but `IBEX_ENVIRONMENT` must already be present in `.env` or exported in the shell. + +## First Run (Human Verification) + +Run from the worktree root: + +```bash +cd /path/to/your/repo +source .env +IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e +``` + +### What to check on first run + +| Layer | What to verify | If it fails | +|-------|---------------|------------| +| Preflight | Source-code check of `checkAccountLevel()` allows level ≥ 1 | `src/services/bridge/index.ts` guard must be `level < 1`, not `level < 2` | +| KYC spec | `bridgeInitiateKyc` returns `{kycLink, tosLink}` URLs | Ensure ENG-345 deployed, sandbox has Bridge customer API set up | +| Virtual account | Skipped by default; with `BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true`, `bridgeCreateVirtualAccount` returns account details | Requires a Bridge-side KYC-approved sandbox customer; local webhook injection alone does not approve the hosted Bridge customer | +| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkUrl, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for hosted bank-linking | +| Deposit webhook | Injected webhook processes and persists deposit | Verify webhook secret in config.yaml | +| Withdrawal error paths | Validation errors returned for invalid inputs | Check withdrawal schema deployed (ENG-348) | +| Withdrawal **success** path | ⚠️ **Not expected to pass first run** — requires real KYC-approved sandbox customer, funded wallet, and verified external account. | The full withdrawal flow only runs with `BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true`; error-path tests run without it | + +### If something fails + +1. Check `IBEX_ENVIRONMENT` is `sandbox` (not `production`) +2. Confirm MongoDB is running: `mongosh --eval "db.adminCommand('ping')"` +3. Run `./dev/setup.sh --webhook` again to refresh ngrok, Bridge webhook endpoints, and local public keys +4. Preflight failure → `src/services/bridge/index.ts` still has `level < 2` — apply the Task 0 fix +5. KYC/VA failures → confirm the corresponding ENG issue is deployed to sandbox + +## Commands + +Run the whole suite: + +```bash +export IBEX_ENVIRONMENT=sandbox +yarn test:bridge-sandbox-e2e +``` + +Run the CI-style variant without `pino-pretty`: + +```bash +export IBEX_ENVIRONMENT=sandbox +yarn test:bridge-sandbox-e2e:ci +``` + +Run one spec: + +```bash +export IBEX_ENVIRONMENT=sandbox +TEST=test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts yarn test:bridge-sandbox-e2e:ci +``` + +Increase timeout for slow sandbox calls: + +```bash +export IBEX_ENVIRONMENT=sandbox +JEST_TIMEOUT=240000 yarn test:bridge-sandbox-e2e:ci +``` + +## Optional Smoke Gates + +These specs are skipped unless explicitly enabled: + +```bash +export IBEX_ENVIRONMENT=sandbox +CUTOVER_TESTS=true yarn test:bridge-sandbox-e2e:ci +``` + +```bash +export IBEX_ENVIRONMENT=sandbox +LN_PARITY_TESTS=true yarn test:bridge-sandbox-e2e:ci +``` + +These Bridge-hosted success paths are also skipped unless explicitly enabled because +they require sandbox state/entitlements outside the local test harness: + +```bash +export IBEX_ENVIRONMENT=sandbox +BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true yarn test:bridge-sandbox-e2e:ci +``` + +```bash +export IBEX_ENVIRONMENT=sandbox +BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true yarn test:bridge-sandbox-e2e:ci +``` + +## Files + +- `jest.config.js` - Jest config scoped to this suite. +- `jest.setup.ts` - opt-in guards, yargs config-path mock, MongoDB setup, Redis/Mongo cleanup. +- `config-overrides.yaml` - sandbox-only non-secret overrides used by Jest after local dev overrides. +- `preflight.ts` - source check that verifies Bridge Level 1 access is not blocked by the service guard. +- `helpers.ts` - test user creation, GraphQL execution, Bridge mutation wrappers, webhook injection, ERPNext lookup, deposit lookup. +- `helpers/http-utils.ts` - mock Express request/response objects for route-handler injection. +- `kyc-virtual-account.spec.ts` - KYC link and virtual account flow. +- `external-account.spec.ts` - Plaid link URL and external-account webhook behavior. +- `deposit-withdrawal.spec.ts` - deposit webhook handling, deposit persistence, withdrawal validation paths. +- `cutover-state.spec.ts` - optional cash wallet cutover state smoke test. +- `ln-parity.spec.ts` - optional Lightning USD invoice smoke test. + +## Known Limitations + +- The external account spec verifies injected webhook behavior by default. Link URL generation is gated because some Bridge sandbox keys/customers are not authorized for hosted bank-linking. +- The deposit tests validate webhook handling and persistence. Full wallet-balance reconciliation depends on sandbox deposit state and is not asserted yet. +- Virtual account and withdrawal success are not covered by default because they require a real Bridge-side KYC-approved sandbox customer, funded wallet, and verified external account. +- Deposit webhook processing writes `BridgeTransferRequest` audit rows to the local ERPNext instance when `~/.config/flash/dev-overrides.yaml` points Frappe at the local Docker site. +- The suite uses Jest `forceExit` because importing the public GraphQL schema creates app-wide Redis clients; teardown calls `disconnectAll()`, but ioredis TCP handles can otherwise keep the opt-in E2E process alive after the tests finish. + +## Troubleshooting + +If the suite exits before running tests, check the setup guards first: + +```bash +echo "$IBEX_ENVIRONMENT" +``` + +It must print `sandbox`. + +If MongoDB setup fails, start the repo's normal local services before rerunning. The suite creates local test users and wallets before calling Bridge flows. + +If preflight fails, inspect the guard in `src/services/bridge/index.ts`. The suite expects `BridgeService.checkAccountLevel()` to block Level 0 only, so Level 1 accounts can run Bridge operations. diff --git a/test/flash/bridge-sandbox-e2e/config-overrides.yaml b/test/flash/bridge-sandbox-e2e/config-overrides.yaml new file mode 100644 index 000000000..f00b74ad0 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/config-overrides.yaml @@ -0,0 +1,2 @@ +sendgrid: + apiKey: "SG.sandbox-e2e-placeholder" diff --git a/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts b/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts new file mode 100644 index 000000000..ab69d191d --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts @@ -0,0 +1,102 @@ +/** + * Bridge Sandbox E2E — Post-Cutover State Assertions + * + * Verifies the system-wide cash wallet cutover state via the public + * `cashWalletCutover` query, and optionally validates that accounts + * see correct wallet routing after cutover. + * + * The cutover state is a system-wide singleton (not per-account) stored in + * the CashWalletCutoverConfig collection. + * + * This spec is guarded by SKIP_CUTOVER_TESTS — it only runs actively when + * CUTOVER_TESTS=true is set, since sandbox environments may not have + * cutover infrastructure seeded. + * + * Verified shapes (from source audit of `cashWalletCutover.ts`, `lifecycle.ts`): + * - cashWalletCutover query (public) returns CashWalletCutoverObject: + * { state: CashWalletCutoverState!, scheduledAt, startedAt, + * completedAt, pausedAt, pauseReason, cutoverVersion: Int!, + * runId, updatedBy, updatedAt: Timestamp! } + * - Valid states: "not_started", "started", "provisioned", "balance_read", + * "invoice_created", "balance_move_sending", "balance_move_sent", + * "balance_move_verified", "fee_reimbursement_invoice_created", + * "fee_reimbursement_sending", "fee_reimbursed", "pointer_flipped", + * "legacy_zero_verified", "complete", "failed", "requires_operator_review", + * "skipped_already_migrated", "rollback_started", "rolled_back" + */ + +const CUTOVER_TESTS = process.env.CUTOVER_TESTS === "true" + +;(CUTOVER_TESTS ? describe : describe.skip)("Post-Cutover State", () => { + describe("System-wide cutover config query", () => { + // The cutover query doesn't use auth context, but + // execQuery requires an accountId for context building. + const dummyAccountId = `acct_cutover_test_${Date.now()}` + + it("returns cashWalletCutover with expected shape", async () => { + const { execQuery } = await import("./helpers") + + const source = ` + query CashWalletCutover { + cashWalletCutover { + state + cutoverVersion + runId + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + updatedAt + updatedBy + } + } + ` + + const response = await execQuery(source, dummyAccountId) + + expect(response.cashWalletCutover).toBeDefined() + expect(typeof response.cashWalletCutover.state).toBe("string") + expect(response.cashWalletCutover.cutoverVersion).toEqual(expect.any(Number)) + expect(typeof response.cashWalletCutover.updatedAt).toBe("string") + }) + + it("state is a valid cutover state enum value", async () => { + const { execQuery } = await import("./helpers") + + const VALID_STATES = new Set([ + "not_started", + "started", + "provisioned", + "balance_read", + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", + "legacy_zero_verified", + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", + ]) + + const source = ` + query CashWalletCutover { + cashWalletCutover { + state + } + } + ` + + const response = await execQuery(source, dummyAccountId) + + expect(VALID_STATES.has(response.cashWalletCutover?.state)).toBe(true) + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts b/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts new file mode 100644 index 000000000..d7389032a --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts @@ -0,0 +1,194 @@ +/** + * Bridge Sandbox E2E — Deposit → Withdrawal Lifecycle + * + * Tests the deposit webhook processing and withdrawal initiation flow. + * + * Verified shapes (from source audit): + * - depositHandler accepts + * { event_id, event_object: { id, state, amount, currency, on_behalf_of, receipt? } } + * returns { status: "success" } on 200 + * - bridgeInitiateWithdrawal(input: { amount: String!, externalAccountId: ID! }) returns + * { errors, withdrawal: { id, amount, currency, status, failureReason, createdAt } } + * + * The deposit handler is fully testable via webhook injection — it's self-contained + * and persists to BridgeDeposits + optional ERPNext. + * + * The withdrawal mutation calls Bridge API's createTransfer endpoint, which requires + * real sandbox state (KYC'd customer, funded wallet, verified external account). + * Full-flow tests are guarded by BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true. + * Error-path tests verify expected failures when prerequisites are missing. + * + * ⚠️ The deposit handler triggers reconciliation only when state === "payment_processed" + * WITH a destination_tx_hash. For sandbox testing, the reconciliation may or may not + * succeed — the test asserts the handler responds correctly regardless. + */ + +import { + createBridgeSandboxUser, + initiateWithdrawal, + injectDepositWebhook, + findDepositLogByEventId, + BridgeTestUser, +} from "./helpers" + +describe("Bridge Deposit → Withdrawal", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + }) + + // ============ Deposit Webhook ============ + + describe("Deposit Webhook Processing", () => { + it("processes a valid deposit webhook and returns success", async () => { + const eventId = `dep-test-${Date.now()}` + const response = await injectDepositWebhook({ + event_id: eventId, + event_object: { + id: `transfer_test_${Date.now()}`, + state: "payment_processed", + amount: "100.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + receipt: { + initial_amount: "100.00", + subtotal_amount: "100.00", + final_amount: "96.00", + developer_fee: "2.00", + destination_tx_hash: `0x${Date.now().toString(16)}dead`, + }, + }, + }) + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + expect(response.body.status).toBe("success") + }) + + it("persists a deposit log to the BridgeDeposits collection", async () => { + const eventId = `dep-log-${Date.now()}` + const transferId = `transfer_log_${Date.now()}` + const response = await injectDepositWebhook({ + event_id: eventId, + event_object: { + id: transferId, + state: "payment_processed", + amount: "50.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + }) + + expect(response.status).toBe(200) + + // Query the deposit log directly + const log = await findDepositLogByEventId(eventId) + expect(log).toBeTruthy() + expect(log!.eventId).toBe(eventId) + expect(log!.transferId).toBe(transferId) + expect(log!.amount).toBe("50.00") + expect(log!.currency).toBe("usdt") + expect(log!.state).toBe("payment_processed") + }) + + it("returns already_processed for duplicate deposit webhooks", async () => { + const eventId = `dep-dup-${Date.now()}` + const payload = { + event_id: eventId, + event_object: { + id: `transfer_dup_${Date.now()}`, + state: "payment_processed", + amount: "25.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + } + + // First call + const first = await injectDepositWebhook(payload) + expect(first.status).toBe(200) + expect(first.body.status).toBe("success") + + // Duplicate event_id — idempotency lock fires before the handler re-processes + const second = await injectDepositWebhook(payload) + expect(second.status).toBe(200) + expect(second.body.status).toBe("already_processed") + }) + + it("handles intermediate state transitions (not just payment_processed)", async () => { + const response = await injectDepositWebhook({ + event_id: `dep-pending-${Date.now()}`, + event_object: { + id: `transfer_pending_${Date.now()}`, + state: "pending_transfer", + amount: "75.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + }) + + // Intermediate states are logged and return success but do NOT trigger + // reconciliation (only payment_processed with tx hash does) + expect(response.status).toBe(200) + expect(response.body.status).toBe("success") + }) + + it("rejects a deposit webhook with missing required fields", async () => { + const response = await injectDepositWebhook({ + event_id: "dep-invalid", + event_object: { + id: "", + state: "", + amount: "", + currency: "", + on_behalf_of: "", + }, + }) + + // Handler validates presence of event_object.id, event_id, amount, on_behalf_of + expect(response.status).toBe(400) + }) + }) + + // ============ Withdrawal ============ + + describe("Withdrawal Initiation", () => { + it("rejects withdrawal when amount is below minimum", async () => { + // minimum withdrawal is 2 (from config), so 0.50 should be rejected + const result = await initiateWithdrawal(user.accountId, { + amount: "0.50", + externalAccountId: "ext_acct_placeholder", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0].message).toMatch(/minimum/i) + expect(result.withdrawal).toBeNull() + }) + + it("rejects withdrawal when amount is invalid (non-numeric)", async () => { + const result = await initiateWithdrawal(user.accountId, { + amount: "abc", + externalAccountId: "ext_acct_placeholder", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.withdrawal).toBeNull() + }) + + it("rejects withdrawal when account has no Bridge customer ID", async () => { + // No KYC has been initiated for this account, so bridgeCustomerId is null + const result = await initiateWithdrawal(user.accountId, { + amount: "50.00", + externalAccountId: "ext_acct_no_customer", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0].message).toMatch(/customer|KYC/i) + expect(result.withdrawal).toBeNull() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/external-account.spec.ts b/test/flash/bridge-sandbox-e2e/external-account.spec.ts new file mode 100644 index 000000000..412eefd03 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/external-account.spec.ts @@ -0,0 +1,156 @@ +/** + * Bridge Sandbox E2E — External Account (Plaid) Flow + * + * Tests the `bridgeAddExternalAccount` mutation and external-account + * webhook handling. + * + * Verified shapes (from source audit): + * - bridgeAddExternalAccount returns + * { errors, externalAccount: { linkUrl: string!, expiresAt: string! } } + * - externalAccountHandler accepts + * { event_id, event_object: { id, customer_id, bank_name, last_4, active } } + * and returns { status: "success" } or { status: "already_processed" } on 200 + * + * ⚠️ Plaid sandbox linking is a manual step — the test generates the link URL + * and verifies it's well-formed, then simulates the webhook that follows + * successful Plaid linking. The test does NOT automate the Plaid browser UI. + * + * ⚠️ Some sandboxes return "link_url" instead of "linkUrl" — check actual response + * against the configured return shape and update assertions if needed. + */ + +import { + createBridgeSandboxUser, + initiateKyc, + addExternalAccount, + injectKycWebhook, + injectExternalAccountWebhook, + getAccountById, + BridgeTestUser, +} from "./helpers" + +const EXTERNAL_ACCOUNT_LINK_TESTS = + process.env.BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED === "true" + +describe("Bridge External Account", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + + // === Prerequisites: KYC + Virtual Account === + const kycResult = await initiateKyc( + user.accountId, + `ext-acct-${user.accountId.slice(-8)}-${Date.now()}@test.flashapp.me`, + ) + if (kycResult.errors?.length) { + throw new Error(`KYC initiation failed: ${kycResult.errors[0].message}`) + } + + // Approve KYC via webhook injection using the customer ID persisted by KYC initiation. + const account = await getAccountById(user.accountId) + const webhookCustomerId = account.bridgeCustomerId + if (!webhookCustomerId) { + throw new Error("KYC initiation did not persist a Bridge customer ID") + } + user.customerId = webhookCustomerId + const webhookResult = await injectKycWebhook({ + event_id: `ext-acct-kyc-${Date.now()}`, + event_object: { customer_id: webhookCustomerId, kyc_status: "approved" }, + }) + if (webhookResult.status !== 200) { + throw new Error(`KYC webhook failed with status ${webhookResult.status}`) + } + }) + ;(EXTERNAL_ACCOUNT_LINK_TESTS ? describe : describe.skip)( + "Plaid Link URL Generation", + () => { + it("generates a Plaid link URL when called", async () => { + const result = await addExternalAccount(user.accountId) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.externalAccount).toBeDefined() + expect(result.externalAccount!.linkUrl).toBeTruthy() + expect(result.externalAccount!.linkUrl).toMatch(/^https:\/\//) + expect(result.externalAccount!.expiresAt).toBeTruthy() + }) + + it("link URL is different on each call (one-time use tokens)", async () => { + const result1 = await addExternalAccount(user.accountId) + const result2 = await addExternalAccount(user.accountId) + + expect(result1.errors).toHaveLength(0) + expect(result2.errors).toHaveLength(0) + + // Plaid link tokens are one-time use; consecutive calls should differ + expect(result1.externalAccount?.linkUrl).toBeTruthy() + expect(result2.externalAccount?.linkUrl).toBeTruthy() + expect(result1.externalAccount!.linkUrl).not.toBe( + result2.externalAccount!.linkUrl, + ) + }) + }, + ) + + describe("External Account Webhook Processing", () => { + it("processes a valid external-account webhook and returns success", async () => { + // This simulates what Bridge sends after a user completes the Plaid flow + const response = await injectExternalAccountWebhook({ + event_id: `ext-created-${Date.now()}`, + event_object: { + id: `ext_acct_test_${Date.now()}`, + customer_id: user.customerId!, + bank_name: "Test Bank", + last_4: "1234", + active: true, + }, + }) + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + expect(response.body.status).toBe("success") + }) + + it("returns already_processed for duplicate external-account webhooks", async () => { + const eventId = `ext-duplicate-${Date.now()}` + const payload = { + event_id: eventId, + event_object: { + id: `ext_acct_dup_${Date.now()}`, + customer_id: user.customerId!, + bank_name: "Test Bank", + last_4: "9999", + active: true, + }, + } + + // First call — should succeed + const first = await injectExternalAccountWebhook(payload) + expect(first.status).toBe(200) + expect(first.body.status).toBe("success") + + // Second call with same event_id — idempotency lock + const second = await injectExternalAccountWebhook(payload) + expect(second.status).toBe(200) + expect(second.body.status).toBe("already_processed") + }) + + it("rejects a webhook with missing customer_id", async () => { + const response = await injectExternalAccountWebhook({ + event_id: `ext-missing-${Date.now()}`, + event_object: { + id: "ext_acct_missing_cus", + customer_id: "", + bank_name: "No Customer", + last_4: "0000", + active: true, + }, + }) + + // Handler validates customer_id presence — returns 400 or 503 depending + // on whether it fails the initial guard (400) or the account lookup (503) + expect(response.status).toBeGreaterThanOrEqual(400) + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/helpers.ts b/test/flash/bridge-sandbox-e2e/helpers.ts new file mode 100644 index 000000000..e2fb5b939 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/helpers.ts @@ -0,0 +1,374 @@ +/** + * Bridge Sandbox E2E — Helpers + * + * Shared utilities for sandbox end-to-end tests. + * Follows the test/galoy/helpers pattern: executes GraphQL operations + * via the schema (graphql() from the graphql library), not via + * direct resolver calls. + * + * All GraphQL return shapes verified against source code. + */ + +import { graphql, Source } from "graphql" + +import { createAccountWithPhoneIdentifier } from "@app/accounts" +import { addWalletIfNonexistent } from "@app/accounts/add-wallet" +import { DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES } from "@app/cash-wallet-cutover/client-capability" +import { getDefaultAccountsConfig } from "@config" +import { AccountLevel } from "@domain/accounts" +import { CouldNotFindAccountFromKratosIdError, RepositoryError } from "@domain/errors" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { gqlMainSchema } from "@graphql/public" +import { depositHandler } from "@services/bridge/webhook-server/routes/deposit" +import { externalAccountHandler } from "@services/bridge/webhook-server/routes/external-account" +import { kycHandler } from "@services/bridge/webhook-server/routes/kyc" +import { AuthWithPhonePasswordlessService } from "@services/kratos" +import { + AccountsRepository, + UsersRepository, + WalletsRepository, +} from "@services/mongoose" +import { AccountsRepository as AccountsRepo } from "@services/mongoose/accounts" +import { Account as AccountModel, BridgeDeposits } from "@services/mongoose/schema" + +import { createReqRes } from "./helpers/http-utils" + +import { randomPhone } from "test/galoy/helpers" + +// ============ Types ============ + +export interface BridgeTestUser { + accountId: string + walletId: string + customerId?: string + virtualAccountId?: string + level: AccountLevel +} + +type GraphQlErrorResponse = { + errors: Array<{ message: string }> +} + +type KycInitiationResult = GraphQlErrorResponse & { + kycLink?: { kycLink: string; tosLink: string } +} + +type VirtualAccountResult = GraphQlErrorResponse & { + virtualAccount?: Record +} + +type ExternalAccountResult = GraphQlErrorResponse & { + externalAccount?: { linkUrl: string; expiresAt: string } +} + +type WithdrawalResult = GraphQlErrorResponse & { + withdrawal?: Record | null +} + +type HandlerResponse = { + status: number + body?: unknown +} + +// ============ Schema Execution ============ + +function buildContext(accountId: string): GraphQLPublicContextAuth { + return { + domainAccount: { id: accountId, level: 1 }, + cashWalletClientCapabilities: DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES, + } as GraphQLPublicContextAuth +} + +export async function execQuery( + source: string, + accountId: string, + variableValues?: Record, +): Promise | GraphQlErrorResponse> { + const result = await graphql({ + schema: gqlMainSchema, + source: new Source(source), + contextValue: buildContext(accountId), + variableValues, + }) + if (result.errors) { + return { errors: result.errors.map((error) => ({ message: error.message })) } + } + return result.data ?? {} +} + +// ============ User Creation ============ + +/** + * Create a test user with the given account level and USDT wallet. + * Persists to local MongoDB (not Bridge sandbox). + */ +export async function createBridgeSandboxUser( + level: AccountLevel = AccountLevel.One, +): Promise { + const phone = randomPhone() + const kratosUserId = await AuthWithPhonePasswordlessService().createIdentityNoSession({ + phone, + }) + if (kratosUserId instanceof Error) throw kratosUserId + + // Create Kratos user + const user = await UsersRepository().update({ + id: kratosUserId, + deviceTokens: [`token-${kratosUserId}`] as DeviceToken[], + phone, + }) + if (user instanceof Error) throw user + + // Create account + let account = await AccountsRepository().findByUserId(kratosUserId) + + if (account instanceof CouldNotFindAccountFromKratosIdError) { + account = await createAccountWithPhoneIdentifier({ + newAccountInfo: { phone, kratosUserId }, + config: { + ...getDefaultAccountsConfig(), + initialLevel: level, + }, + }) + if (account instanceof Error) throw account + + // Add USDT wallet for Bridge flows + const usdtWallet = await addWalletIfNonexistent({ + currency: WalletCurrency.Usdt, + accountId: account.id, + type: WalletType.Checking, + }) + if (usdtWallet instanceof Error) throw usdtWallet + + // Set account level directly (createAccountWithPhoneIdentifier may not enforce initialLevel) + await AccountModel.updateOne({ _id: account.id }, { $set: { level } }) + } + + if (account instanceof Error) throw account + + // Get the USDT wallet + const walletsResult = await WalletsRepository().listByAccountId(account.id) + if (walletsResult instanceof RepositoryError) throw walletsResult + const usdtWallet = walletsResult.find( + (wallet) => + wallet.currency === WalletCurrency.Usdt && wallet.type === WalletType.Checking, + ) + if (!usdtWallet) throw new Error("No USDT wallet created for sandbox user") + + return { + accountId: account.id, + walletId: usdtWallet.id, + level, + } +} + +// ============ Bridge Mutation Wrappers ============ + +const BRIDGE_INITIATE_KYC = ` + mutation BridgeInitiateKyc($input: BridgeInitiateKycInput!) { + bridgeInitiateKyc(input: $input) { + errors { message } + kycLink { kycLink tosLink } + } + } +` + +const BRIDGE_CREATE_VIRTUAL_ACCOUNT = ` + mutation BridgeCreateVirtualAccount { + bridgeCreateVirtualAccount { + errors { message } + virtualAccount { id bankName routingNumber accountNumber accountNumberLast4 pending message kycLink tosLink } + } + } +` + +const BRIDGE_ADD_EXTERNAL_ACCOUNT = ` + mutation BridgeAddExternalAccount { + bridgeAddExternalAccount { + errors { message } + externalAccount { linkUrl expiresAt } + } + } +` + +const BRIDGE_REQUEST_WITHDRAWAL = ` + mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { message } + withdrawal { id amount currency status failureReason createdAt } + } + } +` + +/** + * Initiate Bridge KYC for a user. + * Returns { errors, kycLink: { kycLink, tosLink } | null } + */ +export async function initiateKyc( + accountId: string, + email: string, +): Promise { + const data = (await execQuery(BRIDGE_INITIATE_KYC, accountId, { + input: { email }, + })) as { bridgeInitiateKyc?: KycInitiationResult } + return data?.bridgeInitiateKyc ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Create a virtual account for a user. + * Requires KYC to be completed first. + */ +export async function createVirtualAccount( + accountId: string, +): Promise { + const data = (await execQuery(BRIDGE_CREATE_VIRTUAL_ACCOUNT, accountId)) as { + bridgeCreateVirtualAccount?: VirtualAccountResult + } + return data?.bridgeCreateVirtualAccount ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Add an external account (Plaid). + * Requires KYC + virtual account to be completed first. + */ +export async function addExternalAccount( + accountId: string, +): Promise { + const data = (await execQuery(BRIDGE_ADD_EXTERNAL_ACCOUNT, accountId)) as { + bridgeAddExternalAccount?: ExternalAccountResult + } + return data?.bridgeAddExternalAccount ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Initiate a withdrawal. + */ +export async function initiateWithdrawal( + accountId: string, + input: { amount: string; externalAccountId: string }, +): Promise { + const data = (await execQuery(BRIDGE_REQUEST_WITHDRAWAL, accountId, { + input, + })) as { bridgeRequestWithdrawal?: WithdrawalResult } + return data?.bridgeRequestWithdrawal ?? { errors: [{ message: "No data returned" }] } +} + +// ============ Webhook Injection ============ + +/** + * Inject a KYC webhook payload directly into the Express route handler. + * Tests the same handler code that runs in production. + */ +export async function injectKycWebhook(payload: { + event_id: string + event_object: { customer_id: string; kyc_status: string } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await kycHandler( + req as Parameters[0], + res as Parameters[1], + ) + return { status: res.statusCode, body: res._body } +} + +/** + * Inject an external account webhook payload directly into the Express route handler. + * Simulates Bridge sending an external_account.created event after Plaid linking. + */ +export async function injectExternalAccountWebhook(payload: { + event_id: string + event_object: { + id: string + customer_id: string + bank_name?: string + last_4?: string + active?: boolean + } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await externalAccountHandler( + req as Parameters[0], + res as Parameters[1], + ) + return { status: res.statusCode, body: res._body } +} + +/** + * Inject a deposit webhook payload directly into the Express route handler. + * Simulates Bridge sending a transfer state-transition event. + */ +export async function injectDepositWebhook(payload: { + event_id: string + event_object: { + id: string + state: string + amount: string + currency: string + on_behalf_of: string + receipt?: { + initial_amount?: string + subtotal_amount?: string + final_amount?: string + developer_fee?: string + destination_tx_hash?: string + } + } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await depositHandler( + req as Parameters[0], + res as Parameters[1], + ) + return { status: res.statusCode, body: res._body } +} + +// ============ ERPNext Verification ============ + +/** + * Query ERPNext for a matching audit row. + * Silently returns null when ERPNEXT_URL is not set. + */ +export async function verifyErpnextAuditRow( + docType: string, + referenceId: string, +): Promise | null> { + if (!process.env.ERPNEXT_URL) { + return null + } + try { + const response = await fetch( + `${process.env.ERPNEXT_URL}/api/resource/${docType}?filters=${JSON.stringify([["reference_id", "=", referenceId]])}`, + { + headers: { + Authorization: `token ${process.env.ERPNEXT_API_KEY}:${process.env.ERPNEXT_API_SECRET}`, + }, + }, + ) + if (!response.ok) return null + const json = await response.json() + return json.data?.[0] || null + } catch { + return null + } +} + +// ============ Deposit Log Lookup ============ + +/** + * Find a deposit log by event ID directly from MongoDB. + */ +export async function findDepositLogByEventId( + eventId: string, +): Promise | null> { + const doc = await BridgeDeposits.findOne({ eventId }).lean().exec() + return (doc as Record) ?? null +} + +// ============ Account Lookup ============ + +export async function getAccountById(accountId: string) { + const account = await AccountsRepo().findById(accountId as AccountId) + if (account instanceof Error) throw account + return account +} diff --git a/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts b/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts new file mode 100644 index 000000000..a5d9942ef --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts @@ -0,0 +1,68 @@ +/** + * Create mock Express req/res objects for webhook handler injection. + */ + +import { EventEmitter } from "events" + +interface MockRequestOptions { + body?: Record + headers?: Record + method?: string + path?: string +} + +class MockResponse extends EventEmitter { + public statusCode: number = 200 + public _body: unknown = null + public _headers: Record = {} + public _ended: boolean = false + + status(code: number): this { + this.statusCode = code + return this + } + + json(data: unknown): this { + this._body = data + this._ended = true + this.emit("finish") + return this + } + + send(data: unknown): this { + this._body = data + this._ended = true + this.emit("finish") + return this + } + + setHeader(name: string, value: string): this { + this._headers[name] = value + return this + } + + end(): this { + this._ended = true + this.emit("finish") + return this + } +} + +export function createReqRes(options: MockRequestOptions = {}): { + req: Record + res: MockResponse +} { + const req: Record = { + body: options.body || {}, + headers: options.headers || { "content-type": "application/json" }, + method: options.method || "POST", + path: options.path || "/", + ip: "127.0.0.1", + query: {}, + params: {}, + } + + const res = new MockResponse() + + return { req, res } +} diff --git a/test/flash/bridge-sandbox-e2e/jest.config.js b/test/flash/bridge-sandbox-e2e/jest.config.js new file mode 100644 index 000000000..021f2d501 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/jest.config.js @@ -0,0 +1,27 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const swcConfig = require("../../swc-config.json") + +module.exports = { + moduleFileExtensions: ["js", "json", "ts", "cjs", "mjs"], + rootDir: "../../../", + roots: ["/test/flash/bridge-sandbox-e2e"], + transform: { + "^.+\\.(t|j)sx?$": ["@swc/jest", swcConfig], + }, + testRegex: ".*\\.spec\\.ts$", + setupFilesAfterEnv: ["/test/flash/bridge-sandbox-e2e/jest.setup.ts"], + testEnvironment: "node", + forceExit: true, + moduleNameMapper: { + "^@config$": ["src/config/index"], + "^@app$": ["src/app/index"], + "^@utils$": ["src/utils/index"], + "^@core/(.*)$": ["src/core/$1"], + "^@app/(.*)$": ["src/app/$1"], + "^@domain/(.*)$": ["src/domain/$1"], + "^@services/(.*)$": ["src/services/$1"], + "^@servers/(.*)$": ["src/servers/$1"], + "^@graphql/(.*)$": ["src/graphql/$1"], + "^test/(.*)$": ["test/$1"], + }, +} diff --git a/test/flash/bridge-sandbox-e2e/jest.setup.ts b/test/flash/bridge-sandbox-e2e/jest.setup.ts new file mode 100644 index 000000000..c4bbfe2f2 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/jest.setup.ts @@ -0,0 +1,85 @@ +/** + * Bridge Sandbox E2E Setup + * + * This suite requires: + * - RUN_BRIDGE_SANDBOX_E2E=true in environment + * - A running backend connected to Bridge sandbox + * - BridgeService.checkAccountLevel() allowing level >= 1 + * - IBEX_ENVIRONMENT=sandbox (safety guard) + */ + +// Must mock yargs BEFORE any config imports so yaml.ts gets a valid --configPath +jest.mock("yargs", () => { + const defaultOverridesPath = `${process.env.CONFIG_PATH ?? `${process.env.HOME}/.config/flash`}/dev-overrides.yaml` + const overridePath = process.env.BRIDGE_SANDBOX_CONFIG_PATH ?? defaultOverridesPath + const yargsMock = { + option: jest.fn().mockReturnThis(), + argv: { + configPath: [ + "./dev/config/base-config.yaml", + overridePath, + "./test/flash/bridge-sandbox-e2e/config-overrides.yaml", + ], + }, + } + return jest.fn(() => yargsMock) +}) + +jest.mock("@services/notifications/firebase", () => ({ + __esModule: true, + default: { + isDeviceTokenValid: jest.fn(async () => true), + subscribeToTopics: jest.fn(async () => undefined), + }, + messaging: null, +})) + +import { setupMongoConnection } from "@services/mongodb" +import { disconnectAll } from "@services/redis" + +import { preflightServiceLevelGuard } from "./preflight" + +let mongoose: Awaited> | undefined + +beforeAll(async () => { + // === Guard: Must be explicitly opted in === + if (!process.env.RUN_BRIDGE_SANDBOX_E2E) { + throw new Error( + "Bridge sandbox E2E skipped. Set RUN_BRIDGE_SANDBOX_E2E=true in env to run.", + ) + } + + // === Guard: Must be pointed at sandbox, not production === + // Set via: export IBEX_ENVIRONMENT=sandbox (or add to .env) + if (process.env.IBEX_ENVIRONMENT !== "sandbox") { + throw new Error( + "IBEX_ENVIRONMENT must be 'sandbox' for Bridge sandbox E2E tests.\n" + + " Run: export IBEX_ENVIRONMENT=sandbox\n" + + " Or add to .env: export IBEX_ENVIRONMENT=sandbox", + ) + } + + // === Connect MongoDB for test user creation === + try { + mongoose = await setupMongoConnection(true) + } catch (err) { + throw new Error( + `MongoDB connection failed: ${err instanceof Error ? err.message : err}`, + ) + } + + // === Preflight: Verify service-level guard allows level >= 1 === + const preflightOk = preflightServiceLevelGuard() + if (!preflightOk) { + throw new Error("Preflight failed — aborting suite.") + } +}) + +afterAll(async () => { + disconnectAll() + if (mongoose) { + await mongoose.connection.close() + } +}) + +jest.setTimeout(Number(process.env.JEST_TIMEOUT) || 120000) diff --git a/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts b/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts new file mode 100644 index 000000000..1baffb0e5 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts @@ -0,0 +1,103 @@ +/** + * Bridge Sandbox E2E — KYC + Virtual Account Flow + * + * Tests the complete KYC initiation → webhook processing → virtual account creation flow. + * + * Verified return shapes (from source audit): + * - bridgeInitiateKyc returns { errors, kycLink: { kycLink: string!, tosLink: string! } } + * - bridgeCreateVirtualAccount returns { errors, virtualAccount: { id, bankName, ... } } + * - No ERPNext writer exists for BridgeVirtualAccount (only BridgeTransferRequest exists) + */ + +import { + createBridgeSandboxUser, + initiateKyc, + createVirtualAccount, + injectKycWebhook, + getAccountById, + BridgeTestUser, +} from "./helpers" + +const VIRTUAL_ACCOUNT_TESTS = + process.env.BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED === "true" + +describe("Bridge KYC → Virtual Account", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + }) + + describe("KYC Initiation", () => { + it("initiates KYC and returns a KYC link URL and TOS link", async () => { + const result = await initiateKyc( + user.accountId, + `sandbox-${user.accountId.slice(-8)}@test.flashapp.me`, + ) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.kycLink).toBeDefined() + expect(result.kycLink!.kycLink).toBeTruthy() + expect(result.kycLink!.kycLink).toMatch(/^https:\/\//) + expect(result.kycLink!.tosLink).toBeTruthy() + expect(result.kycLink!.tosLink).toMatch(/^https:\/\//) + }) + }) + + describe("KYC Webhook Processing", () => { + beforeAll(async () => { + // Initiate KYC first to create the Bridge customer + const kycResult = await initiateKyc( + user.accountId, + `webhook-${user.accountId.slice(-8)}-${Date.now()}@test.flashapp.me`, + ) + if (kycResult.errors?.length) { + throw new Error(`KYC initiation failed: ${kycResult.errors[0].message}`) + } + }) + + it("processes a KYC-approved webhook and marks account as approved", async () => { + const account = await getAccountById(user.accountId) + const webhookCustomerId = account.bridgeCustomerId + if (!webhookCustomerId) { + throw new Error("KYC initiation did not persist a Bridge customer ID") + } + + const response = await injectKycWebhook({ + event_id: `test-kyc-approved-${Date.now()}`, + event_object: { + customer_id: webhookCustomerId, + kyc_status: "approved", + }, + }) + + // The handler returns 200 for any valid webhook payload structure + expect(response.status).toBe(200) + }) + }) + ;(VIRTUAL_ACCOUNT_TESTS ? describe : describe.skip)("Virtual Account Creation", () => { + it("creates a virtual account after KYC approval", async () => { + const result = await createVirtualAccount(user.accountId) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.virtualAccount).toBeDefined() + expect(result.virtualAccount!.id).toBeTruthy() + // Virtual account should include bank details + expect(result.virtualAccount!.bankName).toBeDefined() + expect(result.virtualAccount!.routingNumber).toBeDefined() + expect(result.virtualAccount!.accountNumberLast4).toBeDefined() + }) + + it("virtual account is idempotent — calling twice returns same result", async () => { + const result1 = await createVirtualAccount(user.accountId) + const result2 = await createVirtualAccount(user.accountId) + + expect(result1.errors).toHaveLength(0) + expect(result2.errors).toHaveLength(0) + // Both calls should succeed (idempotent) — the second may return existing VA + expect(result1.virtualAccount?.id || result2.virtualAccount?.id).toBeTruthy() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts b/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts new file mode 100644 index 000000000..0e7a356f8 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts @@ -0,0 +1,59 @@ +/** + * Bridge Sandbox E2E — ETH-USDT LN Parity Smoke + * + * Verifies that Lightning payments still route correctly after USDT + * on-chain deposits are handled by Bridge. + * + * Key assertions (when infrastructure is available): + * - LN USD invoices can be created on a Bridge-capable account + * - LN USD invoice amounts convert correctly from USD → USDT parity + * (addressing CurrencyPrecisionAnalysis bug #282 / flash-mobile #555) + * - On-chain USDT deposits through Bridge don't affect LN routing + * + * ⚠️ GUARDED: Requires LN payment infrastructure + funded wallet in + * the sandbox environment. Only runs when LN_PARITY_TESTS=true. + * + * This spec is a placeholder for validation that should be run manually + * after the sandbox environment is seeded with: + * 1. A user with completed KYC + virtual account (Bridge mode) + * 2. A funded USDT wallet (via sandbox deposit) + * 3. Working LNURL-USD invoice creation + */ + +const ACCOUNT_ID = `acct_lnparity_test_${Date.now()}` +const LN_PARITY_TESTS = process.env.LN_PARITY_TESTS === "true" + +;(LN_PARITY_TESTS ? describe : describe.skip)("ETH-USDT LN Parity", () => { + describe("Lightning invoice creation (USD)", () => { + it("creates a LN USD invoice for a Bridge-capable account", async () => { + const { execQuery } = await import("./helpers") + + const source = ` + mutation LnUsdInvoiceCreate($input: LnUsdInvoiceCreateInput!) { + lnUsdInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest paymentHash } + } + } + ` + + const response = await execQuery(source, ACCOUNT_ID, { + input: { amount: 1000 }, // 1000 millisatoshis = $0.10 USD-ish + }) + + expect(response.lnUsdInvoiceCreate).toBeDefined() + + const errors = response.lnUsdInvoiceCreate?.errors + if (errors?.length) { + // Log known missing-infrastructure errors without failing + console.warn("LN invoice creation returned errors:", errors) + return + } + + const invoice = response.lnUsdInvoiceCreate.invoice + expect(invoice.paymentRequest).toBeTruthy() + expect(invoice.paymentRequest).toMatch(/^lnb\d+/) + expect(invoice.paymentHash).toBeTruthy() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/preflight.ts b/test/flash/bridge-sandbox-e2e/preflight.ts new file mode 100644 index 000000000..6810ed100 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/preflight.ts @@ -0,0 +1,64 @@ +/** + * Preflight Checks for Bridge Sandbox E2E Suite + * + * IMPORTANT: BridgeService.checkAccountLevel() is a private module-level + * function — it is NOT exported and cannot be imported by tests. + * + * This preflight uses source-code analysis to verify the guard condition. + * It checks the source file for `account.level < N` within the + * `checkAccountLevel` function and validates that N <= 1. + */ + +import fs from "fs" +import path from "path" + +/** + * Verify the service-level guard condition in BridgeService.checkAccountLevel(). + * + * Checks the source file for the account level comparison. + * The guard in src/services/bridge/index.ts should be `account.level < 1` + * so that Level 1 accounts can access Bridge operations. + * + * @returns true if the guard is correctly configured (level >= 1 allowed) + */ +export function preflightServiceLevelGuard(): boolean { + const servicePath = path.resolve(__dirname, "../../../src/services/bridge/index.ts") + + let content: string + try { + content = fs.readFileSync(servicePath, "utf-8") + } catch { + console.error("PREFLIGHT FAILED: Could not read BridgeService source at", servicePath) + return false + } + + // Extract the guard comparison value from checkAccountLevel. + // Matches: `account.level < N` anywhere inside the function body. + const funcMatch = content.match( + /const\s+checkAccountLevel[\s\S]*?account\.level\s*<(\s*\d+)/, + ) + + if (!funcMatch) { + console.error( + "PREFLIGHT FAILED: Could not detect service-level guard pattern in BridgeService.", + ) + return false + } + + const guardLevel = parseInt(funcMatch[1], 10) + + if (guardLevel <= 1) { + // Level 0 only is blocked — level 1+ is allowed. Correct configuration. + return true + } + + console.error( + `PREFLIGHT FAILED: BridgeService.checkAccountLevel() blocks level < ${guardLevel}, ` + + `but the e2e suite requires level >= 1 to pass through.\n` + + `Fix required in src/services/bridge/index.ts:\n` + + ` if (account.level < ${guardLevel}) -> if (account.level < 1)\n` + + `See test/flash/bridge-sandbox-e2e/README.md for setup details.`, + ) + + return false +} diff --git a/test/flash/unit/dev/setup-bridge-webhooks.spec.ts b/test/flash/unit/dev/setup-bridge-webhooks.spec.ts new file mode 100644 index 000000000..4668525a3 --- /dev/null +++ b/test/flash/unit/dev/setup-bridge-webhooks.spec.ts @@ -0,0 +1,126 @@ +import { + buildWebhookDefinitions, + extractNgrokHttpsUrl, + mergeDevOverrides, + reconcileBridgeWebhooks, +} from "../../../../dev/setup-bridge-webhooks" + +describe("setup-bridge-webhooks", () => { + it("builds one Bridge webhook definition per local route", () => { + const definitions = buildWebhookDefinitions("https://flash-dev.ngrok-free.app") + + expect(definitions).toEqual({ + kyc: { + url: "https://flash-dev.ngrok-free.app/kyc", + eventCategories: ["customer", "kyc_link"], + }, + deposit: { + url: "https://flash-dev.ngrok-free.app/deposit", + eventCategories: ["virtual_account.activity", "bridge_wallet.activity"], + }, + transfer: { + url: "https://flash-dev.ngrok-free.app/transfer", + eventCategories: ["transfer"], + }, + external_account: { + url: "https://flash-dev.ngrok-free.app/external-account", + eventCategories: ["external_account"], + }, + }) + }) + + it("extracts the HTTPS ngrok public URL", () => { + const url = extractNgrokHttpsUrl({ + tunnels: [ + { proto: "http", public_url: "http://example.ngrok-free.app" }, + { proto: "https", public_url: "https://example.ngrok-free.app" }, + ], + }) + + expect(url).toBe("https://example.ngrok-free.app") + }) + + it("merges Bridge secrets and webhook public keys into existing dev overrides", () => { + const merged = mergeDevOverrides( + { + ibex: { environment: "sandbox" }, + bridge: { webhook: { replaySecret: "keep-me" } }, + }, + { + apiKey: "sk-test-123", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + webhookBaseUrl: "https://example.ngrok-free.app", + publicKeys: { + kyc: "kyc-pem", + deposit: "deposit-pem", + transfer: "transfer-pem", + external_account: "external-account-pem", + }, + }, + ) + + expect(merged).toEqual({ + ibex: { environment: "sandbox" }, + bridge: { + apiKey: "sk-test-123", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + webhook: { + replaySecret: "keep-me", + uri: "https://example.ngrok-free.app", + publicKeys: { + kyc: "kyc-pem", + deposit: "deposit-pem", + transfer: "transfer-pem", + external_account: "external-account-pem", + }, + }, + }, + }) + }) + + it("deletes old webhooks, creates new disabled webhooks, then enables them", async () => { + const calls: string[] = [] + const definitions = buildWebhookDefinitions("https://fresh.ngrok-free.app") + const api = { + listWebhooks: jest.fn().mockResolvedValue([ + { id: "wep_old_1", status: "active", url: "https://old.example/kyc" }, + { id: "wep_old_2", status: "disabled", url: "https://old.example/deposit" }, + { id: "wep_deleted", status: "deleted", url: "https://old.example/deleted" }, + ]), + deleteWebhook: jest.fn(async (id: string) => { + calls.push(`delete:${id}`) + }), + createWebhook: jest.fn(async ({ key }: { key: string }) => { + calls.push(`create:${key}`) + return { + id: `wep_${key}`, + public_key: `${key}-public-key`, + } + }), + enableWebhook: jest.fn(async (id: string) => { + calls.push(`enable:${id}`) + }), + } + + const result = await reconcileBridgeWebhooks(api, definitions) + + expect(calls).toEqual([ + "delete:wep_old_1", + "delete:wep_old_2", + "create:kyc", + "enable:wep_kyc", + "create:deposit", + "enable:wep_deposit", + "create:transfer", + "enable:wep_transfer", + "create:external_account", + "enable:wep_external_account", + ]) + expect(result.publicKeys).toEqual({ + kyc: "kyc-public-key", + deposit: "deposit-public-key", + transfer: "transfer-public-key", + external_account: "external_account-public-key", + }) + }) +}) diff --git a/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts b/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts index 37b3df425..a27a5c5ff 100644 --- a/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts +++ b/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts @@ -62,4 +62,23 @@ describe("BridgeTransferRequest", () => { expect(request.toErpnext().source_systems_seen).toBe("ibex_crypto_receive") }) + + it("serializes datetimes in the format accepted by Frappe", () => { + const request = new BridgeTransferRequest({ + requestId: "tr_123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Settled, + amount: "2.500000", + currency: "USDT", + firstSeenAt: "2026-06-08T20:30:01.373Z", + lastSeenAt: "2026-06-08T20:31:02.540Z", + }) + + expect(request.toErpnext()).toEqual( + expect.objectContaining({ + first_seen_at: "2026-06-08 20:30:01", + last_seen_at: "2026-06-08 20:31:02", + }), + ) + }) })