diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3e3cd2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + pull_request: + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "yarn" + cache-dependency-path: yarn.lock + + - run: yarn install --frozen-lockfile + + - run: yarn test + + e2e-tests: + name: E2E tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "yarn" + cache-dependency-path: yarn.lock + + - run: yarn install --frozen-lockfile + + - name: Download Alby Hub binary + run: | + curl -sL https://github.com/getAlby/hub/releases/download/v1.21.4/albyhub-Server-Linux-x86_64.tar.bz2 \ + -o src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 + mkdir -p src/test/e2e/albyhub-Server-Linux-x86_64 + tar -xjf src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 -C src/test/e2e/albyhub-Server-Linux-x86_64 + + - name: Cache Bitcoin Core binary + uses: actions/cache@v4 + with: + path: bitcoin-28.1 + key: bitcoind-28.1-linux-x86_64 + + - name: Install and start bitcoind (regtest) + run: | + if [ ! -f bitcoin-28.1/bin/bitcoind ]; then + curl -sL https://bitcoincore.org/bin/bitcoin-core-28.1/bitcoin-28.1-x86_64-linux-gnu.tar.gz \ + -o bitcoin-28.1.tar.gz + tar -xzf bitcoin-28.1.tar.gz + rm bitcoin-28.1.tar.gz + fi + + mkdir -p /tmp/bitcoin-regtest + printf '%s\n' \ + 'regtest=1' \ + 'server=1' \ + 'daemon=1' \ + '' \ + '[regtest]' \ + 'rpcuser=polaruser' \ + 'rpcpassword=polarpass' \ + 'rpcbind=127.0.0.1' \ + 'rpcallowip=127.0.0.1' \ + 'fallbackfee=0.0002' \ + > /tmp/bitcoin-regtest/bitcoin.conf + + bitcoin-28.1/bin/bitcoind -datadir=/tmp/bitcoin-regtest + + for i in $(seq 1 30); do + if bitcoin-28.1/bin/bitcoin-cli \ + -regtest \ + -rpcconnect=127.0.0.1 \ + -rpcport=18443 \ + -rpcuser=polaruser \ + -rpcpassword=polarpass \ + getblockchaininfo > /dev/null 2>&1; then + echo "bitcoind ready after ${i}s" + break + fi + echo "Waiting for bitcoind... ($i/30)" + sleep 1 + done + + bitcoin-28.1/bin/bitcoin-cli \ + -regtest \ + -rpcconnect=127.0.0.1 \ + -rpcport=18443 \ + -rpcuser=polaruser \ + -rpcpassword=polarpass \ + createwallet "default" + + - run: yarn test:e2e diff --git a/.gitignore b/.gitignore index b7dab5e..d89feb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -build \ No newline at end of file +build +src/test/e2e/albyhub-* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cac0e10 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/README.md b/README.md index 1471d7a..cd2d25f 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,10 @@ LDK_ESPLORA_SERVER=https://mutinynet.com/api \ # Set up, start the node, and save the token npx @getalby/hub-cli setup --password YOUR_PASSWORD --backend LDK npx @getalby/hub-cli start --password YOUR_PASSWORD --save -npx @getalby/hub-cli info +npx @getalby/hub-cli get-info # Get initial test funds from https://faucet.mutinynet.com (requires human + GitHub login) -npx @getalby/hub-cli wallet-address +npx @getalby/hub-cli get-onchain-address ``` ## Commands @@ -118,13 +118,13 @@ npx @getalby/hub-cli unlock --password YOUR_PASSWORD --permission readonly --sav ```bash # Hub status, version, backend type -npx @getalby/hub-cli info +npx @getalby/hub-cli get-info # Lightning node readiness -npx @getalby/hub-cli node-status +npx @getalby/hub-cli get-node-status # Health check with active alarms -npx @getalby/hub-cli health +npx @getalby/hub-cli get-health ``` ### Balances & Wallet @@ -134,33 +134,51 @@ npx @getalby/hub-cli health npx @getalby/hub-cli balances # Get an on-chain deposit address -npx @getalby/hub-cli wallet-address +npx @getalby/hub-cli get-onchain-address ``` ### Channels & Peers ```bash # List Lightning channels -npx @getalby/hub-cli channels +npx @getalby/hub-cli list-channels # List LSP providers with fees and channel size limits -npx @getalby/hub-cli channel-suggestions +npx @getalby/hub-cli get-channel-suggestions # Get Alby LSP offer (requires linked Alby account) -npx @getalby/hub-cli channel-offer +npx @getalby/hub-cli get-channel-offer + +# Get your node's connection info (pubkey, address, port) +npx @getalby/hub-cli get-node-connection-info # List connected peers -npx @getalby/hub-cli peers +npx @getalby/hub-cli list-peers + +# Connect to a peer +npx @getalby/hub-cli connect-peer --pubkey --address --port + +# Open an outbound channel to a peer (requires on-chain funds) +npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 + +# Open a public channel +npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 --public + +# Close a channel (cooperative) +npx @getalby/hub-cli close-channel --peer-id --channel-id + +# Force-close a channel +npx @getalby/hub-cli close-channel --peer-id --channel-id --force ``` ### Opening a Channel via LSP ```bash -# 1. Pick an LSP from channel-suggestions -npx @getalby/hub-cli channel-suggestions +# 1. Pick an LSP from get-channel-suggestions +npx @getalby/hub-cli get-channel-suggestions # 2. Request a Lightning invoice from the LSP -npx @getalby/hub-cli lsp-order --amount 1000000 --lsp-type --lsp-identifier +npx @getalby/hub-cli request-lsp-order --amount 1000000 --lsp-type --lsp-identifier # 3. Pay the invoice (mainnet — if you have a funded wallet) npx @getalby/hub-cli pay-invoice @@ -168,6 +186,13 @@ npx @getalby/hub-cli pay-invoice # On Mutinynet, a human must pay the invoice via https://faucet.mutinynet.com ``` +### Node Management + +```bash +# Stop the Lightning node (hub HTTP server keeps running) +npx @getalby/hub-cli stop +``` + ### Payments ```bash @@ -185,10 +210,10 @@ npx @getalby/hub-cli make-invoice --amount 1000 --description "test" ```bash # List recent payments -npx @getalby/hub-cli transactions +npx @getalby/hub-cli list-transactions # With pagination -npx @getalby/hub-cli transactions --limit 50 --offset 0 +npx @getalby/hub-cli list-transactions --limit 50 --offset 0 # Look up a specific payment by hash npx @getalby/hub-cli lookup-transaction @@ -227,26 +252,36 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | Command | Description | Required Options | | --- | --- | --- | -| `info` | Hub status, version, backend type | — | -| `node-status` | Lightning node readiness | — | -| `health` | Health check and active alarms | — | +| `get-info` | Hub status, version, backend type | — | +| `get-node-status` | Lightning node readiness | — | +| `get-health` | Health check and active alarms | — | ### Balances & Wallet | Command | Description | Required Options | | --- | --- | --- | | `balances` | Lightning + on-chain balances | — | -| `wallet-address` | On-chain deposit address | — | +| `get-onchain-address` | On-chain deposit address | — | ### Channels & Peers | Command | Description | Required Options | | --- | --- | --- | -| `channels` | List Lightning channels | — | -| `channel-suggestions` | List LSP providers with fees | — | -| `channel-offer` | Get Alby LSP offer | — | -| `peers` | List connected peers | — | -| `lsp-order` | Request LSP channel invoice | `--amount`, `--lsp-type`, `--lsp-identifier` | +| `list-channels` | List Lightning channels | — | +| `get-channel-suggestions` | List LSP providers with fees | — | +| `get-channel-offer` | Get Alby LSP offer | — | +| `get-node-connection-info` | Get node pubkey, address, port | — | +| `list-peers` | List connected peers | — | +| `connect-peer` | Connect to a Lightning peer | `--pubkey`, `--address`, `--port` | +| `open-channel` | Open an outbound channel to a peer | `--pubkey`, `--amount-sats` | +| `close-channel` | Close a lightning channel (cooperative or force) | `--peer-id`, `--channel-id` | +| `request-lsp-order` | Request LSP channel invoice | `--amount`, `--lsp-type`, `--lsp-identifier` | + +### Node Management + +| Command | Description | Required Options | +| --- | --- | --- | +| `stop` | Stop the Lightning node (HTTP server keeps running) | — | ### Payments @@ -259,7 +294,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | Command | Description | Required Options | | --- | --- | --- | -| `transactions` | List payment history | — | +| `list-transactions` | List payment history | — | | `lookup-transaction` | Look up a payment by hash | `` (argument) | ### NWC Apps diff --git a/package.json b/package.json index 1e65b46..f422c67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build": "tsc && chmod 755 build/index.js", "start": "node build/index.js", "dev": "yarn build && node build/index.js", - "test": "yarn build && vitest run", + "test": "yarn build && vitest run --config vitest.config.ts", + "test:e2e": "yarn build && vitest run --config vitest.config.e2e.ts", "test:watch": "vitest" }, "keywords": [ diff --git a/src/client.ts b/src/client.ts index d0cd7da..28a5660 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,6 +31,14 @@ export class HubClient { return this.handleResponse(res); } + async delete(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "DELETE", + headers: this.headers(), + }); + return this.handleResponse(res); + } + private async handleResponse(res: Response): Promise { const text = await res.text(); if (!res.ok) { diff --git a/src/commands/channel-offer.ts b/src/commands/channel-offer.ts index a6b8548..445928d 100644 --- a/src/commands/channel-offer.ts +++ b/src/commands/channel-offer.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelOfferCommand(program: Command): void { program - .command("channel-offer") + .command("get-channel-offer") .description( "Get Alby LSP channel offer with recommended size and fee (requires linked Alby account)", ) diff --git a/src/commands/channel-suggestions.ts b/src/commands/channel-suggestions.ts index a41336e..25e3c4f 100644 --- a/src/commands/channel-suggestions.ts +++ b/src/commands/channel-suggestions.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelSuggestionsCommand(program: Command): void { program - .command("channel-suggestions") + .command("get-channel-suggestions") .description( "List available LSP providers with fees and channel size limits", ) diff --git a/src/commands/channels.ts b/src/commands/channels.ts index 96bd889..87d6d3b 100644 --- a/src/commands/channels.ts +++ b/src/commands/channels.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelsCommand(program: Command): void { program - .command("channels") + .command("list-channels") .description("List Lightning channels") .action(async () => { await handleError(async () => { diff --git a/src/commands/close-channel.ts b/src/commands/close-channel.ts new file mode 100644 index 0000000..b391179 --- /dev/null +++ b/src/commands/close-channel.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerCloseChannelCommand(program: Command): void { + program + .command("close-channel") + .description("Close a lightning channel") + .requiredOption("--peer-id ", "Peer's lightning public key") + .requiredOption("--channel-id ", "Channel ID") + .option( + "--force", + "Force close the channel (not recommended - only as last resort)", + false, + ) + .action( + async (opts: { peerId: string; channelId: string; force: boolean }) => { + await handleError(async () => { + const client = getClient(program); + const query = opts.force ? "?force=true" : ""; + const result = await client.delete>( + `/api/peers/${opts.peerId}/channels/${opts.channelId}${query}`, + ); + output(result); + }); + }, + ); +} diff --git a/src/commands/connect-peer.ts b/src/commands/connect-peer.ts new file mode 100644 index 0000000..cd21d5e --- /dev/null +++ b/src/commands/connect-peer.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerConnectPeerCommand(program: Command): void { + program + .command("connect-peer") + .description("Connect to a Lightning peer") + .requiredOption("--pubkey ", "Peer's Lightning public key") + .requiredOption("--address
", "Peer's IP address or hostname") + .requiredOption("--port ", "Peer's port number", parseInt) + .action(async (opts: { pubkey: string; address: string; port: number }) => { + await handleError(async () => { + const client = getClient(program); + await client.post("/api/peers", { + pubkey: opts.pubkey, + address: opts.address, + port: opts.port, + }); + output({ success: true }); + }); + }); +} diff --git a/src/commands/get-node-connection-info.ts b/src/commands/get-node-connection-info.ts new file mode 100644 index 0000000..8120259 --- /dev/null +++ b/src/commands/get-node-connection-info.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { NodeConnectionInfo } from "../types.js"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerGetNodeConnectionInfoCommand(program: Command): void { + program + .command("get-node-connection-info") + .description("Get the Lightning node's connection info (pubkey, address, port)") + .action(async () => { + await handleError(async () => { + const client = getClient(program); + const result = await client.get("/api/node/connection-info"); + output(result); + }); + }); +} diff --git a/src/commands/health.ts b/src/commands/health.ts index 9d46621..b7f23c1 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerHealthCommand(program: Command): void { program - .command("health") + .command("get-health") .description( "Check hub health and active alarms (alby_service, nostr_relay_offline, node_not_ready, channels_offline, vss_no_subscription)", ) diff --git a/src/commands/info.ts b/src/commands/info.ts index c63548d..811dfc3 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerInfoCommand(program: Command): void { program - .command("info") + .command("get-info") .description("Get hub status, version, and configuration") .action(async () => { await handleError(async () => { diff --git a/src/commands/lsp-order.ts b/src/commands/lsp-order.ts index b76c475..634ec57 100644 --- a/src/commands/lsp-order.ts +++ b/src/commands/lsp-order.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerLspOrderCommand(program: Command): void { program - .command("lsp-order") + .command("request-lsp-order") .description( "Request a Lightning invoice from an LSP to open a channel. Pay the returned invoice to open the channel.", ) @@ -31,12 +31,15 @@ export function registerLspOrderCommand(program: Command): void { }) => { await handleError(async () => { const client = getClient(program); - const result = await client.post("/api/lsp-orders", { - amount: opts.amount, - lspType: opts.lspType, - lspIdentifier: opts.lspIdentifier, - public: opts.public, - }); + const result = await client.post( + "/api/lsp-orders", + { + amount: opts.amount, + lspType: opts.lspType, + lspIdentifier: opts.lspIdentifier, + public: opts.public, + }, + ); output(result); }); }, diff --git a/src/commands/make-invoice.ts b/src/commands/make-invoice.ts index e520267..76c35a5 100644 --- a/src/commands/make-invoice.ts +++ b/src/commands/make-invoice.ts @@ -12,7 +12,7 @@ export function registerMakeInvoiceCommand(program: Command): void { await handleError(async () => { const client = getClient(program); const result = await client.post("/api/invoices", { - amount: opts.amount, + amount: opts.amount * 1000, description: opts.description, }); output(result); diff --git a/src/commands/node-status.ts b/src/commands/node-status.ts index c7fe5ff..debbaf1 100644 --- a/src/commands/node-status.ts +++ b/src/commands/node-status.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerNodeStatusCommand(program: Command): void { program - .command("node-status") + .command("get-node-status") .description("Get Lightning node readiness status") .action(async () => { await handleError(async () => { diff --git a/src/commands/open-channel.ts b/src/commands/open-channel.ts new file mode 100644 index 0000000..5ed9f63 --- /dev/null +++ b/src/commands/open-channel.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerOpenChannelCommand(program: Command): void { + program + .command("open-channel") + .description("Open an outbound lightning channel to a peer") + .requiredOption("--pubkey ", "Peer's lightning public key") + .requiredOption( + "--amount-sats ", + "Channel size in satoshis", + parseInt, + ) + .option("--public", "Open a public channel (default: private)", false) + .action( + async (opts: { pubkey: string; amountSats: number; public: boolean }) => { + await handleError(async () => { + const client = getClient(program); + const result = await client.post<{ fundingTxId: string }>( + "/api/channels", + { + pubkey: opts.pubkey, + amountSats: opts.amountSats, + public: opts.public, + }, + ); + output(result); + }); + }, + ); +} diff --git a/src/commands/peers.ts b/src/commands/peers.ts index 6c56956..46cc0ad 100644 --- a/src/commands/peers.ts +++ b/src/commands/peers.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerPeersCommand(program: Command): void { program - .command("peers") + .command("list-peers") .description("List connected Lightning peers") .action(async () => { await handleError(async () => { diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 0000000..3c087f9 --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; +import { getClient, handleError } from "../utils.js"; + +export function registerStopCommand(program: Command): void { + program + .command("stop") + .description("Stop the Lightning node (the hub HTTP server keeps running)") + .action(async () => { + await handleError(async () => { + const client = getClient(program); + await client.post("/api/stop"); + }); + }); +} diff --git a/src/commands/transactions.ts b/src/commands/transactions.ts index f069814..99ca5bf 100644 --- a/src/commands/transactions.ts +++ b/src/commands/transactions.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerTransactionsCommand(program: Command): void { program - .command("transactions") + .command("list-transactions") .description("List payment history") .option("--limit ", "Maximum number of transactions to return", "20") .option("--offset ", "Pagination offset", "0") diff --git a/src/commands/wallet-address.ts b/src/commands/wallet-address.ts index ae5beb9..831ba1f 100644 --- a/src/commands/wallet-address.ts +++ b/src/commands/wallet-address.ts @@ -3,7 +3,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerWalletAddressCommand(program: Command): void { program - .command("wallet-address") + .command("get-onchain-address") .description("Get an on-chain Bitcoin deposit address") .action(async () => { await handleError(async () => { diff --git a/src/index.ts b/src/index.ts index 7cf34c3..b249452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { registerSetupCommand } from "./commands/setup.js"; import { registerUnlockCommand } from "./commands/unlock.js"; import { registerStartCommand } from "./commands/start.js"; +import { registerStopCommand } from "./commands/stop.js"; import { registerInfoCommand } from "./commands/info.js"; import { registerBalancesCommand } from "./commands/balances.js"; import { registerChannelsCommand } from "./commands/channels.js"; @@ -20,6 +21,10 @@ import { registerPeersCommand } from "./commands/peers.js"; import { registerNodeStatusCommand } from "./commands/node-status.js"; import { registerHealthCommand } from "./commands/health.js"; import { registerWalletAddressCommand } from "./commands/wallet-address.js"; +import { registerGetNodeConnectionInfoCommand } from "./commands/get-node-connection-info.js"; +import { registerConnectPeerCommand } from "./commands/connect-peer.js"; +import { registerOpenChannelCommand } from "./commands/open-channel.js"; +import { registerCloseChannelCommand } from "./commands/close-channel.js"; const program = new Command(); @@ -38,6 +43,7 @@ program registerSetupCommand(program); registerUnlockCommand(program); registerStartCommand(program); +registerStopCommand(program); registerInfoCommand(program); registerBalancesCommand(program); registerChannelsCommand(program); @@ -54,5 +60,9 @@ registerPeersCommand(program); registerNodeStatusCommand(program); registerHealthCommand(program); registerWalletAddressCommand(program); +registerGetNodeConnectionInfoCommand(program); +registerConnectPeerCommand(program); +registerOpenChannelCommand(program); +registerCloseChannelCommand(program); program.parse(); diff --git a/src/test/e2e/README.md b/src/test/e2e/README.md new file mode 100644 index 0000000..7bde896 --- /dev/null +++ b/src/test/e2e/README.md @@ -0,0 +1,44 @@ +# E2E Tests + +End-to-end tests that spawn a real Alby Hub binary and exercise the CLI against it. + +## Prerequisites + +### Alby Hub binary + +Download the Linux Ubuntu 24.04 desktop build from the [Alby Hub GitHub releases](https://github.com/getAlby/hub/releases), extract it, and place it at: + +``` +src/test/e2e/albyhub-Server-Linux-x86_64/ +``` + +The directory must contain at minimum: +- `bin/albyhub` — the executable +- `lib/libldk_node.so` — the LDK node shared library + +### Polar (optional — for future `start`/`unlock` tests) + +For tests that require Bitcoin connectivity (not needed for `setup`): + +1. Download [Polar](https://lightningpolar.com/) +2. Create a network with a Bitcoin Core node +3. Start the network +4. Set `POLAR_ESPLORA_URL` env var to your Polar Esplora URL (e.g. `http://127.0.0.1:3000`) + +## Running + +```bash +yarn test:e2e +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `POLAR_ESPLORA_URL` | `http://127.0.0.1:3000` | Esplora URL from Polar (only needed for `start`/`unlock` tests) | + +## Notes + +- The hub is started on port `18080` to avoid conflicts with a locally running hub +- A temporary `WORK_DIR` is created per test run and cleaned up automatically +- The `setup` test does not require Bitcoin/Polar connectivity — it only calls `POST /api/setup` diff --git a/src/test/e2e/channel-lifecycle.e2e.test.ts b/src/test/e2e/channel-lifecycle.e2e.test.ts new file mode 100644 index 0000000..41498eb --- /dev/null +++ b/src/test/e2e/channel-lifecycle.e2e.test.ts @@ -0,0 +1,613 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + killHub, + waitForInfo, + bitcoinRpc, + waitForBalances, + waitForChannels, +} from "./helpers"; +import type { + ListTransactionsResponse, + NodeConnectionInfo, +} from "../../types.js"; + +const HUB_A_PORT = 18083; +const HUB_B_PORT = 18084; +const HUB_A_LDK_PORT = 19736; +const HUB_B_LDK_PORT = 19737; +const HUB_A_URL = `http://localhost:${HUB_A_PORT}`; +const HUB_B_URL = `http://localhost:${HUB_B_PORT}`; + +let hubAProcess: ChildProcess; +let hubBProcess: ChildProcess; +let tokenA: string; +let tokenB: string; +let hubBConnInfo: NodeConnectionInfo; +let miningAddr: string; + +beforeAll(async () => { + ({ hubProcess: hubAProcess } = await spawnHub( + HUB_A_PORT, + "hub-cli-e2e-hub-a-", + HUB_A_LDK_PORT, + )); + ({ hubProcess: hubBProcess } = await spawnHub( + HUB_B_PORT, + "hub-cli-e2e-hub-b-", + HUB_B_LDK_PORT, + )); + + // Setup and start Hub A + const setupA = runCommand([ + "--url", + HUB_A_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setupA.status !== 0) + throw new Error(`Hub A setup failed: ${setupA.stderr}`); + + const startA = runCommand([ + "--url", + HUB_A_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (startA.status !== 0) + throw new Error(`Hub A start failed: ${startA.stderr}`); + tokenA = JSON.parse(startA.stdout).token; + + // Setup and start Hub B + const setupB = runCommand([ + "--url", + HUB_B_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setupB.status !== 0) + throw new Error(`Hub B setup failed: ${setupB.stderr}`); + + const startB = runCommand([ + "--url", + HUB_B_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (startB.status !== 0) + throw new Error(`Hub B start failed: ${startB.stderr}`); + tokenB = JSON.parse(startB.stdout).token; + + // Wait for both hubs to be running + await waitForInfo(HUB_A_URL, (info) => info.running); + await waitForInfo(HUB_B_URL, (info) => info.running); + + // Get Hub B's connection info + const connInfoResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "get-node-connection-info", + ]); + if (connInfoResult.status !== 0) { + throw new Error( + `get-node-connection-info failed: ${connInfoResult.stderr}`, + ); + } + hubBConnInfo = JSON.parse(connInfoResult.stdout) as NodeConnectionInfo; + + // Mine 101 blocks so coinbase rewards are mature and spendable + miningAddr = (await bitcoinRpc("getnewaddress")) as string; + await bitcoinRpc("generatetoaddress", [101, miningAddr]); +}, 120_000); + +afterAll(async () => { + if (hubAProcess) await killHub(hubAProcess); + if (hubBProcess) await killHub(hubBProcess); +}); + +test("deposits on-chain funds to hub A", { timeout: 120_000 }, async () => { + const addrResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-onchain-address", + ]); + expect(addrResult.status).toBe(0); + const { address } = JSON.parse(addrResult.stdout); + expect(typeof address).toBe("string"); + expect(address.length).toBeGreaterThan(0); + + await bitcoinRpc("sendtoaddress", [address, 0.1]); + + miningAddr = (await bitcoinRpc("getnewaddress")) as string; + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + const balances = await waitForBalances( + HUB_A_URL, + tokenA, + (b) => b.onchain.spendable > 0, + 120_000, + ); + expect(balances.onchain.spendable).toBeGreaterThan(0); +}); + +test("connects hub A as peer to hub B", { timeout: 60_000 }, async () => { + const connectResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "connect-peer", + "--pubkey", + hubBConnInfo.pubkey, + "--address", + "127.0.0.1", + "--port", + String(hubBConnInfo.port), + ]); + expect(connectResult.status).toBe(0); + const connectOutput = JSON.parse(connectResult.stdout); + expect(connectOutput.success).toBe(true); + + const peersResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-peers", + ]); + expect(peersResult.status).toBe(0); + const peers = JSON.parse(peersResult.stdout); + const hubBPeer = peers.find( + (p: { nodeId: string }) => p.nodeId === hubBConnInfo.pubkey, + ); + expect(hubBPeer).toBeDefined(); + + // Hub A: verify Hub B peer is connected + const peersAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-peers", + ]); + expect(peersAResult.status).toBe(0); + const peersA = JSON.parse(peersAResult.stdout) as { + nodeId: string; + isConnected: boolean; + }[]; + const hubBPeerA = peersA.find((p) => p.nodeId === hubBConnInfo.pubkey); + expect(hubBPeerA).toBeDefined(); + expect(hubBPeerA!.isConnected).toBe(true); + + // Hub B: verify at least one connected peer exists + const peersBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-peers", + ]); + expect(peersBResult.status).toBe(0); + const peersB = JSON.parse(peersBResult.stdout) as { isConnected: boolean }[]; + expect(peersB.some((p) => p.isConnected)).toBe(true); + + // Hub A: get-node-status pre-channel baseline + const nodeStatusResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-node-status", + ]); + expect(nodeStatusResult.status).toBe(0); + const nodeStatus = JSON.parse(nodeStatusResult.stdout) as { + isReady: boolean; + }; + expect(nodeStatus.isReady).toBe(true); + + // Hub A: get-health pre-channel baseline + const healthResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-health", + ]); + expect(healthResult.status).toBe(0); + const healthOutput = JSON.parse(healthResult.stdout); + expect(healthOutput).toEqual({}); // no alarms +}); + +test("opens channel from hub A to hub B", { timeout: 120_000 }, async () => { + const openResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "open-channel", + "--pubkey", + hubBConnInfo.pubkey, + "--amount-sats", + "100000", + ]); + expect(openResult.status).toBe(0); + const openOutput = JSON.parse(openResult.stdout); + expect(typeof openOutput.fundingTxId).toBe("string"); + expect(openOutput.fundingTxId.length).toBeGreaterThan(0); + + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + const hubAChannels = await waitForChannels( + HUB_A_URL, + tokenA, + (chs) => + chs.some((c) => c.remotePubkey === hubBConnInfo.pubkey && c.active), + 120_000, + ); + const hubAActiveChannel = hubAChannels.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.active, + ); + expect(hubAActiveChannel).toBeDefined(); + + const hubBChannels = await waitForChannels( + HUB_B_URL, + tokenB, + (chs) => chs.some((c) => c.active), + 120_000, + ); + const hubBActiveChannel = hubBChannels.find((c) => c.active); + expect(hubBActiveChannel).toBeDefined(); + + // Hub A: list-channels via CLI and verify active channel to Hub B + const listChAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-channels", + ]); + expect(listChAResult.status).toBe(0); + const listChA = JSON.parse(listChAResult.stdout) as { + remotePubkey: string; + active: boolean; + }[]; + expect(Array.isArray(listChA)).toBe(true); + const activeChA = listChA.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.active, + ); + expect(activeChA).toBeDefined(); + + // Hub B: list-channels via CLI and verify at least one active channel + const listChBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-channels", + ]); + expect(listChBResult.status).toBe(0); + const listChB = JSON.parse(listChBResult.stdout) as { active: boolean }[]; + expect(Array.isArray(listChB)).toBe(true); + expect(listChB.some((c) => c.active)).toBe(true); + + // Hub A: get-health post-channel + const healthPostResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-health", + ]); + expect(healthPostResult.status).toBe(0); + const healthPostOutput = JSON.parse(healthPostResult.stdout); + expect(healthPostOutput).toEqual({}); // no alarms +}); + +test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { + const AMOUNT_SATS = 20_000; + + // Hub B creates an invoice + const invoiceResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "make-invoice", + "--amount", + String(AMOUNT_SATS), + "--description", + "e2e test payment", + ]); + expect(invoiceResult.status).toBe(0); + const invoiceData = JSON.parse(invoiceResult.stdout) as { invoice: string }; + expect(typeof invoiceData.invoice).toBe("string"); + + // Record Hub A's balance before payment + const balancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(balancesBeforeResult.status).toBe(0); + const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { + lightning: { totalSpendable: number }; + }; + const hubASpendableBefore = balancesBeforeData.lightning.totalSpendable; + expect(hubASpendableBefore).toBeGreaterThan(0); + + // Hub A pays the invoice + const payResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "pay-invoice", + invoiceData.invoice, + ]); + expect(payResult.status).toBe(0); + + // Verify Hub A's balance decreased + const hubABalancesAfterResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesAfterResult.status).toBe(0); + const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubABalancesAfterData.lightning.totalSpendable).toBeLessThan( + hubASpendableBefore, + ); + + // Wait for Hub B's channel localBalance to reflect the received payment. + // /api/channels is not filtered by IsUsable (unlike /api/balances), and localBalance is in msats. + await waitForChannels( + HUB_B_URL, + tokenB, + (chs) => chs.some((c) => c.localBalance > 0), + 60_000, + ); + + // Verify via CLI that Hub B's balance shows received sats + const hubBBalancesAfterResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubBBalancesAfterData.lightning.totalSpendable).toBeGreaterThan(0); +}); + +test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { + const AMOUNT_SATS = 5_000; + + // Hub A creates an invoice + const invoiceResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "make-invoice", + "--amount", + String(AMOUNT_SATS), + "--description", + "e2e test reverse payment", + ]); + expect(invoiceResult.status).toBe(0); + const invoiceData = JSON.parse(invoiceResult.stdout) as { invoice: string }; + expect(typeof invoiceData.invoice).toBe("string"); + + // Record Hub A's balance before payment + const hubABalancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesBeforeResult.status).toBe(0); + const hubABalancesBeforeData = JSON.parse( + hubABalancesBeforeResult.stdout, + ) as { + lightning: { totalSpendable: number }; + }; + + // Record Hub B's balance before payment + const hubBBalancesBeforeResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + expect(hubBBalancesBeforeResult.status).toBe(0); + const hubBBalancesBeforeData = JSON.parse( + hubBBalancesBeforeResult.stdout, + ) as { + lightning: { totalSpendable: number }; + }; + const hubBSpendableBefore = hubBBalancesBeforeData.lightning.totalSpendable; + expect(hubBSpendableBefore).toBeGreaterThan(0); + + // Hub B pays the invoice + const payResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "pay-invoice", + invoiceData.invoice, + ]); + expect(payResult.status).toBe(0); + + // Verify Hub B's balance decreased + const hubBBalancesAfterResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + expect(hubBBalancesAfterResult.status).toBe(0); + const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubBBalancesAfterData.lightning.totalSpendable).toBeLessThan( + hubBSpendableBefore, + ); + + // Wait for Hub A's channel localBalance to reflect receipt + await waitForChannels( + HUB_A_URL, + tokenA, + (chs) => + chs.some( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.localBalance > 0, + ), + 60_000, + ); + + // Verify Hub A's lightning balance increased + const hubABalancesAfterResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesAfterResult.status).toBe(0); + const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubABalancesAfterData.lightning.totalSpendable).toBeGreaterThan( + hubABalancesBeforeData.lightning.totalSpendable, + ); + + // Hub A: list-transactions — should have at least one incoming settled transaction + const txAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-transactions", + ]); + expect(txAResult.status).toBe(0); + const txAData = JSON.parse(txAResult.stdout) as ListTransactionsResponse; + expect(txAData.totalCount).toBeGreaterThan(0); + expect(txAData.transactions.length).toBeGreaterThan(0); + const incomingSettledA = txAData.transactions.find( + (t) => t.type === "incoming" && t.state === "settled", + ); + expect(incomingSettledA).toBeDefined(); + + // Hub B: list-transactions — should have at least one outgoing settled transaction + const txBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-transactions", + ]); + expect(txBResult.status).toBe(0); + const txBData = JSON.parse(txBResult.stdout) as ListTransactionsResponse; + expect(txBData.totalCount).toBeGreaterThan(0); + const outgoingSettledB = txBData.transactions.find( + (t) => t.type === "outgoing" && t.state === "settled", + ); + expect(outgoingSettledB).toBeDefined(); +}); + +test( + "closes channel and returns funds to hub A on-chain balance", + { timeout: 180_000 }, + async () => { + // Get Hub A's current channels to find the channel with Hub B + const channelsResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-channels", + ]); + expect(channelsResult.status).toBe(0); + const channels = JSON.parse(channelsResult.stdout) as { + id: string; + remotePubkey: string; + }[]; + const channel = channels.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey, + ); + expect(channel).toBeDefined(); + const channelId = channel!.id; + + // Get Hub A's current on-chain balance + const balancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(balancesBeforeResult.status).toBe(0); + const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { + onchain: { spendable: number }; + }; + const onchainBefore = balancesBeforeData.onchain.spendable; + + // Close the channel + const closeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "close-channel", + "--peer-id", + hubBConnInfo.pubkey, + "--channel-id", + channelId, + ]); + expect(closeResult.status).toBe(0); + + // Mine blocks to confirm cooperative close + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + // Wait for channel to disappear or become inactive + await waitForChannels(HUB_A_URL, tokenA, (chs) => !chs.length, 120_000); + + // Mine more blocks to ensure on-chain funds are confirmed + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + // Wait for Hub A's on-chain spendable balance to exceed its pre-close value + const balancesAfter = await waitForBalances( + HUB_A_URL, + tokenA, + (b) => b.onchain.spendable > onchainBefore, + 120_000, + ); + expect(balancesAfter.onchain.spendable).toBeGreaterThan(onchainBefore); + }, +); diff --git a/src/test/e2e/helpers.ts b/src/test/e2e/helpers.ts new file mode 100644 index 0000000..c6164c4 --- /dev/null +++ b/src/test/e2e/helpers.ts @@ -0,0 +1,182 @@ +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ChildProcess } from "node:child_process"; +import type { BalancesResponse, Channel, InfoResponse } from "../../types.js"; + +export const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); +export const HUB_BINARY = join( + E2E_DIR, + "albyhub-Server-Linux-x86_64/bin/albyhub", +); +export const TEST_PASSWORD = "test-password-e2e"; +export const NETWORK = "regtest"; + +// Bitcoind RPC config (Polar defaults) +export const LDK_BITCOIND_RPC_HOST = "127.0.0.1"; +export const LDK_BITCOIND_RPC_PORT = "18443"; +export const LDK_BITCOIND_RPC_USER = "polaruser"; +export const LDK_BITCOIND_RPC_PASSWORD = "polarpass"; +export const DEFAULT_LDK_PORT = 19735; + +export function runCommand(args: string[]) { + return spawnSync("node", ["build/index.js", ...args], { + encoding: "utf-8", + cwd: process.cwd(), + }); +} + +export async function spawnHub( + port: number, + tmpPrefix: string, + ldkPort = DEFAULT_LDK_PORT, +): Promise<{ hubProcess: ChildProcess; workDir: string }> { + const workDir = mkdtempSync(join(tmpdir(), tmpPrefix)); + + console.log("Hub WORK_DIR:", workDir); + + const hubProcess = spawn(HUB_BINARY, [], { + env: { + ...process.env, + WORK_DIR: workDir, + PORT: String(port), + NETWORK, + LDK_BITCOIND_RPC_HOST, + LDK_BITCOIND_RPC_PORT, + LDK_BITCOIND_RPC_USER, + LDK_BITCOIND_RPC_PASSWORD, + LDK_LISTENING_ADDRESSES: `0.0.0.0:${ldkPort}`, + LDK_ANNOUNCEMENT_ADDRESSES: `127.0.0.1:${ldkPort}`, + }, + stdio: "pipe", + }); + + hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); + hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); + + await waitForHub(`http://localhost:${port}`); + + return { hubProcess, workDir }; +} + +export async function waitForHub( + url: string, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Hub did not become ready within ${timeoutMs}ms`); +} + +export async function killHub(hubProcess: ChildProcess): Promise { + return new Promise((resolve) => { + hubProcess.once("exit", resolve); + hubProcess.kill(); + }); +} + +export async function waitForInfo( + url: string, + condition: (info: InfoResponse) => boolean, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) { + const info = (await res.json()) as InfoResponse; + if (condition(info)) return info; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); +} + +export async function bitcoinRpc( + method: string, + params: unknown[] = [], +): Promise { + const auth = Buffer.from( + `${LDK_BITCOIND_RPC_USER}:${LDK_BITCOIND_RPC_PASSWORD}`, + ).toString("base64"); + const res = await fetch( + `http://${LDK_BITCOIND_RPC_HOST}:${LDK_BITCOIND_RPC_PORT}/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ jsonrpc: "1.0", id: 1, method, params }), + }, + ); + const json = (await res.json()) as { + result: unknown; + error: { message: string } | null; + }; + if (json.error) + throw new Error(`Bitcoin RPC ${method} failed: ${json.error.message}`); + return json.result; +} + +export async function waitForBalances( + url: string, + token: string, + condition: (balances: BalancesResponse) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/balances`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const balances = (await res.json()) as BalancesResponse; + if (condition(balances)) return balances; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Balance condition not met within ${timeoutMs}ms`); +} + +export async function waitForChannels( + url: string, + token: string, + condition: (channels: Channel[]) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/channels`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const channels = (await res.json()) as Channel[]; + if (condition(channels)) return channels; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Channel condition not met within ${timeoutMs}ms`); +} diff --git a/src/test/e2e/setup.e2e.test.ts b/src/test/e2e/setup.e2e.test.ts new file mode 100644 index 0000000..0e4fb77 --- /dev/null +++ b/src/test/e2e/setup.e2e.test.ts @@ -0,0 +1,128 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { TEST_PASSWORD, spawnHub, runCommand, killHub } from "./helpers"; + +const HUB_PORT = 18080; // non-default port to avoid clashing with a real hub +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-")); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test("cannot setup with an empty password", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + "", + "--backend", + "LDK", + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual( + "Failed to setup node: no unlock password provided", + ); +}); + +test("setup initializes the hub without specifying a backend", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); +}); + +test("setup initializes the hub with backend specified", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); +}); + +test("can setup multiple times if node never started", () => { + let result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); +}); + +test("cannot setup if node has ever been started", async () => { + let result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + + result = runCommand(["--url", HUB_URL, "start", "--password", TEST_PASSWORD]); + expect(result.status).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Failed to setup node: setup already completed"); +}); diff --git a/src/test/e2e/start.e2e.test.ts b/src/test/e2e/start.e2e.test.ts new file mode 100644 index 0000000..8b03d21 --- /dev/null +++ b/src/test/e2e/start.e2e.test.ts @@ -0,0 +1,102 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { TEST_PASSWORD, spawnHub, runCommand, waitForInfo, killHub } from "./helpers"; + +const HUB_PORT = 18081; // different port from setup.e2e.test.ts (18080) +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-start-")); + + // setup is a prerequisite for all start tests + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test("cannot start with wrong unlock password", () => { + const result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "wrong-password", + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); +}); + +test("start returns a JWT token", { timeout: 60_000 }, async () => { + const result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(typeof output.token).toBe("string"); + expect(output.token.length).toBeGreaterThan(0); + const info = await waitForInfo(HUB_URL, (i) => i.running); + expect(info.running).toBe(true); +}); + +test("rate limit on start", { timeout: 60_000 }, () => { + let result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "incorrect_password", + ]); + expect(result.status).toBe(1); + let output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); + result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "incorrect_password_2", + ]); + expect(result.status).toBe(1); + output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("rate limit exceeded"); +}); + +test("cannot start if already started", { timeout: 60_000 }, async () => { + let result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + await waitForInfo(HUB_URL, (i) => i.running); + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + result = runCommand(["--url", HUB_URL, "start", "--password", TEST_PASSWORD]); + expect(result.status).toBe(0); + const info = await waitForInfo(HUB_URL, (i) => i.startupError.length > 0); + expect(info.startupError).toEqual("app already started"); +}); diff --git a/src/test/e2e/stop.e2e.test.ts b/src/test/e2e/stop.e2e.test.ts new file mode 100644 index 0000000..25e34c7 --- /dev/null +++ b/src/test/e2e/stop.e2e.test.ts @@ -0,0 +1,146 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18085; +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-stop-")); + + // setup is a prerequisite for all stop tests + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test( + "stop stops the LN node (HTTP server stays up)", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(stop.status).toBe(0); + + const info = await waitForInfo(HUB_URL, (i) => !i.running); + expect(info.running).toBe(false); + }, +); + +test( + "can restart the LN node after stopping it", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(stop.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => !i.running); + + const restart = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(restart.status).toBe(0); + + const info = await waitForInfo(HUB_URL, (i) => i.running); + expect(info.running).toBe(true); + }, +); + +test("stop fails without a token", { timeout: 60_000 }, async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "stop"]); + expect(stop.status).toBe(1); + const output = JSON.parse(stop.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("missing or malformed jwt"); +}); + +test( + "cannot stop if the LN node is not started", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const firstStop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(firstStop.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => !i.running); + + const secondStop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(secondStop.status).toBe(1); + const output = JSON.parse(secondStop.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("LNClient not started"); + }, +); diff --git a/src/test/e2e/unlock.e2e.test.ts b/src/test/e2e/unlock.e2e.test.ts new file mode 100644 index 0000000..f90631b --- /dev/null +++ b/src/test/e2e/unlock.e2e.test.ts @@ -0,0 +1,126 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18082; // different port from setup.e2e.test.ts (18080) and start.e2e.test.ts (18081) +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-unlock-")); + // No setup or start — test 1 needs a fresh hub +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test("unlock fails if node is not started", () => { + const result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + // TODO: hub should return a better error message here + expect(output.error).toEqual("Failed to save session: config not unlocked"); +}); + +test("unlock works if node is started", { timeout: 60_000 }, async () => { + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); + + const result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(typeof output.token).toBe("string"); + expect(output.token.length).toBeGreaterThan(0); +}); + +test("rate limit on unlock", { timeout: 60_000 }, async () => { + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); + + // avoid rate limit + await new Promise((resolve) => setTimeout(resolve, 3000)); + + let result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + "incorrect_password", + ]); + expect(result.status).toBe(1); + let output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); + + result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + "incorrect_password_2", + ]); + expect(result.status).toBe(1); + output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("rate limit exceeded"); +}); diff --git a/src/types.ts b/src/types.ts index e0176f2..eaf592f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,12 @@ export interface NodeStatus { internalNodeStatus: unknown; } +export interface NodeConnectionInfo { + pubkey: string; + address: string; + port: number; +} + export interface HealthAlarm { kind: string; rawDetails?: unknown; diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts new file mode 100644 index 0000000..4f3e09b --- /dev/null +++ b/vitest.config.e2e.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + include: ["src/test/e2e/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + fileParallelism: false, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0d394d5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + exclude: ["node_modules", "src/test/e2e/**"], + }, +});