Files
Sovran_SystemsOS/app/tests/test_wallet_connections.py
T
copilot-swe-agent[bot]andGitHub c449044dcf fix: regenerate Alby Hub v1.23.0 patches against exact upstream source
Patch 1 (0001-private-route-hints.patch):
- Add missing RouteHints: hints, context line (v1.23.0 added this field
  between Expiry and Private; old patch lacked it, causing hunk failure)
- Fix amountMsat parameter name in hunk header (was 'amount' from older version)
- Only regular invoice Private field is changed; MakeHoldInvoice keeps
  Private: !hasPublicChannels unchanged

Patch 2 (0002-isolated-invoice-app-id.patch):
- Fix CreateInvoice interface: amountMsat uint64 (not amount uint64)
- Fix MakeInvoiceRequest: Amount/AmountSat/AmountMsat are *uint64 pointers
  (not Amount uint64) matching v1.23.0 struct layout
- Fix MakeInvoice call: 9 args → 10 args with appId as 8th positional arg
  (nil, nil trailing) matching v1.23.0 signature
- Fix function impl parameter: amountMsat uint64 throughout

Both patches dry-run verified against exact v1.23.0 source and apply cleanly
in sequence. Updated NixPatchContractTests to assert v1.23.0 context
(RouteHints, amountMsat, *uint64 pointer types, 10-arg MakeInvoice call).

All 233 Python tests pass. JS syntax checks pass.
2026-07-27 10:49:25 +00:00

1335 lines
53 KiB
Python

"""
Wallet Connections tests — validates the real AlbyHubManager and LNURL service
using mocked Alby Hub HTTP responses.
Tests cover:
- Feature registry and role visibility
- Manager idempotent setup/start/unlock
- Token refresh after 401/403
- App and transaction pagination
- Real create request body and scopes
- Real pairingUri returned once, absent from list
- Duplicate alias/name rejection
- Initial transfer success and partial-failure semantics
- Real list mapping (no secrets)
- Pending transaction blocking
- Drain permission update, transfer, final verification, restoration
- Delete using app pubkey after drain
- LNURL discovery from real-style app metadata
- Callback /api/invoices request includes numeric appId
- AppId mismatch rejection
- Invalid/fake BOLT11 rejection
- Public verification failure does not duplicate or roll back creation
- Caddy proxies to dedicated LNURL port 8181, not 8937
- Internal ports are not publicly opened
"""
import json
import re
import shutil
import subprocess
import sys
import tempfile
import types
import unittest
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# ── Minimal stubs so server.py can be imported without full FastAPI ──
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *a, **kw): pass
def mount(self, *a, **kw): return None
def add_middleware(self, *a, **kw): return None
def __getattr__(self, _name):
def _deco_factory(*a, **kw):
def _deco(func): return func
return _deco
return _deco_factory
class _BaseModel: pass
class _JSONResponse:
def __init__(self, content=None, status_code=200):
self.content = content
self.status_code = status_code
self.body = json.dumps(content or {}).encode("utf-8")
fastapi_mod = types.ModuleType("fastapi")
fastapi_mod.FastAPI = _FastAPI
fastapi_mod.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_mod
resp_mod = types.ModuleType("fastapi.responses")
resp_mod.HTMLResponse = object
resp_mod.RedirectResponse = object
resp_mod.JSONResponse = _JSONResponse
sys.modules["fastapi.responses"] = resp_mod
sys.modules["fastapi.staticfiles"] = types.ModuleType("fastapi.staticfiles")
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
sys.modules["fastapi.staticfiles"].StaticFiles = _StaticFiles
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
tmpl_mod = types.ModuleType("fastapi.templating")
tmpl_mod.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = tmpl_mod
req_mod = types.ModuleType("fastapi.requests")
req_mod.Request = object
sys.modules["fastapi.requests"] = req_mod
pyd_mod = types.ModuleType("pydantic")
pyd_mod.BaseModel = _BaseModel
sys.modules["pydantic"] = pyd_mod
stl_base = types.ModuleType("starlette.middleware.base")
stl_base.BaseHTTPMiddleware = object
sys.modules["starlette.middleware.base"] = stl_base
stl_mw = types.ModuleType("starlette.middleware")
sys.modules["starlette.middleware"] = stl_mw
stl = types.ModuleType("starlette")
sys.modules["starlette"] = stl
_install_web_stubs()
from sovran_systemsos_web import server
from sovran_systemsos_web import nwc_hub_manager as mgr
from sovran_systemsos_web.nwc_lnurl_service import (
_lnurl_callback,
_lnurl_discovery,
)
# ── Helpers ───────────────────────────────────────────────────────
def _make_app(
id_=1,
name="Test Wallet",
alias="testwallet",
scopes=None,
pubkey="aabbcc",
balance_msat=0,
pending=None,
max_amount=0,
):
if scopes is None:
scopes = list(mgr.RECEIVE_ONLY_SCOPES)
return {
"id": id_,
"name": name,
"appPubkey": pubkey,
"nostrPubkey": pubkey,
"scopes": scopes,
"isolated": True,
"maxAmountSat": max_amount,
"budgetRenewal": "never",
"balanceMsat": balance_msat,
"pendingTransactions": pending or [],
"metadata": {
"app_store_app_id": "uncle-jim",
"lnurl_alias": alias,
"lnurl_description": "Pay via Lightning",
"lnurl_min_sendable_msat": 1000,
"lnurl_max_sendable_msat": 1_000_000_000,
},
"createdAt": 1700000000,
}
def _fresh_manager() -> mgr.AlbyHubManager:
"""Return a new manager with non-existent paths so filesystem checks fail fast."""
return mgr.AlbyHubManager(
api_base="http://127.0.0.1:18080",
unlock_password_file="/nonexistent/unlock-password",
macaroon_file="/nonexistent/albyhub.macaroon",
)
# ── Feature registry tests ────────────────────────────────────────
class FeatureRegistryTests(unittest.TestCase):
def test_node_role_includes_nwc_wallets(self):
self.assertIn("nwc-wallets", server.ROLE_FEATURES["node"])
def test_desktop_role_excludes_nwc_wallets(self):
self.assertNotIn("nwc-wallets", server.ROLE_FEATURES["desktop"])
def test_feature_metadata(self):
feat = next(f for f in server.FEATURE_REGISTRY if f["id"] == "nwc-wallets")
self.assertEqual(feat["name"], "Wallet Connections")
self.assertTrue(feat["needs_domain"])
self.assertEqual(feat["domain_name"], "lightning")
ports = [(p["port"], p["protocol"]) for p in feat["port_requirements"]]
self.assertIn(("80", "TCP"), ports)
self.assertIn(("443", "TCP"), ports)
def test_wallet_connections_tile_icon_is_nwc(self):
repo_root = Path(__file__).resolve().parents[2]
hub_module = repo_root / "modules" / "core" / "sovran-hub.nix"
text = hub_module.read_text()
self.assertIn('{ name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc";', text)
self.assertIn('{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus";', text)
def test_service_map_points_to_albyhub(self):
self.assertEqual(server.FEATURE_SERVICE_MAP["nwc-wallets"], "albyhub.service")
def test_domain_map_points_to_albyhub(self):
self.assertEqual(server.SERVICE_DOMAIN_MAP["albyhub.service"], "lightning")
def test_lnurl_paths_not_in_auth_exempt_prefixes(self):
for prefix in server._AUTH_EXEMPT_PREFIXES:
self.assertNotIn("lnurlp", prefix)
def test_caddy_lnurl_proxy_port_is_not_8937(self):
"""Caddy must proxy LNURL routes to the dedicated service port (8181), not Hub port 8937."""
caddy_nix = (
Path(__file__).resolve().parents[3] / "modules" / "core" / "caddy.nix"
)
if caddy_nix.exists():
content = caddy_nix.read_text()
# Find the LIGHTNING block
lightning_block = re.search(
r"LIGHTNING\s*\{[^}]+\}", content, re.DOTALL
)
if lightning_block:
block = lightning_block.group(0)
self.assertNotIn(
"8937",
block,
"Caddy must NOT proxy LNURL routes to Hub port 8937",
)
self.assertIn(
"8181",
block,
"Caddy must proxy LNURL routes to dedicated LNURL port 8181",
)
# ── Alias validation ──────────────────────────────────────────────
class AliasValidationTests(unittest.TestCase):
def test_valid_aliases(self):
for alias in ("app", "a1", "my-wallet", "app_1", "a" * 32):
self.assertTrue(server._nwc_validate_alias(alias), f"expected valid: {alias}")
def test_invalid_aliases(self):
for alias in ("_bad", "Upper", "a" * 33, "", "-start"):
self.assertFalse(server._nwc_validate_alias(alias), f"expected invalid: {alias}")
# ── Manager unit tests (mocked HTTP) ────────────────────────────────
class ManagerEnsureReadyTests(unittest.TestCase):
def _manager_with_stubs(self, unlock_pw="testpass", macaroon_hex="deadbeef"):
m = _fresh_manager()
m._wait_for_file = MagicMock()
m._wait_for_hub_api = MagicMock()
m._read_unlock_password = MagicMock(return_value=unlock_pw)
m._wait_for_node_ready = MagicMock()
return m
def test_setup_and_token_cached(self):
m = self._manager_with_stubs()
m._hub_setup = MagicMock()
m._obtain_token = MagicMock(return_value="tok123")
token = m.ensure_ready()
self.assertEqual(token, "tok123")
# Second call should use cached token without re-auth
token2 = m.ensure_ready()
self.assertEqual(token2, "tok123")
m._obtain_token.assert_called_once()
def test_idempotent_setup_skipped_when_already_complete(self):
m = self._manager_with_stubs()
m._hub_setup = MagicMock()
m._obtain_token = MagicMock(return_value="tok-setup")
m.ensure_ready()
m._hub_setup.assert_called_once()
def test_401_triggers_token_refresh(self):
m = self._manager_with_stubs()
m._hub_setup = MagicMock()
tokens = iter(["first-token", "refreshed-token"])
m._obtain_token = MagicMock(side_effect=tokens)
m.ensure_ready()
# First _request call raises 401; second returns success after token refresh
request_count = [0]
def _request_side(*_a, **_kw):
request_count[0] += 1
if request_count[0] == 1:
raise mgr.AlbyHubHttpError(401, "Unauthorised")
return {"ok": True}
m._request = MagicMock(side_effect=_request_side)
result = m._authenticated_request("GET", "/api/apps")
# The retry with a refreshed token should succeed
self.assertEqual(result, {"ok": True})
# Token must have been refreshed (obtain_token called twice total)
self.assertEqual(m._obtain_token.call_count, 2)
def test_403_triggers_token_refresh(self):
m = self._manager_with_stubs()
m._hub_setup = MagicMock()
tokens = iter(["first", "second", "third"])
m._obtain_token = MagicMock(side_effect=tokens)
m.ensure_ready()
m._token = None # clear to force re-auth
request_count = [0]
def _request_side(*_a, **_kw):
request_count[0] += 1
if request_count[0] == 1:
raise mgr.AlbyHubHttpError(403, "Forbidden")
return {}
m._request = MagicMock(side_effect=_request_side)
result = m._authenticated_request("GET", "/api/apps")
self.assertEqual(result, {})
def test_obtain_token_uses_start_when_not_running(self):
m = _fresh_manager()
def _request(method, path, **_kw):
if method == "GET" and path == "/api/info":
return {"running": False}
if method == "POST" and path == "/api/start":
return {"token": "start-token"}
self.fail(f"unexpected call: {method} {path}")
m._request = MagicMock(side_effect=_request)
token = m._obtain_token("pw")
self.assertEqual(token, "start-token")
def test_obtain_token_uses_unlock_when_running(self):
m = _fresh_manager()
calls = []
def _request(method, path, **kw):
calls.append((method, path, kw.get("body")))
if method == "GET" and path == "/api/info":
return {"running": True}
if method == "POST" and path == "/api/unlock":
return {"token": "unlock-token"}
self.fail(f"unexpected call: {method} {path}")
m._request = MagicMock(side_effect=_request)
token = m._obtain_token("pw")
self.assertEqual(token, "unlock-token")
unlock_call = [c for c in calls if c[0] == "POST" and c[1] == "/api/unlock"][0]
self.assertEqual(unlock_call[2]["permission"], "full")
def test_hub_setup_uses_lnd_macaroon_file(self):
m = _fresh_manager()
calls = []
def _request(method, path, **kw):
calls.append((method, path, kw.get("body")))
if method == "GET" and path == "/api/info":
return {"setupCompleted": False}
if method == "POST" and path == "/api/setup":
return {}
self.fail(f"unexpected call: {method} {path}")
m._request = MagicMock(side_effect=_request)
m._hub_setup("pw")
setup_call = [c for c in calls if c[0] == "POST" and c[1] == "/api/setup"][0]
body = setup_call[2]
self.assertEqual(body["backendType"], "LND")
self.assertEqual(body["lndMacaroonFile"], m.macaroon_file)
self.assertNotIn("lndMacaroon", body)
class ManagerPaginationTests(unittest.TestCase):
def test_paginate_uses_total_count(self):
m = _fresh_manager()
page1 = {"apps": [{"id": i} for i in range(3)], "totalCount": 5}
page2 = {"apps": [{"id": 3}, {"id": 4}], "totalCount": 5}
def _request(method, path, **_kw):
if "offset=0" in path:
return page1
return page2
m._token = "tok"
m._request = MagicMock(side_effect=_request)
result = m._paginate("/api/apps?limit={limit}&offset={offset}", page_size=3)
self.assertEqual(len(result), 5)
def test_paginate_single_page_stops(self):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(return_value=[{"id": 1}, {"id": 2}])
result = m._paginate("/api/apps?limit={limit}&offset={offset}", page_size=100)
self.assertEqual(len(result), 2)
m._request.assert_called_once()
class ManagerListTests(unittest.TestCase):
def _mgr_with_token(self, apps):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(return_value=apps)
return m
def test_list_returns_only_managed_isolated_apps(self):
apps = [
_make_app(id_=1, alias="alice"),
{
"id": 2, "name": "Unmanaged", "isolated": True,
"metadata": {"app_store_app_id": "other"},
"scopes": [], "budget": {}, "pendingTransactions": [],
},
{
"id": 3, "name": "Not isolated", "isolated": False,
"metadata": {"app_store_app_id": "uncle-jim"},
"scopes": [], "budget": {}, "pendingTransactions": [],
},
]
m = self._mgr_with_token(apps)
result = m.list_wallets(domain="pay.example.com")
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["alias"], "alice")
def test_list_does_not_include_pairing_uri(self):
apps = [_make_app()]
m = self._mgr_with_token(apps)
for wallet in m.list_wallets():
self.assertNotIn("pairing_uri", wallet)
self.assertNotIn("pairingUri", wallet)
def test_list_maps_access_preset_from_scopes(self):
apps = [
_make_app(id_=1, alias="recv", scopes=list(mgr.RECEIVE_ONLY_SCOPES)),
_make_app(id_=2, alias="send", scopes=list(mgr.LIMITED_SEND_SCOPES)),
]
m = self._mgr_with_token(apps)
wallets = m.list_wallets()
self.assertEqual(wallets[0]["access_preset"], "receive_only")
self.assertEqual(wallets[1]["access_preset"], "send_receive_limited")
def test_list_maps_lightning_address(self):
apps = [_make_app(alias="bob")]
m = self._mgr_with_token(apps)
result = m.list_wallets(domain="pay.example.com")
self.assertEqual(result[0]["lightning_address"], "bob@pay.example.com")
def test_list_uses_app_pubkey_and_balance_msat(self):
apps = [_make_app(pubkey="pubkey-1", balance_msat=12345)]
m = self._mgr_with_token(apps)
wallets = m.list_wallets()
self.assertEqual(wallets[0]["pubkey"], "pubkey-1")
self.assertEqual(wallets[0]["balance_sats"], 12)
self.assertEqual(wallets[0]["dust_msat"], 345)
class ManagerCreateTests(unittest.TestCase):
def _mgr(self, existing_apps=None, create_resp=None):
m = _fresh_manager()
m._token = "tok"
if existing_apps is None:
existing_apps = []
if create_resp is None:
create_resp = {
"id": 99,
"pairingUri": "nostr+walletconnect://fakepubkey?relay=wss%3A%2F%2Frelay.getalby.com&secret=FAKESECRET",
**_make_app(id_=99, alias="new"),
}
call_count = [0]
def _request(method, path, **kw):
call_count[0] += 1
if method == "GET" and path.startswith("/api/apps"):
return existing_apps
if method == "POST" and path == "/api/apps":
return create_resp
if method == "GET" and path.startswith("/api/v2/apps/99"):
return _make_app(id_=99, alias="new")
return {}
m._request = MagicMock(side_effect=_request)
return m
@staticmethod
def _call_body(call):
"""Return the ``body`` kwarg from a MagicMock call_args."""
return call.kwargs.get("body") or {}
def test_create_returns_pairing_uri_once(self):
m = self._mgr()
result = m.create_wallet("New Wallet", "new", "receive_only", None)
self.assertIn("pairing_uri", result)
self.assertTrue(result["pairing_uri"].startswith("nostr+walletconnect://"))
def test_create_uses_ordered_apps_list_and_v2_app_lookup(self):
m = self._mgr()
m.create_wallet("New Wallet", "new", "receive_only", None)
paths = [c.args[1] for c in m._request.call_args_list if len(c.args) > 1]
self.assertTrue(
any("/api/apps?limit=100&offset=0&order_by=created_at" in p for p in paths)
)
self.assertTrue(any(p.startswith("/api/v2/apps/99") for p in paths))
def test_create_sends_isolated_true(self):
m = self._mgr()
m.create_wallet("W", "w", "receive_only", None)
create_call = next(
c for c in m._request.call_args_list
if c.args[0] == "POST" and c.args[1] == "/api/apps"
)
body = self._call_body(create_call)
self.assertTrue(body.get("isolated"))
def test_create_receive_only_scopes(self):
m = self._mgr()
m.create_wallet("W", "w", "receive_only", None)
create_call = next(
c for c in m._request.call_args_list
if c.args[0] == "POST" and "/api/apps" in c.args[1]
)
body = self._call_body(create_call)
self.assertNotIn("pay_invoice", body.get("scopes", []))
for scope in mgr.RECEIVE_ONLY_SCOPES:
self.assertIn(scope, body.get("scopes", []))
def test_create_limited_includes_pay_invoice(self):
m = self._mgr()
m.create_wallet("W", "w", "send_receive_limited", 5000)
create_call = next(
c for c in m._request.call_args_list
if c.args[0] == "POST" and "/api/apps" in c.args[1]
)
body = self._call_body(create_call)
self.assertIn("pay_invoice", body.get("scopes", []))
def test_create_includes_managed_metadata(self):
m = self._mgr()
m.create_wallet("W", "w", "receive_only", None)
create_call = next(
c for c in m._request.call_args_list
if c.args[0] == "POST" and "/api/apps" in c.args[1]
)
body = self._call_body(create_call)
meta = body.get("metadata", {})
self.assertEqual(meta.get("app_store_app_id"), "uncle-jim")
self.assertEqual(meta.get("lnurl_alias"), "w")
def test_create_rejects_duplicate_alias(self):
existing = [_make_app(alias="dup")]
m = self._mgr(existing_apps=existing)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.create_wallet("New", "dup", "receive_only", None)
self.assertEqual(ctx.exception.code, "alias_exists")
def test_create_rejects_duplicate_name(self):
existing = [_make_app(name="Existing Wallet")]
m = self._mgr(existing_apps=existing)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.create_wallet("Existing Wallet", "newone", "receive_only", None)
self.assertEqual(ctx.exception.code, "wallet_name_exists")
def test_create_limited_performs_initial_transfer(self):
m = self._mgr()
transfers = []
original_request = m._request.side_effect
def _request(method, path, **kw):
if method == "POST" and path == "/api/transfers":
transfers.append(kw.get("body"))
return {}
return original_request(method, path, **kw)
m._request = MagicMock(side_effect=_request)
m.create_wallet("W", "w", "send_receive_limited", 5000)
self.assertEqual(len(transfers), 1)
self.assertEqual(transfers[0]["toAppId"], 99)
self.assertEqual(transfers[0]["amountSat"], 5000)
self.assertEqual(transfers[0]["description"], "Initial funding for W")
def test_create_partial_failure_funding_returns_pairing_uri(self):
"""Even when initial funding fails, the real pairing URI must be returned."""
m = self._mgr()
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return []
if method == "POST" and path == "/api/apps":
return {
"id": 99,
"pairingUri": "nostr+walletconnect://pubkey?relay=r&secret=S",
**_make_app(id_=99, alias="new"),
}
if method == "POST" and path == "/api/transfers":
raise mgr.AlbyHubError("transfer_failed", "Insufficient funds")
if method == "GET" and "/api/v2/apps/99" in path:
return _make_app(id_=99, alias="new")
return {}
m._request = MagicMock(side_effect=_request)
result = m.create_wallet("W", "new", "send_receive_limited", 5000)
# Pairing URI must still be returned
self.assertTrue(result["pairing_uri"])
# Funding failure must be clearly reported
self.assertFalse(result["result"]["funding"]["success"])
self.assertIn("message", result["result"]["funding"])
def test_create_partial_failure_message_says_created_successfully(self):
"""Partial-funding message must say 'was created successfully', not 'already exists'."""
m = self._mgr()
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return []
if method == "POST" and path == "/api/apps":
return {
"id": 99,
"pairingUri": "nostr+walletconnect://pubkey?relay=r&secret=S",
**_make_app(id_=99, alias="new"),
}
if method == "POST" and path == "/api/transfers":
raise mgr.AlbyHubError("transfer_failed", "Insufficient funds")
if method == "GET" and "/api/v2/apps/99" in path:
return _make_app(id_=99, alias="new")
return {}
m._request = MagicMock(side_effect=_request)
result = m.create_wallet("W", "new", "send_receive_limited", 5000)
message = result["result"]["funding"]["message"]
self.assertIn("was created successfully", message)
self.assertNotIn("already exists", message)
self.assertIn("Do not recreate", message)
class ManagerDrainTests(unittest.TestCase):
def _mgr(self, app, transfer_ok=True):
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return {"apps": [app], "totalCount": 1}
if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"):
return {**app, "balanceMsat": app.get("balanceMsat", 0) % 1000}
if method == "GET" and path.startswith("/api/transactions?"):
return {"transactions": [], "totalCount": 0}
if method == "PATCH":
return {}
if method == "POST" and path == "/api/transfers":
if not transfer_ok:
raise mgr.AlbyHubError("transfer_failed", "Fail")
return {}
return {}
m._request = MagicMock(side_effect=_request)
return m
def test_drain_transfers_whole_sats(self):
app = _make_app(balance_msat=5_000_000)
m = self._mgr(app)
result = m.drain_wallet("1")
self.assertTrue(result["ok"])
self.assertEqual(result["drained_sats"], 5000)
transfer_call = next(
c for c in m._request.call_args_list
if c.args[0] == "POST" and c.args[1] == "/api/transfers"
)
body = transfer_call.kwargs["body"]
self.assertEqual(body["fromAppId"], 1)
self.assertEqual(body["amountMsat"], 5_000_000)
def test_drain_preserves_dust(self):
app = _make_app(balance_msat=5_000_500)
m = self._mgr(app)
result = m.drain_wallet("1")
self.assertEqual(result["drained_sats"], 5000)
self.assertEqual(result["dust_msat"], 500)
def test_drain_patches_permissions_then_restores(self):
app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000)
m = self._mgr(app)
patches = []
patch_paths = []
original = m._request.side_effect
def _request(method, path, **kw):
if method == "PATCH":
patch_paths.append(path)
patches.append(kw.get("body"))
return {}
return original(method, path, **kw)
m._request = MagicMock(side_effect=_request)
m.drain_wallet("1")
self.assertEqual(len(patches), 2)
first_patch = patches[0]
second_patch = patches[1]
# First patch must add pay_invoice
self.assertIn("pay_invoice", first_patch.get("scopes", []))
self.assertTrue(all("/api/apps/aabbcc" in p for p in patch_paths))
# Second patch (restore) must match original scopes
self.assertEqual(
sorted(second_patch.get("scopes", [])),
sorted(mgr.RECEIVE_ONLY_SCOPES),
)
def test_drain_rejects_pending_transactions(self):
app = _make_app(pending=[{"state": "pending"}])
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return {"apps": [app], "totalCount": 1}
if path.startswith("/api/transactions?"):
return {"transactions": [{"state": "pending"}], "totalCount": 1}
return {}
m._request = MagicMock(side_effect=_request)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.drain_wallet("1")
self.assertEqual(ctx.exception.code, "pending_transactions")
def test_drain_restores_permissions_on_failure(self):
app = _make_app(scopes=list(mgr.RECEIVE_ONLY_SCOPES), balance_msat=1_000_000)
m = self._mgr(app, transfer_ok=False)
patches = []
original = m._request.side_effect
def _request(method, path, **kw):
if method == "PATCH":
patches.append(kw.get("body"))
return {}
return original(method, path, **kw)
m._request = MagicMock(side_effect=_request)
with self.assertRaises(mgr.AlbyHubError):
m.drain_wallet("1")
# Restore patch must still have been attempted
self.assertGreaterEqual(len(patches), 2)
restore = patches[-1]
self.assertEqual(sorted(restore.get("scopes", [])), sorted(mgr.RECEIVE_ONLY_SCOPES))
def test_drain_fails_when_final_balance_not_expected_dust(self):
app = _make_app(balance_msat=2_000)
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return {"apps": [app], "totalCount": 1}
if method == "GET" and path.startswith("/api/transactions?"):
return {"transactions": [], "totalCount": 0}
if method == "PATCH":
return {}
if method == "POST" and path == "/api/transfers":
return {}
if method == "GET" and path.startswith("/api/v2/apps/1"):
return {**app, "balanceMsat": 999}
return {}
m._request = MagicMock(side_effect=_request)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.drain_wallet("1")
self.assertEqual(ctx.exception.code, "drain_incomplete")
class ManagerDeleteTests(unittest.TestCase):
def _mgr(self, app, drain_ok=True):
m = _fresh_manager()
m._token = "tok"
deleted = []
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return {"apps": [app], "totalCount": 1}
if method == "GET" and path.startswith("/api/transactions?"):
return {"transactions": [], "totalCount": 0}
if method == "GET" and path.startswith(f"/api/v2/apps/{app['id']}"):
# After drain the balance is zero
a = dict(app)
a["balanceMsat"] = 0
return a
if method == "PATCH":
return {}
if method == "POST" and path == "/api/transfers":
if not drain_ok:
raise mgr.AlbyHubError("transfer_failed", "Fail")
return {}
if method == "DELETE":
deleted.append(path)
return {}
return {}
m._request = MagicMock(side_effect=_request)
m._deleted = deleted
return m
def test_delete_uses_pubkey_endpoint(self):
app = _make_app(pubkey="pubkey123", balance_msat=0)
m = self._mgr(app)
m.delete_wallet("1")
delete_path = m._deleted[0] if m._deleted else ""
self.assertIn("pubkey123", delete_path)
def test_delete_drains_before_deleting(self):
app = _make_app(pubkey="pk", balance_msat=1_000_000)
m = self._mgr(app)
m.delete_wallet("1")
# Ensure DELETE was called (drain happened first)
self.assertTrue(m._deleted)
def test_delete_rejects_pending_transactions(self):
app = _make_app(pending=[{"state": "pending"}])
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if method == "GET" and path.startswith("/api/apps?"):
return {"apps": [app], "totalCount": 1}
if path.startswith("/api/transactions?"):
return {"transactions": [{"state": "pending"}], "totalCount": 1}
return {}
m._request = MagicMock(side_effect=_request)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.delete_wallet("1")
self.assertEqual(ctx.exception.code, "pending_transactions")
class ManagerInvoiceTests(unittest.TestCase):
def _mgr(self, invoice="lnbc1000n1ptest", app_id=1):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(
return_value={"invoice": invoice, "appId": app_id}
)
return m
def test_invoice_returns_valid_bolt11(self):
m = self._mgr("lnbc5000n1pfake_invoice_test")
invoice = m.issue_invoice(1, 5_000_000)
self.assertTrue(invoice.startswith("lnbc"))
def test_invoice_rejects_fake_pr_string(self):
m = self._mgr("lnbc5n1" + "z" * 40)
# This starts with lnbc so is valid format - test the appId mismatch instead
m._request = MagicMock(
return_value={"invoice": "not_a_bolt11", "appId": 1}
)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.issue_invoice(1, 5_000_000)
self.assertEqual(ctx.exception.code, "invalid_invoice")
def test_invoice_rejects_appid_mismatch(self):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(
return_value={"invoice": "lnbc1000n1test", "appId": 999}
)
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.issue_invoice(1, 1_000_000)
self.assertEqual(ctx.exception.code, "invoice_attribution_failed")
def test_invoice_requires_returned_appid(self):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(return_value={"invoice": "lnbc1000n1test"})
with self.assertRaises(mgr.AlbyHubError) as ctx:
m.issue_invoice(1, 1_000_000)
self.assertEqual(ctx.exception.code, "invoice_attribution_failed")
def test_invoice_request_includes_app_id(self):
# Use a manager that returns the correct appId matching what we request
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(
return_value={"invoice": "lnbc1000n1test", "appId": 42}
)
m.issue_invoice(42, 2_000_000)
call_body = m._request.call_args.kwargs.get("body") or m._request.call_args[1].get("body")
self.assertEqual(call_body["appId"], 42)
# ── LNURL service tests ───────────────────────────────────────────
class LnurlDiscoveryTests(unittest.TestCase):
def _manager_for(self, app):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(return_value=[app])
return m
def test_discovery_returns_pay_request(self):
app = _make_app(alias="alice")
m = self._manager_for(app)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_discovery("alice", m)
self.assertEqual(code, 200)
self.assertEqual(payload["tag"], "payRequest")
self.assertIn("alice", payload["callback"])
def test_discovery_uses_app_metadata_for_limits(self):
app = _make_app(alias="bob")
app["metadata"]["lnurl_min_sendable_msat"] = 2000
app["metadata"]["lnurl_max_sendable_msat"] = 500_000_000
m = self._manager_for(app)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_discovery("bob", m)
self.assertEqual(payload["minSendable"], 2000)
self.assertEqual(payload["maxSendable"], 500_000_000)
def test_discovery_returns_404_for_unknown_alias(self):
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(return_value=[])
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_discovery("nobody", m)
self.assertEqual(code, 404)
def test_discovery_returns_503_when_domain_unconfigured(self):
m = self._manager_for(_make_app())
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value=None):
payload, code = _lnurl_discovery("alice", m)
self.assertEqual(code, 503)
class LnurlCallbackTests(unittest.TestCase):
def _manager_for(self, app, invoice="lnbc1000n1pfakebolt11test"):
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if path.startswith("/api/apps"):
return {"apps": [app], "totalCount": 1}
if path == "/api/invoices":
body = kw.get("body") or {}
return {"invoice": invoice, "appId": body.get("appId")}
return {}
m._request = MagicMock(side_effect=_request)
return m
def test_callback_returns_bolt11_invoice(self):
app = _make_app(alias="carol")
m = self._manager_for(app)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_callback("carol", "1000", m)
self.assertEqual(code, 200)
self.assertIn("pr", payload)
self.assertEqual(payload["routes"], [])
self.assertTrue(payload["pr"].startswith("lnbc"))
def test_callback_sends_app_id_to_invoices_api(self):
app = _make_app(id_=7, alias="dave")
m = self._manager_for(app)
invoice_calls = []
original = m._request.side_effect
def _request(method, path, **kw):
if method == "POST" and path == "/api/invoices":
invoice_calls.append(kw.get("body"))
return original(method, path, **kw)
m._request = MagicMock(side_effect=_request)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
_lnurl_callback("dave", "1000", m)
self.assertEqual(len(invoice_calls), 1)
self.assertEqual(invoice_calls[0]["appId"], 7)
def test_callback_rejects_amount_below_minimum(self):
app = _make_app(alias="eve")
m = self._manager_for(app)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_callback("eve", "500", m)
self.assertEqual(code, 400)
def test_callback_rejects_non_whole_satoshi(self):
app = _make_app(alias="frank")
m = self._manager_for(app)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_callback("frank", "1500", m)
self.assertEqual(code, 400)
def test_callback_rejects_appid_mismatch(self):
app = _make_app(id_=1, alias="grace")
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if path.startswith("/api/apps"):
return {"apps": [app], "totalCount": 1}
if path == "/api/invoices":
# Return wrong appId
return {"invoice": "lnbc1000n1pfake", "appId": 999}
return {}
m._request = MagicMock(side_effect=_request)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_callback("grace", "1000", m)
self.assertEqual(code, 502)
def test_callback_rejects_fake_bolt11(self):
app = _make_app(id_=1, alias="heidi")
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if path.startswith("/api/apps"):
return {"apps": [app], "totalCount": 1}
if path == "/api/invoices":
return {"invoice": "not_a_bolt11_string", "appId": 1}
return {}
m._request = MagicMock(side_effect=_request)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
payload, code = _lnurl_callback("heidi", "1000", m)
self.assertEqual(code, 502)
# ── LNURL HTTP handler tests ──────────────────────────────────────
class LnurlHandlerAmountTests(unittest.TestCase):
"""Tests the HTTP handler layer for amount parameter validation.
Uses the handler's do_GET directly with _send_json patched on the instance
so no real socket is needed.
"""
def _run_handler(self, path: str, manager=None) -> list[tuple[int, dict]]:
"""Invoke do_GET for *path* and return all (code, body) pairs sent."""
from sovran_systemsos_web.nwc_lnurl_service import _make_handler
if manager is None:
manager = _fresh_manager()
handler_class = _make_handler(manager)
sent: list[tuple[int, dict]] = []
handler = handler_class.__new__(handler_class)
handler._manager = manager
handler.path = path
# Intercept output without a real socket
handler._send_json = lambda code, body: sent.append((code, body))
handler.do_GET()
return sent
def test_duplicate_amount_returns_400_with_protocol_error(self):
"""Two amount values must be rejected at the HTTP handler level."""
sent = self._run_handler("/lnurlp/alice/callback?amount=1000&amount=500000")
self.assertEqual(len(sent), 1)
code, body = sent[0]
self.assertEqual(code, 400)
self.assertEqual(body["status"], "ERROR")
self.assertIn("single amount", body["reason"])
def test_three_amount_values_returns_400(self):
sent = self._run_handler("/lnurlp/alice/callback?amount=1000&amount=2000&amount=3000")
self.assertEqual(len(sent), 1)
code, body = sent[0]
self.assertEqual(code, 400)
self.assertEqual(body["status"], "ERROR")
def test_single_amount_passes_to_callback(self):
"""A single valid amount must reach the callback helper (not short-circuit)."""
app = _make_app(alias="alice")
m = _fresh_manager()
m._token = "tok"
def _request(method, path, **kw):
if path.startswith("/api/apps"):
return {"apps": [app], "totalCount": 1}
if path == "/api/invoices":
body = kw.get("body") or {}
return {"invoice": "lnbc1000n1pfake", "appId": body.get("appId")}
return {}
m._request = MagicMock(side_effect=_request)
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
sent = self._run_handler("/lnurlp/alice/callback?amount=1000", manager=m)
self.assertEqual(len(sent), 1)
code, _ = sent[0]
self.assertEqual(code, 200)
def test_missing_amount_returns_400_missing_reason(self):
"""No amount parameter must produce a 'Missing amount' error."""
app = _make_app(alias="alice")
m = _fresh_manager()
m._token = "tok"
m._request = MagicMock(side_effect=lambda method, path, **kw: (
{"apps": [app], "totalCount": 1} if path.startswith("/api/apps") else {}
))
with patch("sovran_systemsos_web.nwc_lnurl_service._read_domain", return_value="pay.example.com"):
sent = self._run_handler("/lnurlp/alice/callback", manager=m)
self.assertEqual(len(sent), 1)
code, body = sent[0]
self.assertEqual(code, 400)
self.assertIn("Missing", body["reason"])
# ── Nix/Patch contract tests ───────────────────────────────────────
class NixPatchContractTests(unittest.TestCase):
@staticmethod
def _patched_albyhub_nix_expr(result_expr: str) -> str:
return (
"let flake = builtins.getFlake (toString ./.); "
"pkgs = import flake.inputs.nixpkgs { system = builtins.currentSystem; }; "
"patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { patches = (old.patches or []) ++ [ "
"./packages/albyhub/0001-private-route-hints.patch "
"./packages/albyhub/0002-isolated-invoice-app-id.patch "
"]; }); "
f"in {result_expr}"
)
def test_nwc_module_uses_non_placeholder_albyhub_strategy(self):
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "modules" / "nwc-wallets.nix"
text = module_path.read_text()
self.assertIn("pkgs.albyhub.overrideAttrs", text)
self.assertIn("../packages/albyhub/0001-private-route-hints.patch", text)
self.assertIn("../packages/albyhub/0002-isolated-invoice-app-id.patch", text)
self.assertNotIn("sha256-AAAA", text)
self.assertNotIn("lib.fakeHash", text)
self.assertIn("AUTO_UNLOCK_PASSWORD", text)
self.assertNotIn("AUTO_UNLOCK_PASSWORD_FILE", text)
def test_nwc_module_uses_lib_getexe_for_albyhub_binary(self):
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "modules" / "nwc-wallets.nix"
text = module_path.read_text()
self.assertIn("exec ${lib.getExe patchedAlbyHub}", text)
self.assertNotIn("${patchedAlbyHub}/bin/hub", text)
def test_official_nwc_icon_asset_is_committed(self):
repo_root = Path(__file__).resolve().parents[2]
icon_path = repo_root / "app" / "icons" / "nwc.svg"
self.assertTrue(icon_path.exists())
text = icon_path.read_text()
self.assertIn("linearGradient", text)
self.assertIn("#F7931A", text)
def test_albyhub_main_program_via_nix_eval(self):
if shutil.which("nix") is None:
self.skipTest("nix not installed in this environment")
repo_root = Path(__file__).resolve().parents[2]
get_exe_expr = self._patched_albyhub_nix_expr("pkgs.lib.getExe patchedAlbyHub")
get_exe_result = subprocess.run(
[
"nix",
"eval",
"--raw",
"--impure",
"--expr",
get_exe_expr,
],
check=True,
capture_output=True,
text=True,
cwd=repo_root,
)
exe_path = get_exe_result.stdout.strip()
self.assertIn("/nix/store/", exe_path)
self.assertTrue(exe_path.endswith("/bin/albyhub"))
self.assertNotIn("/bin/hub", exe_path)
main_program_result = subprocess.run(
[
"nix",
"eval",
"--raw",
"--impure",
"--expr",
self._patched_albyhub_nix_expr("patchedAlbyHub.meta.mainProgram"),
],
check=True,
capture_output=True,
text=True,
cwd=repo_root,
)
self.assertEqual(main_program_result.stdout.strip(), "albyhub")
def test_private_route_hint_patch_exact_change(self):
repo_root = Path(__file__).resolve().parents[2]
patch_path = repo_root / "packages" / "albyhub" / "0001-private-route-hints.patch"
text = patch_path.read_text()
# v1.23.0 source has RouteHints field between Expiry and Private; the
# patch must include it as context or the hunk will fail to apply.
self.assertIn("RouteHints: hints,", text)
# Old value (context / removed line) and new value (added line).
self.assertIn("Private: !hasPublicChannels", text)
self.assertIn("Private: true", text)
def test_isolated_invoice_appid_patch_contains_all_required_files(self):
repo_root = Path(__file__).resolve().parents[2]
patch_path = repo_root / "packages" / "albyhub" / "0002-isolated-invoice-app-id.patch"
text = patch_path.read_text()
self.assertIn("diff --git a/api/models.go b/api/models.go", text)
self.assertIn("diff --git a/api/transactions.go b/api/transactions.go", text)
self.assertIn("diff --git a/http/http_service.go b/http/http_service.go", text)
self.assertIn("diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go", text)
# v1.23.0 AppId field in MakeInvoiceRequest
self.assertIn("AppId *uint", text)
self.assertIn('json:"appId"', text)
# v1.23.0 uses amountMsat (not amount) in the CreateInvoice signature
self.assertIn(
"CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint)",
text,
)
# MakeInvoice call must pass appId as 8th positional argument (not nil)
self.assertIn("MakeInvoice(ctx, amountMsat, description,", text)
self.assertIn(", appId, nil, nil)", text)
def test_nwc_lnurl_service_runs_as_albyhub(self):
"""nwc-lnurl.service must run as albyhub to read /var/lib/albyhub/unlock-password."""
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "modules" / "nwc-wallets.nix"
text = module_path.read_text()
# Locate the nwc-lnurl service block
service_idx = text.find("systemd.services.nwc-lnurl")
self.assertNotEqual(service_idx, -1, "nwc-lnurl service declaration not found")
service_section = text[service_idx:]
self.assertIn('User = "albyhub"', service_section)
self.assertIn('Group = "albyhub"', service_section)
def test_nwc_module_no_separate_nwc_lnurl_user(self):
"""No standalone nwc-lnurl user or group should exist; albyhub identity is reused."""
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "modules" / "nwc-wallets.nix"
text = module_path.read_text()
self.assertNotIn("users.users.nwc-lnurl", text)
self.assertNotIn("users.groups.nwc-lnurl", text)
# ── Server API integration tests ─────────────────────────────────
class _FakeJSONResponse:
def __init__(self, content=None, status_code=200):
self.content = content
self.status_code = status_code
self.body = json.dumps(content or {}).encode("utf-8")
class ServerApiTests(unittest.IsolatedAsyncioTestCase):
"""Thin tests ensuring the server API routes call the manager correctly."""
def _mock_manager(self, wallets=None, create_result=None, domain=None):
m = MagicMock()
m.list_wallets.return_value = wallets or []
if create_result:
m.create_wallet.return_value = create_result
return m
async def test_api_nwc_wallets_returns_wallets(self):
fake_manager = self._mock_manager(
wallets=[{"id": "1", "name": "W", "alias": "w"}]
)
with (
patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager),
patch.object(server, "_nwc_domain", return_value="pay.example.com"),
):
result = await server.api_nwc_wallets()
self.assertEqual(len(result["wallets"]), 1)
async def test_api_create_returns_pairing_uri(self):
pairing_uri = "nostr+walletconnect://pk?relay=r&secret=S"
fake_manager = self._mock_manager(
create_result={
"wallet": {"id": "1", "alias": "new"},
"pairing_uri": pairing_uri,
"result": {"wallet_created": True, "secret_created": True, "lightning_address_registered": True, "funding": {"attempted": False}},
}
)
req = types.SimpleNamespace(
name="New Wallet",
alias="newwallet",
access_preset="receive_only",
spending_limit_sats=None,
)
with (
patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager),
patch.object(server, "_nwc_domain", return_value="pay.example.com"),
patch.object(server, "_nwc_test_address", return_value={"ok": True}),
patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"),
patch.object(server, "JSONResponse", _FakeJSONResponse),
):
resp = await server.api_nwc_create_wallet(req)
body = json.loads(resp.body.decode("utf-8"))
self.assertEqual(body["pairing_uri"], pairing_uri)
self.assertEqual(resp.status_code, 201)
async def test_api_create_pairing_qrcode_in_response(self):
pairing_uri = "nostr+walletconnect://pk?relay=r&secret=S"
fake_manager = self._mock_manager(
create_result={
"wallet": {"id": "1", "alias": "new"},
"pairing_uri": pairing_uri,
"result": {"wallet_created": True, "secret_created": True, "lightning_address_registered": True, "funding": {"attempted": False}},
}
)
req = types.SimpleNamespace(
name="QR Wallet", alias="qrwallet", access_preset="receive_only",
spending_limit_sats=None,
)
with (
patch.object(server._nwc_mgr, "get_manager", return_value=fake_manager),
patch.object(server, "_nwc_domain", return_value="pay.example.com"),
patch.object(server, "_nwc_test_address", return_value={"ok": False}),
patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,qrdata"),
patch.object(server, "JSONResponse", _FakeJSONResponse),
):
resp = await server.api_nwc_create_wallet(req)
body = json.loads(resp.body.decode("utf-8"))
self.assertEqual(body.get("pairing_qrcode"), "data:image/png;base64,qrdata")
async def test_api_create_rejects_invalid_alias(self):
req = types.SimpleNamespace(
name="Bad", alias="_INVALID", access_preset="receive_only",
spending_limit_sats=None,
)
with patch.object(server, "JSONResponse", _FakeJSONResponse):
resp = await server.api_nwc_create_wallet(req)
body = json.loads(resp.body.decode("utf-8"))
self.assertEqual(resp.status_code, 400)
self.assertEqual(body["error"], "alias_invalid")
if __name__ == "__main__":
unittest.main()