From 74059b92564fac6ed4cb87a403b337d5a7c421fb Mon Sep 17 00:00:00 2001 From: fusion44 Date: Mon, 2 Feb 2026 12:25:30 +0100 Subject: [PATCH] tests: add better integration tests --- scripts/test_env.nu | 9 +- scripts/verify_api.py | 379 ++++++++++++++++++ .../system_impl/test_native_python.py | 30 -- .../system_impl/test_raspiblitz.py | 9 - tests/repositories/test_system.py | 36 -- tests/routers/test_lightning.py | 3 +- tests/routers/test_system.py | 2 +- tests/routers/utils.py | 7 +- 8 files changed, 390 insertions(+), 85 deletions(-) create mode 100644 scripts/verify_api.py delete mode 100644 tests/repositories/system_impl/test_native_python.py delete mode 100644 tests/repositories/system_impl/test_raspiblitz.py delete mode 100644 tests/repositories/test_system.py diff --git a/scripts/test_env.nu b/scripts/test_env.nu index 4ff3683..bdc56a2 100644 --- a/scripts/test_env.nu +++ b/scripts/test_env.nu @@ -206,12 +206,13 @@ def regopenchannel [ ] { match $to { "cln" => { - let k = (clnpubkey) - lndcli openchannel $k --local_amt $local_amount --push_amt $push_amount + let k = regpubkey cln + lndcli connect $"($k)@127.0.0.1:9736" + lndcli openchannel --node_key $k --local_amt $local_amount --push_amt $push_amount } "lnd" => { - let k = (lndpubkey) - clncli fundchannel $k $local_amount + let k = regpubkey lnd + clncli fundchannel $"($k)@127.0.0.0:9735" $local_amount } _ => { print "to must either be \"lnd\" or \"cln\"." } } diff --git a/scripts/verify_api.py b/scripts/verify_api.py new file mode 100644 index 0000000..69a64b9 --- /dev/null +++ b/scripts/verify_api.py @@ -0,0 +1,379 @@ +import requests +import sys +import json +import time +import subprocess +import uuid +import os + +BASE_URL = "http://localhost:8000" +PASSWORD = "nixblitz" + +session = requests.Session() + +def print_pass(message): + print(f"✅ PASS: {message}") + +def print_fail(message, details=""): + print(f"❌ FAIL: {message}") + if details: + print(f" Details: {details}") + sys.exit(1) + +def create_cln_invoice(amount_msat, label, description): + # Using the paths defined in the project structure + cln_dir = os.path.abspath("test_env_data/cln") + rpc_file = os.path.join(cln_dir, "regtest/lightning-rpc") + + cmd = [ + "lightning-cli", + "--regtest", + f"--lightning-dir={cln_dir}", + f"--rpc-file={rpc_file}", + "invoice", str(amount_msat), label, description + ] + + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + return json.loads(res.stdout)["bolt11"] + except subprocess.CalledProcessError as e: + print_fail("Failed to create CLN invoice", f"STDOUT: {e.stdout}\nSTDERR: {e.stderr}") + except Exception as e: + print_fail("Failed to create CLN invoice", str(e)) + +def get_cln_node_uri(): + cln_dir = os.path.abspath("test_env_data/cln") + rpc_file = os.path.join(cln_dir, "regtest/lightning-rpc") + cmd = ["lightning-cli", "--regtest", f"--lightning-dir={cln_dir}", f"--rpc-file={rpc_file}", "getinfo"] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(res.stdout) + pubkey = data["id"] + # In devenv.nix, CLN is bound to 9736 + address = "127.0.0.1:9736" + return f"{pubkey}@{address}" + except Exception as e: + print_fail("Failed to get CLN URI", str(e)) + +def mine_blocks(num_blocks): + btc_dir = os.path.abspath("test_env_data/bitcoin") + # Ensure wallet is loaded + subprocess.run(["bitcoin-cli", "-regtest", f"-datadir={btc_dir}", "loadwallet", "testwallet"], capture_output=True) + + # Get a fresh address + res = subprocess.run(["bitcoin-cli", "-regtest", f"-datadir={btc_dir}", "-rpcwallet=testwallet", "getnewaddress"], capture_output=True, text=True) + address = res.stdout.strip() + if not address: + # Fallback address if getnewaddress fails + address = "bcrt1qs758pk7pkkyer7kv8v3ch6u90858759v6yzn8f" + + cmd = ["bitcoin-cli", "-regtest", f"-datadir={btc_dir}", "-rpcwallet=testwallet", "generatetoaddress", str(num_blocks), address] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + print(f"Mined {num_blocks} blocks to {address}.") + except Exception as e: + # Try legacy 'generate' just in case, though unlikely for v30 + try: + subprocess.run(["bitcoin-cli", "-regtest", f"-datadir={btc_dir}", "-rpcwallet=testwallet", "-generate", str(num_blocks)], capture_output=True, check=True) + print(f"Mined {num_blocks} blocks using -generate.") + except: + print(f"Warning: Failed to mine blocks: {e}") + +def test_open_channel(): + print("Testing Channel Opening...") + + # Check LND sync status + try: + resp = session.get(f"{BASE_URL}/lightning/get-info") + info = resp.json() + print(f"LND Sync Status: Synced to Chain: {info.get('synced_to_chain')}, Synced to Graph: {info.get('synced_to_graph')}") + except: + pass + + cln_uri = get_cln_node_uri() + print(f"Target CLN URI: {cln_uri}") + + # Check if LND has funds, if not, fund it + try: + response = session.get(f"{BASE_URL}/lightning/get-balance") + response.raise_for_status() + balance = response.json() + print(f"Debug Balance Response: {balance}") + # The key in models.py is onchain_total_balance + onchain_balance = balance.get("onchain_total_balance") + if onchain_balance is None: + # Try other possible keys if model changed + onchain_balance = balance.get("onchain_total", 0) + + if onchain_balance < 2000000: # Need ~2M sats + print(f"LND balance low ({onchain_balance} sats), funding LND...") + resp = session.post(f"{BASE_URL}/lightning/new-address", json={"type": "p2wkh"}) + addr = resp.json() + print(f"LND address: {addr}") + + btc_dir = os.path.abspath("test_env_data/bitcoin") + # Try to send funds using a fixed fee rate to avoid estimation issues + try: + print(f"Sending 10 BTC to {addr}...") + subprocess.run([ + "bitcoin-cli", "-regtest", f"-datadir={btc_dir}", "-rpcwallet=testwallet", + "-named", "sendtoaddress", f"address={addr}", "amount=10", "fee_rate=1000" + ], check=True, capture_output=True, text=True) + mine_blocks(6) + print("Waiting 10 seconds for LND to sync funds...") + time.sleep(10) + + # Check balance again + response = session.get(f"{BASE_URL}/lightning/get-balance") + balance = response.json() + onchain_balance = balance.get("onchain_total_balance", 0) + print(f"LND New Balance: {onchain_balance} sats") + except subprocess.CalledProcessError as e: + print_fail(f"sendtoaddress failed: {e.stderr}") + + time.sleep(2) # wait for sync + except Exception as e: + print(f"Warning: Could not check/fund balance: {e}") + + try: + # Reduced amount to 100k sats + funding_amt = 100000 + params = { + "local_funding_amount": funding_amt, + "node_URI": cln_uri, + "target_confs": 3 + } + print(f"Opening {funding_amt} sat channel to {cln_uri}...") + + # Before calling API, let's try to connect via lncli to debug + lnd_dir = os.path.abspath("test_env_data/lnd") + pubkey = cln_uri.split("@")[0] + host = cln_uri.split("@")[1] + + print(f"DEBUG: Trying manual connect via lncli...") + subprocess.run([ + "lncli", f"--lnddir={lnd_dir}", "--network=regtest", + "connect", cln_uri + ], capture_output=True) + + response = session.post(f"{BASE_URL}/lightning/open-channel", params=params) + + if response.status_code != 200: + print(f"Channel open failed with status {response.status_code}: {response.text}") + # If it's a 500 with "EOF", let's try to see what lncli openchannel says + print(f"DEBUG: Trying manual openchannel via lncli...") + res = subprocess.run([ + "lncli", f"--lnddir={lnd_dir}", "--network=regtest", + "openchannel", "--node_key", pubkey, "--local_amt", str(funding_amt) + ], capture_output=True, text=True) + print(f"DEBUG: lncli output: {res.stdout}") + print(f"DEBUG: lncli error: {res.stderr}") + + if "EOF" in response.text: + print("Got EOF, retrying once after 5s...") + time.sleep(5) + response = session.post(f"{BASE_URL}/lightning/open-channel", params=params) + + response.raise_for_status() + txid = response.json() + print_pass(f"POST /lightning/open-channel (TXID: {txid})") + + print("Mining 6 blocks to confirm channel...") + mine_blocks(6) + + print("Waiting for channel to become active...") + max_retries = 30 + for i in range(max_retries): + resp = session.get(f"{BASE_URL}/lightning/list-channels") + channels = resp.json() + # LND channel_id in list-channels is often funding_txid:output_index + active_channels = [c for c in channels if c.get("active")] + if active_channels: + print(f"Channel is active! (Try {i+1})") + print(f"DEBUG: Channel info: {active_channels[0]}") + break + print(f"Channel not active yet, waiting... (Try {i+1}/{max_retries})") + time.sleep(2) + else: + print_fail("Channel never became active") + + print("Waiting 5 more seconds for good measure...") + time.sleep(5) + + except Exception as e: + details = str(e) + if hasattr(e, 'response') and e.response is not None: + details += f"\nResponse Body: {e.response.text}" + print_fail("POST /lightning/open-channel failed", details) + +def test_send_payment(): + label = f"test-label-{uuid.uuid4()}" + description = "test-payment-from-api" + amount_msat = 4000000 # 4000 sat + + print(f"Creating CLN invoice for {amount_msat} msat...") + bolt11 = create_cln_invoice(amount_msat, label, description) + print(f"Invoice created: {bolt11[:50]}...") + + try: + # POST /lightning/send-payment + # Parameters are query parameters according to openapi.json + params = {"pay_req": bolt11} + print("Sending payment via API...") + response = session.post(f"{BASE_URL}/lightning/send-payment", params=params) + response.raise_for_status() + + payment_data = response.json() + status = payment_data.get("status") + + if status == "succeeded": + print_pass("POST /lightning/send-payment (Payment Succeeded)") + else: + print_fail("Payment did not succeed", f"Status: {status}\nResponse: {payment_data}") + + except Exception as e: + # If it's a 400/500, we might get more info from the body + details = str(e) + if hasattr(e, 'response') and e.response is not None: + details += f"\nResponse Body: {e.response.text}" + print_fail("POST /lightning/send-payment failed", details) + +def test_login(): + url = f"{BASE_URL}/system/login" + payload = {"password": PASSWORD} + try: + response = session.post(url, json=payload) + response.raise_for_status() + token = response.json() + + if not token or not isinstance(token, str): + print_fail("Login failed", f"Response is not a token string: {token}") + + session.headers.update({"Authorization": f"Bearer {token}"}) + print_pass("Login successful") + return token + except Exception as e: + print_fail("Login failed", str(e)) + +def test_system_info(): + endpoints = [ + "/system/get-system-info", + "/system/health", + "/system/connection-info" + ] + for ep in endpoints: + try: + response = session.get(f"{BASE_URL}{ep}") + response.raise_for_status() + print_pass(f"GET {ep}") + except Exception as e: + print_fail(f"GET {ep} failed", str(e)) + +def test_bitcoin_info(): + endpoints = [ + "/bitcoin/btc-info", + "/bitcoin/get-blockchain-info", + "/bitcoin/get-network-info", + "/bitcoin/get-block-count" + ] + for ep in endpoints: + try: + response = session.get(f"{BASE_URL}{ep}") + response.raise_for_status() + + # The API returns the raw bitcoind RPC response for some endpoints as a string + if ep == "/bitcoin/get-block-count": + data = response.json() + if isinstance(data, str): + data = json.loads(data) + + if "result" not in data: + print_fail(f"{ep} response missing 'result'", str(data)) + + try: + int(data["result"]) + except (ValueError, TypeError): + print_fail(f"{ep} result is not an integer", str(data["result"])) + + print_pass(f"GET {ep}") + except Exception as e: + print_fail(f"GET {ep} failed", str(e)) + +def test_lightning_info(): + endpoints = [ + "/lightning/get-info", + "/lightning/get-balance", + "/lightning/list-channels", + "/lightning/list-invoices" + ] + for ep in endpoints: + try: + response = session.get(f"{BASE_URL}{ep}") + response.raise_for_status() + print_pass(f"GET {ep}") + except Exception as e: + print_fail(f"GET {ep} failed", str(e)) + +def test_lightning_actions(): + # 1. New Address + try: + # Assuming NewAddressInput has 'type' field + payload = {"type": "p2wkh"} + response = session.post(f"{BASE_URL}/lightning/new-address", json=payload) + response.raise_for_status() + address = response.json() + if not isinstance(address, str): + print_fail("New address response is not a string", str(address)) + print_pass("POST /lightning/new-address") + except Exception as e: + print_fail("POST /lightning/new-address failed", str(e)) + + # 2. Add Invoice + pay_req = None + try: + # Params are query parameters as per openapi.json + params = {"value_msat": 1000, "memo": "test_api_script"} + response = session.post(f"{BASE_URL}/lightning/add-invoice", params=params) + response.raise_for_status() + invoice_data = response.json() + pay_req = invoice_data.get("payment_request") + if not pay_req: + print_fail("Add invoice response missing payment_request", str(invoice_data)) + print_pass("POST /lightning/add-invoice") + except Exception as e: + print_fail("POST /lightning/add-invoice failed", str(e)) + + # 3. Decode Pay Req + if pay_req: + try: + response = session.get(f"{BASE_URL}/lightning/decode-pay-req", params={"pay_req": pay_req}) + response.raise_for_status() + decoded = response.json() + # Check basic fields + if decoded.get("num_msat") != 1000: + print_fail("Decoded payment request has wrong amount", str(decoded)) + print_pass("GET /lightning/decode-pay-req") + except Exception as e: + print_fail("GET /lightning/decode-pay-req failed", str(e)) + +def main(): + print(f"Starting API tests against {BASE_URL}") + print("-" * 40) + + test_login() + + print("Priming blockchain (mining 101 blocks)...") + mine_blocks(101) + + test_system_info() + test_bitcoin_info() + test_lightning_info() + test_lightning_actions() + test_open_channel() + test_send_payment() + + print("-" * 40) + print("🎉 All basic API tests passed!") + +if __name__ == "__main__": + main() diff --git a/tests/repositories/system_impl/test_native_python.py b/tests/repositories/system_impl/test_native_python.py deleted file mode 100644 index f964d04..0000000 --- a/tests/repositories/system_impl/test_native_python.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from pydantic import AnyStrMinLengthError, ValidationError - -import app.repositories.system_impl.native_python as npy -from app.system.models import LoginInput - - -@pytest.mark.asyncio -async def test_match_password(monkeypatch): - ok = LoginInput(password="password_1234") - nok = LoginInput(password="wrong_password") - - monkeypatch.setattr( - "app.repositories.system_impl.native_python.config", - lambda _, cast: ok.password, - ) - - res = await npy.match_password(ok) - assert res is True - - res = await npy.match_password(nok) - assert res is False - - with pytest.raises(ValidationError) as exc_info: - # Should be 8 characters - LoginInput(password="1234567") - - exc = exc_info.value - assert type(exc) is ValidationError - assert type(exc.args[0][0].exc) is AnyStrMinLengthError diff --git a/tests/repositories/system_impl/test_raspiblitz.py b/tests/repositories/system_impl/test_raspiblitz.py deleted file mode 100644 index 4599264..0000000 --- a/tests/repositories/system_impl/test_raspiblitz.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - - -@pytest.mark.asyncio -async def test_match_password(monkeypatch): - # TODO: implement this - # emulate what - # /home/admin/config.scripts/blitz.passwords.sh check a "{i.password}" does - assert True diff --git a/tests/repositories/test_system.py b/tests/repositories/test_system.py deleted file mode 100644 index ebb07ff..0000000 --- a/tests/repositories/test_system.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -from fastapi import HTTPException, status - -import app.system.service as sys -from app.system.models import LoginInput - - -@pytest.mark.asyncio -async def test_login(monkeypatch): - async def fake_match_pw_positive(_): - return True - - monkeypatch.setattr( - "app.repositories.system.match_password", - fake_match_pw_positive, - ) - - res = await sys.login(i=LoginInput(password="12345678")) - assert type(res) is dict - assert res.startswith("ey") is True - - async def fake_match_pw_negative(_): - return False - - monkeypatch.setattr( - "app.repositories.system.match_password", - fake_match_pw_negative, - ) - - with pytest.raises(HTTPException) as exc_info: - res = await sys.login(i=LoginInput(password="123456789")) - print(res) - - exc = exc_info.value - assert type(exc) is HTTPException - assert exc.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/routers/test_lightning.py b/tests/routers/test_lightning.py index 41ea9c5..e6f7d65 100644 --- a/tests/routers/test_lightning.py +++ b/tests/routers/test_lightning.py @@ -2,13 +2,12 @@ from starlette.testclient import TestClient from app.main import app from tests.routers.utils import call_route -from tests.utils import monkeypatch_auth test_client = TestClient(app) def test_route_authentications_latest(): - prefixes = ["/latest/lightning", "/v1/lightning"] + prefixes = ["/lightning"] for prefix in prefixes: p = {"value_msat": 1337} diff --git a/tests/routers/test_system.py b/tests/routers/test_system.py index 800212e..8511776 100644 --- a/tests/routers/test_system.py +++ b/tests/routers/test_system.py @@ -7,7 +7,7 @@ client = TestClient(app) def test_route_authentications_latest(): - prefixes = ["/latest/system", "/v1/system"] + prefixes = ["/system"] for prefix in prefixes: call_route(client, f"{prefix}/refresh-token", method="p") diff --git a/tests/routers/utils.py b/tests/routers/utils.py index d63a089..74d6be6 100644 --- a/tests/routers/utils.py +++ b/tests/routers/utils.py @@ -6,16 +6,17 @@ invalid_auth_header = {"Authorization": "Bearer dsgfdg"} def call_route(test_client: TestClient, route: str, params={}, method="g"): # Test with no "Authentication" header at all - # Should return a 403 by FastAPI's HTTPBearer + # Should return a 401 by BlitzAPI's JWTBearer response = None if method == "g": response = test_client.get(route, params=params) elif method == "p": response = test_client.post(route, params=params) - assert response.status_code == status.HTTP_403_FORBIDDEN + + assert response.status_code == status.HTTP_401_UNAUTHORIZED # Test with an invalid "Authentication" header - # Should return a 401 by BlitzAPI's AuthBearer + # Should return a 401 by BlitzAPI's JWTBearer response = None if method == "g": response = test_client.get(route, params=params, headers=invalid_auth_header)