mirror of
https://github.com/fusion44/blitz_api.git
synced 2026-08-13 11:52:45 +02:00
tests: add better integration tests
This commit is contained in:
parent
e564d18d2b
commit
74059b9256
8 changed files with 390 additions and 85 deletions
|
|
@ -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\"." }
|
||||
}
|
||||
|
|
|
|||
379
scripts/verify_api.py
Normal file
379
scripts/verify_api.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue