diff --git a/app/tests/test_bip110_status.py b/app/tests/test_bip110_status.py deleted file mode 100644 index fafec4f..0000000 --- a/app/tests/test_bip110_status.py +++ /dev/null @@ -1,166 +0,0 @@ -import unittest -from unittest.mock import patch -from pathlib import Path -import sys -import types - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - - -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, *args, **kwargs): - pass - - def mount(self, *args, **kwargs): - return None - - def add_middleware(self, *args, **kwargs): - return None - - def __getattr__(self, _name): - def _decorator_factory(*args, **kwargs): - def _decorator(func): - return func - - return _decorator - - return _decorator_factory - - class _BaseModel: - pass - - class _StaticFiles: - def __init__(self, *args, **kwargs): - pass - - class _Jinja2Templates: - def __init__(self, *args, **kwargs): - pass - - class _BaseHTTPMiddleware: - pass - - fastapi_module = types.ModuleType("fastapi") - fastapi_module.FastAPI = _FastAPI - fastapi_module.HTTPException = _HTTPException - sys.modules["fastapi"] = fastapi_module - - responses_module = types.ModuleType("fastapi.responses") - responses_module.HTMLResponse = object - responses_module.JSONResponse = object - responses_module.RedirectResponse = object - sys.modules["fastapi.responses"] = responses_module - - staticfiles_module = types.ModuleType("fastapi.staticfiles") - staticfiles_module.StaticFiles = _StaticFiles - sys.modules["fastapi.staticfiles"] = staticfiles_module - - templating_module = types.ModuleType("fastapi.templating") - templating_module.Jinja2Templates = _Jinja2Templates - sys.modules["fastapi.templating"] = templating_module - - requests_module = types.ModuleType("fastapi.requests") - requests_module.Request = object - sys.modules["fastapi.requests"] = requests_module - - pydantic_module = types.ModuleType("pydantic") - pydantic_module.BaseModel = _BaseModel - sys.modules["pydantic"] = pydantic_module - - starlette_base_module = types.ModuleType("starlette.middleware.base") - starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware - sys.modules["starlette.middleware.base"] = starlette_base_module - - starlette_middleware_module = types.ModuleType("starlette.middleware") - starlette_middleware_module.base = starlette_base_module - sys.modules["starlette.middleware"] = starlette_middleware_module - - starlette_module = types.ModuleType("starlette") - starlette_module.middleware = starlette_middleware_module - sys.modules["starlette"] = starlette_module - - -_install_web_stubs() -from sovran_systemsos_web import server - - -class Bip110StatusTests(unittest.TestCase): - def _status(self, deploy_info, net_info): - with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object( - server, "_get_bitcoin_version_info", return_value=net_info - ): - return server._get_bip110_status() - - def test_started_reduced_data_reports_signaling(self): - deploy_info = { - "deployments": { - "reduced_data": { - "type": "bip9", - "active": False, - "bip9": { - "bit": 4, - "status": "started", - "statistics": {"elapsed": 833, "count": 4, "threshold": 1109}, - "signalling": "--#--", - }, - } - } - } - - result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) - self.assertEqual( - result, - {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}, - ) - - def test_active_reduced_data_reports_active(self): - deploy_info = { - "deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}} - } - - result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) - self.assertEqual(result["state"], "active") - self.assertTrue(result["supported"]) - self.assertTrue(result["signaling"]) - self.assertEqual(result["source"], "getdeploymentinfo") - - def test_locked_in_reduced_data_reports_locked_in(self): - deploy_info = { - "deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}} - } - - result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) - self.assertEqual(result["state"], "locked_in") - self.assertTrue(result["supported"]) - self.assertTrue(result["signaling"]) - self.assertEqual(result["source"], "getdeploymentinfo") - - def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self): - deploy_info = { - "deployments": { - "taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}}, - } - } - result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"}) - self.assertEqual( - result, - {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"}, - ) - - def test_node_unreachable_reports_unknown(self): - result = self._status(None, None) - self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_domain_conflict.py b/app/tests/test_domain_conflict.py deleted file mode 100644 index 01605bc..0000000 --- a/app/tests/test_domain_conflict.py +++ /dev/null @@ -1,509 +0,0 @@ -""" -Domain conflict and unique-hostname tests. - -Tests cover: -1. Wallet Connections initial setup displays unique-hostname guidance. -2. Wallet Connections reconfiguration displays the same guidance. -3. The field example is lightning.yourdomain.com for Wallet Connections. -4. An unused hostname such as lightning.example.com is accepted. -5. Reusing a hostname assigned to Matrix, Nextcloud, WordPress, BTCPay Server, - Vaultwarden, Haven, or Element Calling returns HTTP 409. -6. Comparison is case-insensitive. -7. A hostname with one trailing dot conflicts with the equivalent hostname without it. -8. Re-saving the existing lightning hostname for lightning remains allowed. -9. Invalid hostnames are rejected before mutation. -10. On conflict, the domain file remains unchanged. -11. On conflict, the DDNS script remains unchanged and is not executed. -12. Generic domain flows for unrelated services remain intact (matrix -> matrix, etc.). -13. JavaScript syntax checks pass for features.js and helpers.js. -""" - -import json -import os -import subprocess -import sys -import tempfile -import types -import unittest -from pathlib import Path -from unittest.mock import 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 # noqa: E402 - - -# ── Helpers ─────────────────────────────────────────────────────── - -def _make_req(domain_name, domain, ddns_url=""): - """Build a DomainSetRequest-like object using the server's model.""" - req = object.__new__(server.DomainSetRequest) - req.domain_name = domain_name - req.domain = domain - req.ddns_url = ddns_url - return req - - -def _write_domain_file(domains_dir, key, value): - path = os.path.join(domains_dir, key) - with open(path, "w") as fh: - fh.write(value) - - -# ── Unit tests for server helper functions ───────────────────────── - -class NormalizeHostnameTests(unittest.TestCase): - def test_trims_whitespace(self): - self.assertEqual(server._normalize_hostname(" foo.example.com "), "foo.example.com") - - def test_lowercases(self): - self.assertEqual(server._normalize_hostname("FOO.Example.COM"), "foo.example.com") - - def test_removes_exactly_one_trailing_dot(self): - self.assertEqual(server._normalize_hostname("foo.example.com."), "foo.example.com") - - def test_does_not_remove_two_trailing_dots(self): - # Only one trailing dot is removed; two trailing dots leave one. - self.assertEqual(server._normalize_hostname("foo.example.com.."), "foo.example.com.") - - def test_no_trailing_dot_unchanged(self): - self.assertEqual(server._normalize_hostname("foo.example.com"), "foo.example.com") - - def test_strips_and_lowercases_with_trailing_dot(self): - self.assertEqual(server._normalize_hostname(" Lightning.Example.COM. "), "lightning.example.com") - - -class ValidateHostnameTests(unittest.TestCase): - def test_valid_simple_domain(self): - self.assertTrue(server._validate_hostname("foo.example.com")) - - def test_valid_subdomain(self): - self.assertTrue(server._validate_hostname("lightning.yourdomain.com")) - - def test_valid_bare_hostname(self): - self.assertTrue(server._validate_hostname("example")) - - def test_valid_with_hyphens(self): - self.assertTrue(server._validate_hostname("my-host.example.com")) - - def test_invalid_empty(self): - self.assertFalse(server._validate_hostname("")) - - def test_invalid_trailing_dot(self): - # The hostname validator rejects trailing dots directly. - # In the API flow, normalization removes exactly one trailing dot before - # this validator is called, so a single trailing dot in user input is - # handled before reaching validation. - self.assertFalse(server._validate_hostname("foo.example.com.")) - - def test_invalid_with_underscore(self): - self.assertFalse(server._validate_hostname("foo_bar.example.com")) - - def test_invalid_leading_hyphen(self): - self.assertFalse(server._validate_hostname("-foo.example.com")) - - def test_invalid_spaces(self): - self.assertFalse(server._validate_hostname("foo example.com")) - - -class CheckDomainConflictTests(unittest.TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self._orig_domains_dir = server.DOMAINS_DIR - server.DOMAINS_DIR = self.tmpdir - - def tearDown(self): - server.DOMAINS_DIR = self._orig_domains_dir - import shutil - shutil.rmtree(self.tmpdir, ignore_errors=True) - - def test_no_conflict_when_no_other_files(self): - result = server._check_domain_conflict("lightning", "lightning.example.com") - self.assertIsNone(result) - - def test_conflict_when_matrix_has_same_hostname(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "matrix") - - def test_conflict_when_nextcloud_has_same_hostname(self): - _write_domain_file(self.tmpdir, "nextcloud", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "nextcloud") - - def test_conflict_when_wordpress_has_same_hostname(self): - _write_domain_file(self.tmpdir, "wordpress", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "wordpress") - - def test_conflict_when_btcpayserver_has_same_hostname(self): - _write_domain_file(self.tmpdir, "btcpayserver", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "btcpayserver") - - def test_conflict_when_vaultwarden_has_same_hostname(self): - _write_domain_file(self.tmpdir, "vaultwarden", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "vaultwarden") - - def test_conflict_when_haven_has_same_hostname(self): - _write_domain_file(self.tmpdir, "haven", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "haven") - - def test_conflict_when_element_calling_has_same_hostname(self): - _write_domain_file(self.tmpdir, "element-calling", "shared.example.com") - result = server._check_domain_conflict("lightning", "shared.example.com") - self.assertEqual(result, "element-calling") - - def test_no_conflict_for_self(self): - # Re-saving lightning's own existing hostname must be allowed. - _write_domain_file(self.tmpdir, "lightning", "lightning.example.com") - result = server._check_domain_conflict("lightning", "lightning.example.com") - self.assertIsNone(result) - - def test_symmetric_conflict_matrix_reusing_lightning(self): - # Saving matrix with lightning's existing hostname is also rejected. - _write_domain_file(self.tmpdir, "lightning", "shared.example.com") - result = server._check_domain_conflict("matrix", "shared.example.com") - self.assertEqual(result, "lightning") - - def test_no_conflict_unrelated_services_without_lightning(self): - # Two unrelated non-lightning services with the same hostname. - # The rule only applies when lightning is involved. - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - result = server._check_domain_conflict("nextcloud", "shared.example.com") - self.assertIsNone(result) - - -# ── API endpoint integration-style tests ────────────────────────── - -import asyncio - - -def _run(coro): - """Run an async coroutine synchronously.""" - return asyncio.get_event_loop().run_until_complete(coro) - - -class ApiDomainsSetConflictTests(unittest.TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.njalla_dir = tempfile.mkdtemp() - self.njalla_script = os.path.join(self.njalla_dir, "njalla.sh") - self._orig_domains_dir = server.DOMAINS_DIR - self._orig_njalla = server.NJALLA_SCRIPT - server.DOMAINS_DIR = self.tmpdir - server.NJALLA_SCRIPT = self.njalla_script - - def tearDown(self): - server.DOMAINS_DIR = self._orig_domains_dir - server.NJALLA_SCRIPT = self._orig_njalla - import shutil - shutil.rmtree(self.tmpdir, ignore_errors=True) - shutil.rmtree(self.njalla_dir, ignore_errors=True) - - # ── Test 4: unused hostname is accepted ────────────────────────── - def test_unused_hostname_is_accepted(self): - with patch.object(server, "_trigger_hosts_update"): - result = _run(server.api_domains_set(_make_req("lightning", "lightning.example.com"))) - self.assertEqual(result.get("ok"), True) - # Domain file should be written. - with open(os.path.join(self.tmpdir, "lightning")) as fh: - saved = fh.read() - self.assertEqual(saved, "lightning.example.com") - - # ── Test 5: conflict with each managed service returns 409 ──────── - def _assert_conflict_409(self, target_key, conflicting_key, hostname): - _write_domain_file(self.tmpdir, conflicting_key, hostname) - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req(target_key, hostname))) - exc = ctx.exception - self.assertEqual(getattr(exc, "status_code", None), 409) - detail = getattr(exc, "detail", {}) - self.assertEqual(detail.get("error"), "domain_conflict") - self.assertEqual(detail.get("conflicting_domain_key"), conflicting_key) - self.assertIn("message", detail) - - def test_conflict_with_matrix_returns_409(self): - self._assert_conflict_409("lightning", "matrix", "shared.example.com") - - def test_conflict_with_nextcloud_returns_409(self): - self._assert_conflict_409("lightning", "nextcloud", "shared.example.com") - - def test_conflict_with_wordpress_returns_409(self): - self._assert_conflict_409("lightning", "wordpress", "shared.example.com") - - def test_conflict_with_btcpayserver_returns_409(self): - self._assert_conflict_409("lightning", "btcpayserver", "shared.example.com") - - def test_conflict_with_vaultwarden_returns_409(self): - self._assert_conflict_409("lightning", "vaultwarden", "shared.example.com") - - def test_conflict_with_haven_returns_409(self): - self._assert_conflict_409("lightning", "haven", "shared.example.com") - - def test_conflict_with_element_calling_returns_409(self): - self._assert_conflict_409("lightning", "element-calling", "shared.example.com") - - # ── Test 6: comparison is case-insensitive ─────────────────────── - def test_conflict_is_case_insensitive(self): - _write_domain_file(self.tmpdir, "matrix", "Shared.Example.COM") - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "shared.example.com"))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 409) - - def test_conflict_case_insensitive_reversed(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "SHARED.EXAMPLE.COM"))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 409) - - # ── Test 7: trailing-dot normalization causes conflict ──────────── - def test_trailing_dot_conflicts_with_same_hostname(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "shared.example.com."))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 409) - - def test_stored_trailing_dot_conflicts_with_clean_submission(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com.") - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "shared.example.com"))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 409) - - # ── Test 8: re-saving existing lightning hostname is allowed ────── - def test_resave_existing_lightning_hostname_allowed(self): - _write_domain_file(self.tmpdir, "lightning", "lightning.example.com") - with patch.object(server, "_trigger_hosts_update"): - result = _run(server.api_domains_set(_make_req("lightning", "lightning.example.com"))) - self.assertEqual(result.get("ok"), True) - - # ── Test 9: invalid hostname is rejected before mutation ────────── - def test_invalid_hostname_rejected(self): - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "not a hostname!"))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 400) - - def test_hostname_with_underscore_rejected(self): - with self.assertRaises(Exception) as ctx: - _run(server.api_domains_set(_make_req("lightning", "foo_bar.example.com"))) - self.assertEqual(getattr(ctx.exception, "status_code", None), 400) - - # ── Test 10: on conflict, domain file remains unchanged ─────────── - def test_domain_file_unchanged_on_conflict(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - lightning_path = os.path.join(self.tmpdir, "lightning") - # Write a prior value. - _write_domain_file(self.tmpdir, "lightning", "old.example.com") - with self.assertRaises(Exception): - _run(server.api_domains_set(_make_req("lightning", "shared.example.com"))) - # The lightning domain file must still contain the old value. - with open(lightning_path) as fh: - saved = fh.read() - self.assertEqual(saved, "old.example.com") - - def test_domain_file_not_created_on_conflict_if_absent(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - lightning_path = os.path.join(self.tmpdir, "lightning") - self.assertFalse(os.path.exists(lightning_path)) - with self.assertRaises(Exception): - _run(server.api_domains_set(_make_req("lightning", "shared.example.com"))) - self.assertFalse(os.path.exists(lightning_path)) - - # ── Test 11: on conflict, DDNS script unchanged and not executed ── - def test_njalla_script_not_written_on_conflict(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - with self.assertRaises(Exception): - _run(server.api_domains_set( - _make_req("lightning", "shared.example.com", - ddns_url='curl "https://njal.la/update/?h=shared.example.com&k=key&auto"') - )) - self.assertFalse(os.path.exists(self.njalla_script)) - - def test_njalla_script_not_executed_on_conflict(self): - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - with patch("subprocess.run") as mock_run, self.assertRaises(Exception): - _run(server.api_domains_set( - _make_req("lightning", "shared.example.com", - ddns_url='curl "https://njal.la/update/?h=shared.example.com&k=key&auto"') - )) - mock_run.assert_not_called() - - # ── Test 12: generic domain flows for unrelated services intact ─── - def test_matrix_save_without_lightning_succeeds(self): - with patch.object(server, "_trigger_hosts_update"): - result = _run(server.api_domains_set(_make_req("matrix", "matrix.example.com"))) - self.assertEqual(result.get("ok"), True) - with open(os.path.join(self.tmpdir, "matrix")) as fh: - saved = fh.read() - self.assertEqual(saved, "matrix.example.com") - - def test_nextcloud_save_without_lightning_succeeds(self): - with patch.object(server, "_trigger_hosts_update"): - result = _run(server.api_domains_set(_make_req("nextcloud", "cloud.example.com"))) - self.assertEqual(result.get("ok"), True) - - def test_two_non_lightning_services_sharing_hostname_allowed(self): - # The uniqueness rule is only enforced when lightning is involved. - _write_domain_file(self.tmpdir, "matrix", "shared.example.com") - with patch.object(server, "_trigger_hosts_update"): - result = _run(server.api_domains_set(_make_req("nextcloud", "shared.example.com"))) - self.assertEqual(result.get("ok"), True) - - -# ── Test 13: JavaScript syntax checks ───────────────────────────── - -class JsSyntaxTests(unittest.TestCase): - def _js_files(self): - static_js = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "static" / "js" - return sorted(static_js.glob("*.js")) - - def test_js_syntax_no_errors(self): - for js_file in self._js_files(): - with self.subTest(file=js_file.name): - result = subprocess.run( - ["node", "--check", str(js_file)], - capture_output=True, text=True - ) - self.assertEqual( - result.returncode, 0, - msg=f"Syntax error in {js_file.name}:\n{result.stderr}" - ) - - -# ── UI guidance content tests (features.js) ─────────────────────── - -class FeaturesJsContentTests(unittest.TestCase): - """Verify the features.js source contains the required Wallet Connections guidance.""" - - def setUp(self): - self.features_js = ( - Path(__file__).resolve().parents[1] - / "sovran_systemsos_web" / "static" / "js" / "features.js" - ).read_text(encoding="utf-8") - - # ── Test 1: initial setup displays unique-hostname guidance ─────── - def test_setup_modal_contains_nwc_warning(self): - self.assertIn("Wallet Connections requires its own unique hostname", self.features_js) - - # ── Test 2: reconfiguration displays the same guidance ──────────── - def test_reconfig_modal_contains_nwc_warning(self): - # The warning text must appear in both openDomainSetupModal and - # openDomainReconfigureModal — two occurrences minimum. - count = self.features_js.count("Wallet Connections requires its own unique hostname") - self.assertGreaterEqual(count, 2, "Warning must appear in both setup and reconfigure modals") - - # ── Test 3: field example is lightning.yourdomain.com ──────────── - def test_lightning_placeholder_is_present(self): - self.assertIn("lightning.yourdomain.com", self.features_js) - - # ── Warning references correct services ─────────────────────────── - def test_warning_mentions_matrix(self): - self.assertIn("Matrix", self.features_js) - - def test_warning_mentions_nextcloud(self): - self.assertIn("Nextcloud", self.features_js) - - def test_warning_mentions_btcpay_server(self): - self.assertIn("BTCPay Server", self.features_js) - - def test_warning_mentions_vaultwarden(self): - self.assertIn("Vaultwarden", self.features_js) - - def test_warning_mentions_haven(self): - self.assertIn("Haven", self.features_js) - - def test_warning_mentions_wordpress(self): - self.assertIn("WordPress", self.features_js) - - # ── isWalletConnections guard targets correct identifiers ───────── - def test_nwc_wallets_id_check_present(self): - self.assertIn('feat.id === "nwc-wallets"', self.features_js) - - def test_lightning_domain_name_check_present(self): - self.assertIn('feat.domain_name === "lightning"', self.features_js) - - # ── Error surfacing ─────────────────────────────────────────────── - def test_error_message_surfaced_in_setup_modal(self): - # The catch block must use err.message rather than hard-coded string. - self.assertIn("err.message", self.features_js) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_hub_update_boot_staging.py b/app/tests/test_hub_update_boot_staging.py deleted file mode 100644 index df92a81..0000000 --- a/app/tests/test_hub_update_boot_staging.py +++ /dev/null @@ -1,44 +0,0 @@ -import unittest -from pathlib import Path - - -HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix" - - -def _section(source: str, start: str, end: str) -> str: - start_idx = source.find(start) - if start_idx == -1: - raise AssertionError(f"Expected section start not found: {start!r}") - end_idx = source.find(end, start_idx) - if end_idx == -1: - raise AssertionError(f"Expected section end not found: {end!r}") - return source[start_idx:end_idx] - - -class HubUpdateBootStagingTests(unittest.TestCase): - def setUp(self): - self.source = HUB_NIX.read_text() - self.update_section = _section( - self.source, - 'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'', - "# ── Rebuild wrapper script", - ) - self.rebuild_section = _section( - self.source, - 'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'', - "# ── Brave launcher wrapper", - ) - - def test_full_update_uses_boot_not_switch(self): - self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section) - self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section) - - def test_full_update_marks_reboot_required(self): - self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section) - - def test_rebuild_path_keeps_switch_semantics(self): - self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_local_domain_loopback_nix.py b/app/tests/test_local_domain_loopback_nix.py deleted file mode 100644 index 72d2a12..0000000 --- a/app/tests/test_local_domain_loopback_nix.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Structural regression tests for modules/core/local-domain-loopback.nix. - -Verifies that the sovran-hosts-update helper: - - is built as a pkgs.writeShellApplication with explicit runtimeInputs - (gawk, gnugrep, coreutils); - - uses ``lib.getExe hostsUpdateScript`` for both the systemd ExecStart and - the activation script so that both contexts share the same Nix-store - executable; - - does NOT point ExecStart at the raw /etc path; - - includes element-calling in the supported domain list; - - uses ``awk -v`` for safe marker-variable passing rather than interpolating - marker text directly into the awk program; - - retains the domain validation regex (idempotency / injection prevention); - - exposes /etc/sovran-hosts-update.sh as a symlink via ``source =`` (not a - second raw ``text =`` body); - - does NOT rely on environment.systemPackages for the helper's dependencies. -""" - -import re -import shutil -import subprocess -import unittest -from pathlib import Path - -NIX_STRING_INDENT = 6 -REPO_ROOT = Path(__file__).resolve().parents[2] -FLAKE_SOURCE = (REPO_ROOT / "flake.nix").read_text() -PRIMARY_NIXOS_CONFIGURATION_MATCH = re.search( - r"nixosConfigurations\.([A-Za-z0-9_-]+)\s*=", - FLAKE_SOURCE, -) -if PRIMARY_NIXOS_CONFIGURATION_MATCH is None: - raise RuntimeError("Could not determine the primary nixosConfigurations entry from flake.nix") -PRIMARY_NIXOS_CONFIGURATION = PRIMARY_NIXOS_CONFIGURATION_MATCH.group(1) -HELPER_BUILD_ATTR = ( - f'.#nixosConfigurations.{PRIMARY_NIXOS_CONFIGURATION}.config.environment.etc.' - '"sovran-hosts-update.sh".source' -) -NIX_FILE = ( - REPO_ROOT - / "modules" - / "core" - / "local-domain-loopback.nix" -) - - -class LocalDomainLoopbackNixStructureTests(unittest.TestCase): - def setUp(self): - self.source = NIX_FILE.read_text() - - def _helper_script(self) -> str: - start = self.source.index("text = ''") + len("text = ''") - end = self.source.index(" '';", start) - return "\n".join( - line[NIX_STRING_INDENT:] - if line.startswith(" " * NIX_STRING_INDENT) - else line - for line in self.source[start:end].splitlines() - ).lstrip("\n") - - # ── writeShellApplication and explicit runtimeInputs ──────────────────── - - def test_uses_write_shell_application(self): - self.assertIn("pkgs.writeShellApplication", self.source) - - def test_runtime_inputs_includes_coreutils(self): - self.assertIn("pkgs.coreutils", self.source) - - def test_runtime_inputs_includes_gawk(self): - self.assertIn("pkgs.gawk", self.source) - - def test_runtime_inputs_includes_gnugrep(self): - self.assertIn("pkgs.gnugrep", self.source) - - def test_runtime_inputs_block_present(self): - self.assertIn("runtimeInputs", self.source) - - # ── Both execution paths use lib.getExe ───────────────────────────────── - - def test_exec_start_uses_lib_get_exe(self): - """systemd ExecStart must reference the Nix-store executable.""" - self.assertIn("ExecStart = lib.getExe hostsUpdateScript", self.source) - - def test_activation_script_uses_lib_get_exe(self): - """Activation text must call the same Nix-store executable.""" - self.assertIn("${lib.getExe hostsUpdateScript}", self.source) - - def test_exec_start_does_not_point_to_etc_path(self): - """ExecStart must NOT use the raw /etc path (which lacks a deterministic PATH).""" - self.assertNotIn('ExecStart = "/etc/sovran-hosts-update.sh"', self.source) - - # ── /etc symlink uses source =, not a second text = body ──────────────── - - def test_etc_entry_uses_source_not_text(self): - """The /etc/sovran-hosts-update.sh entry must be a symlink (source =), - not a second raw script body (text =).""" - self.assertIn( - 'environment.etc."sovran-hosts-update.sh".source', self.source - ) - - def test_etc_source_points_to_get_exe(self): - self.assertIn( - 'environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript', - self.source, - ) - - # ── element-calling domain is supported ───────────────────────────────── - - def test_element_calling_domain_key_present(self): - self.assertIn("element-calling", self.source) - - # ── Robust awk -v variable passing ────────────────────────────────────── - - def test_awk_uses_dash_v_for_begin_marker(self): - """awk must receive the begin marker via -v, not by shell interpolation.""" - self.assertIn('awk -v begin=', self.source) - - def test_awk_uses_dash_v_for_end_marker(self): - self.assertIn('-v end=', self.source) - - def test_awk_does_not_interpolate_marker_into_program(self): - """The old pattern interpolated $BEGIN_MARKER directly into the awk source.""" - self.assertNotIn('/$BEGIN_MARKER', self.source) - self.assertNotIn('/$END_MARKER', self.source) - - # ── Domain validation ──────────────────────────────────────────────────── - - def test_domain_validation_regex_present(self): - """The hostname validation regex must still be present for injection prevention.""" - self.assertIn("grep -qE", self.source) - self.assertIn("[a-zA-Z0-9]", self.source) - - def test_invalid_domain_warning_present(self): - self.assertIn("skipping invalid domain value", self.source) - - # ── No environment.systemPackages reliance ─────────────────────────────── - - def test_no_environment_system_packages_for_helper(self): - """The helper's tools are declared via runtimeInputs; the module must - not add them to environment.systemPackages.""" - self.assertNotIn("environment.systemPackages", self.source) - - # ── Idempotency: existing Sovran block is removed before rewriting ─────── - - def test_existing_block_removal_logic_present(self): - """awk strip of the managed block must be present for idempotency.""" - self.assertIn("skip=1", self.source) - self.assertIn("skip=0", self.source) - - def test_managed_block_uses_grouped_append_redirect(self): - self.assertIn('} >> "$TMP"', self.source) - self.assertEqual(self.source.count('>> "$TMP"'), 1) - - def test_helper_script_passes_shellcheck(self): - shellcheck = shutil.which("shellcheck") - if shellcheck is None: - self.skipTest("shellcheck is not installed") - proc = subprocess.run( - [shellcheck, "-s", "bash", "-"], - input=self._helper_script(), - text=True, - capture_output=True, - check=False, - ) - output = proc.stdout + proc.stderr - self.assertEqual(proc.returncode, 0, output) - - def test_helper_derivation_builds_when_nix_available(self): - nix = shutil.which("nix") - if nix is None: - self.skipTest("nix is not installed") - proc = subprocess.run( - [ - nix, - "build", - HELPER_BUILD_ATTR, - "--no-link", - ], - cwd=REPO_ROOT, - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) - - # ── Activation script warns on failure rather than silently swallowing ─── - - def test_activation_script_emits_warning_on_failure(self): - self.assertIn("warning: sovran-hosts-update", self.source) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_loopback_diagnostics.py b/app/tests/test_loopback_diagnostics.py deleted file mode 100644 index a229d90..0000000 --- a/app/tests/test_loopback_diagnostics.py +++ /dev/null @@ -1,399 +0,0 @@ -"""Tests for server-local loopback diagnostics and domain validation. - -Covers: -- Domain value validation and injection prevention. -- Loopback address detection (IPv4 and IPv6). -- _resolve_all_addresses returning multiple addresses. -- _check_domain_health_fast with loopback resolution. -- _evaluate_domain_checklist with loopback override — no false dns_mismatch. -- _evaluate_domain_checklist with genuine DNS mismatch — still reports error. -- api_services health stays "healthy" when domain resolves to loopback. -- api_services health stays "needs_attention" when DNS is genuinely wrong. -- api_domains_check returns "local_override" for loopback-resolved domains. -""" - -import unittest -from pathlib import Path -from unittest.mock import MagicMock, mock_open, patch -import sys -import types - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - - -# --------------------------------------------------------------------------- -# Minimal stubs so server.py can be imported without the full FastAPI stack. -# --------------------------------------------------------------------------- - -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, *args, **kwargs): - pass - - def mount(self, *args, **kwargs): - return None - - def add_middleware(self, *args, **kwargs): - return None - - def __getattr__(self, _name): - def _decorator_factory(*args, **kwargs): - def _decorator(func): - return func - return _decorator - return _decorator_factory - - class _BaseModel: - pass - - class _StaticFiles: - def __init__(self, *args, **kwargs): - pass - - class _Jinja2Templates: - def __init__(self, *args, **kwargs): - pass - - class _BaseHTTPMiddleware: - pass - - fastapi_module = types.ModuleType("fastapi") - fastapi_module.FastAPI = _FastAPI - fastapi_module.HTTPException = _HTTPException - sys.modules["fastapi"] = fastapi_module - - responses_module = types.ModuleType("fastapi.responses") - responses_module.HTMLResponse = object - responses_module.JSONResponse = object - responses_module.RedirectResponse = object - sys.modules["fastapi.responses"] = responses_module - - staticfiles_module = types.ModuleType("fastapi.staticfiles") - staticfiles_module.StaticFiles = _StaticFiles - sys.modules["fastapi.staticfiles"] = staticfiles_module - - templating_module = types.ModuleType("fastapi.templating") - templating_module.Jinja2Templates = _Jinja2Templates - sys.modules["fastapi.templating"] = templating_module - - requests_module = types.ModuleType("fastapi.requests") - requests_module.Request = object - sys.modules["fastapi.requests"] = requests_module - - pydantic_module = types.ModuleType("pydantic") - pydantic_module.BaseModel = _BaseModel - sys.modules["pydantic"] = pydantic_module - - starlette_base_module = types.ModuleType("starlette.middleware.base") - starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware - sys.modules["starlette.middleware.base"] = starlette_base_module - - starlette_middleware_module = types.ModuleType("starlette.middleware") - starlette_middleware_module.base = starlette_base_module - sys.modules["starlette.middleware"] = starlette_middleware_module - - starlette_module = types.ModuleType("starlette") - starlette_module.middleware = starlette_middleware_module - sys.modules["starlette"] = starlette_module - - -_install_web_stubs() -from sovran_systemsos_web import server # noqa: E402 - - -# =========================================================================== -# Domain value validation -# =========================================================================== - -class TestValidateDomainValue(unittest.TestCase): - """_validate_domain_value must reject anything that could corrupt /etc/hosts.""" - - def _v(self, value: str) -> bool: - return server._validate_domain_value(value) - - # -- Valid values -------------------------------------------------------- - - def test_simple_domain_valid(self): - self.assertTrue(self._v("cloud.example.com")) - - def test_subdomain_valid(self): - self.assertTrue(self._v("matrix.home.example.org")) - - def test_single_label_with_tld_valid(self): - self.assertTrue(self._v("example.com")) - - def test_hyphen_in_domain_valid(self): - self.assertTrue(self._v("my-nextcloud.example.com")) - - # -- Injection / malformed values ---------------------------------------- - - def test_empty_string_invalid(self): - self.assertFalse(self._v("")) - - def test_newline_injection_invalid(self): - self.assertFalse(self._v("evil.com\n127.0.0.1 other.host")) - - def test_carriage_return_injection_invalid(self): - self.assertFalse(self._v("evil.com\r127.0.0.1 other.host")) - - def test_space_injection_invalid(self): - self.assertFalse(self._v("evil.com 127.0.0.1")) - - def test_hash_comment_injection_invalid(self): - self.assertFalse(self._v("evil.com# comment")) - - def test_bare_hostname_no_dot_invalid(self): - self.assertFalse(self._v("localhost")) - - def test_bare_ip_invalid(self): - self.assertFalse(self._v("192.168.1.1")) - - def test_too_long_invalid(self): - self.assertFalse(self._v("a" * 254 + ".com")) - - def test_leading_dot_invalid(self): - self.assertFalse(self._v(".example.com")) - - def test_trailing_dot_invalid(self): - self.assertFalse(self._v("example.com.")) - - -# =========================================================================== -# Loopback address detection -# =========================================================================== - -class TestIsLoopbackAddress(unittest.TestCase): - - def test_ipv4_loopback(self): - self.assertTrue(server._is_loopback_address("127.0.0.1")) - - def test_ipv4_loopback_other(self): - self.assertTrue(server._is_loopback_address("127.0.0.2")) - - def test_ipv4_loopback_high(self): - self.assertTrue(server._is_loopback_address("127.255.255.255")) - - def test_ipv6_loopback(self): - self.assertTrue(server._is_loopback_address("::1")) - - def test_public_ipv4_not_loopback(self): - self.assertFalse(server._is_loopback_address("203.0.113.10")) - - def test_private_ipv4_not_loopback(self): - self.assertFalse(server._is_loopback_address("192.168.1.50")) - - def test_ipv6_public_not_loopback(self): - self.assertFalse(server._is_loopback_address("2001:db8::1")) - - def test_invalid_string_not_loopback(self): - self.assertFalse(server._is_loopback_address("not-an-ip")) - - -# =========================================================================== -# _check_domain_health_fast -# =========================================================================== - -class TestCheckDomainHealthFast(unittest.TestCase): - """_check_domain_health_fast returns True when there is an issue, - False when everything looks fine.""" - - def _fast(self, domain, external_ip, resolved_addrs): - with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs): - return server._check_domain_health_fast(domain, external_ip) - - def test_no_domain_no_issue(self): - # None/empty domain: the fast check reports True (handled by checklist). - result = server._check_domain_health_fast(None, "203.0.113.10") - self.assertTrue(result) - - def test_empty_domain_no_issue(self): - result = server._check_domain_health_fast("", "203.0.113.10") - self.assertTrue(result) - - def test_loopback_ipv4_no_issue(self): - """Loopback override must not be flagged as a DNS mismatch.""" - result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"]) - self.assertFalse(result) - - def test_loopback_ipv6_no_issue(self): - result = self._fast("cloud.example.com", "203.0.113.10", ["::1"]) - self.assertFalse(result) - - def test_matches_external_ip_no_issue(self): - result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"]) - self.assertFalse(result) - - def test_mismatch_is_an_issue(self): - result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"]) - self.assertTrue(result) - - def test_unavailable_external_ip_no_issue(self): - result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"]) - self.assertFalse(result) - - def test_multiple_addresses_one_matches_no_issue(self): - """If any resolved address matches external_ip the check should pass.""" - result = self._fast( - "cloud.example.com", "203.0.113.10", - ["198.51.100.1", "203.0.113.10"], - ) - self.assertFalse(result) - - -# =========================================================================== -# _evaluate_domain_checklist — loopback override path -# =========================================================================== - -class TestEvaluateDomainChecklistLoopback(unittest.TestCase): - - def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None): - with ( - patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs), - patch.object(server, "_check_domain_reachable", - return_value=reachable_result or {"reachable": True, "status_code": 200}), - ): - return server._evaluate_domain_checklist(domain, external_ip) - - def test_loopback_dns_step_is_ok_not_error(self): - result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"]) - dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2) - self.assertEqual(dns_step["status"], "ok") - self.assertNotIn("mismatch", dns_step.get("detail", "").lower()) - - def test_loopback_domain_status_is_local_override(self): - result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"]) - self.assertEqual(result["domain_status"]["status"], "local_override") - - def test_loopback_has_no_issues_when_reachable(self): - result = self._eval( - "cloud.example.com", "203.0.113.10", ["127.0.0.1"], - reachable_result={"reachable": True, "status_code": 200}, - ) - self.assertFalse(result["has_issues"]) - - def test_loopback_has_issues_when_caddy_unreachable(self): - """A loopback override with Caddy down should still report an issue.""" - result = self._eval( - "cloud.example.com", "203.0.113.10", ["127.0.0.1"], - reachable_result={"reachable": False, "error": "connection refused"}, - ) - self.assertTrue(result["has_issues"]) - - def test_ipv6_loopback_no_issue(self): - result = self._eval("cloud.example.com", "203.0.113.10", ["::1"]) - self.assertEqual(result["domain_status"]["status"], "local_override") - self.assertFalse(result["has_issues"]) - - def test_genuine_mismatch_still_reports_error(self): - result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"]) - self.assertEqual(result["domain_status"]["status"], "dns_mismatch") - self.assertTrue(result["has_issues"]) - - def test_correct_public_dns_still_reports_ok(self): - result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"]) - self.assertEqual(result["domain_status"]["status"], "connected") - self.assertFalse(result["has_issues"]) - - def test_no_domain_has_issues(self): - result = self._eval(None, "203.0.113.10", []) - self.assertTrue(result["has_issues"]) - - -# =========================================================================== -# api_services — composite health with loopback -# =========================================================================== - -class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase): - - async def _get_health(self, resolved_addrs, cached_reachable): - """Return the health value for a single domain-requiring service.""" - service_cfg = { - "services": [ - {"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"} - ] - } - with ( - patch.object(server, "load_config", return_value=service_cfg), - patch.object(server, "_read_hub_overrides", return_value=({}, None, None)), - patch.object(server.sysctl, "is_active", return_value="active"), - patch.dict(server.SERVICE_DOMAIN_MAP, {"caddy.service": "nextcloud"}, clear=False), - patch("builtins.open", mock_open(read_data="cloud.example.com\n")), - patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs), - patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable), - patch.object(server, "_get_listening_ports", - return_value={"tcp": {80, 443}, "udp": set()}), - patch.object(server, "_get_firewall_allowed_ports", - return_value={"tcp": set(), "udp": set()}), - patch.object(server, "_cached_external_ip", "203.0.113.10"), - ): - results = await server.api_services() - - return results[0]["health"] - - async def test_loopback_and_reachable_is_healthy(self): - """Loopback override + Caddy reachable → healthy, not needs_attention.""" - health = await self._get_health(["127.0.0.1"], cached_reachable=True) - self.assertEqual(health, "healthy") - - async def test_loopback_and_caddy_down_is_needs_attention(self): - """Loopback override + Caddy unreachable → needs_attention (genuine issue).""" - health = await self._get_health(["127.0.0.1"], cached_reachable=False) - self.assertEqual(health, "needs_attention") - - async def test_correct_dns_and_reachable_is_healthy(self): - health = await self._get_health(["203.0.113.10"], cached_reachable=True) - self.assertEqual(health, "healthy") - - async def test_dns_mismatch_is_needs_attention(self): - health = await self._get_health(["198.51.100.1"], cached_reachable=True) - self.assertEqual(health, "needs_attention") - - -# =========================================================================== -# api_domains_check — loopback detection -# =========================================================================== - -class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase): - - async def _check(self, resolved_addrs, external_ip="203.0.113.10"): - with ( - patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs), - patch.object(server, "_cached_external_ip", external_ip), - ): - result = await server.api_domains_check( - MagicMock(domains=["cloud.example.com"]) - ) - return result["domains"][0] - - async def test_loopback_ipv4_returns_local_override(self): - result = await self._check(["127.0.0.1"]) - self.assertEqual(result["status"], "local_override") - - async def test_loopback_ipv6_returns_local_override(self): - result = await self._check(["::1"]) - self.assertEqual(result["status"], "local_override") - - async def test_correct_dns_returns_connected(self): - result = await self._check(["203.0.113.10"]) - self.assertEqual(result["status"], "connected") - - async def test_mismatch_returns_dns_mismatch(self): - result = await self._check(["198.51.100.1"]) - self.assertEqual(result["status"], "dns_mismatch") - - async def test_no_resolution_returns_unresolvable(self): - result = await self._check([]) - self.assertEqual(result["status"], "unresolvable") - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_manual_backup_workflow.py b/app/tests/test_manual_backup_workflow.py deleted file mode 100644 index 5fe4dfb..0000000 --- a/app/tests/test_manual_backup_workflow.py +++ /dev/null @@ -1,1390 +0,0 @@ -import asyncio -import os -import re -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -BACKUP_SCRIPT = REPO_ROOT / "app" / "sovran_systemsos_web" / "scripts" / "sovran-hub-backup.sh" -SERVER_FILE = REPO_ROOT / "app" / "sovran_systemsos_web" / "server.py" -SUPPORT_JS = REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js" / "support.js" -NIX_HUB_FILE = REPO_ROOT / "modules" / "core" / "sovran-hub.nix" - - -class ManualBackupWorkflowTests(unittest.TestCase): - # ── Core design: rsync, no tar, no DB, no LND ───────────────────────────── - - def test_backup_script_uses_rsync_not_tar(self): - """New design: backup must use rsync, not tar archives.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("rsync", source, "backup script must use rsync") - self.assertIn("--archive", source, "rsync must be called with --archive flag") - self.assertNotIn("tar --create", source, "backup script must not create tar archives") - self.assertNotIn("--file ", source, "backup script must not write tar --file output") - - def test_backup_script_has_no_database_dump_functions(self): - """Removed: PostgreSQL and MariaDB dump functions must not exist.""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn("pg_dump", source, "backup script must not contain pg_dump") - self.assertNotIn("pg_dumpall", source, "backup script must not contain pg_dumpall") - self.assertNotIn("mariadb-dump", source, "backup script must not contain mariadb-dump") - self.assertNotIn("mysqldump", source, "backup script must not contain mysqldump") - self.assertNotIn("export_postgresql_dumps", source) - self.assertNotIn("export_mariadb_dumps", source) - - def test_backup_script_has_no_lnd_service_orchestration(self): - """Removed: LND stop/restart and SCB export must not exist.""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn("export_lnd_scb_if_possible", source) - self.assertNotIn("stop_lnd_stack_if_needed", source) - self.assertNotIn("lncli", source, "backup script must not call lncli") - self.assertNotIn("LND_STOPPED", source, "backup script must not track LND_STOPPED state") - self.assertNotIn("LND_UNITS_TO_RESTART", source) - - def test_backup_script_has_no_tar_dependencies(self): - """Removed: sha256sum checksums for tar artifacts must not exist.""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn("sha256sum", source, "backup script must not generate tar checksums") - self.assertNotIn("SHA256SUMS", source, "backup script must not write SHA256SUMS.txt") - - # ── Rsync options ────────────────────────────────────────────────────────── - - def test_backup_script_rsync_uses_metadata_preserving_options(self): - """Rsync must be called with Linux metadata-preserving options.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("--archive", source) - self.assertIn("--acls", source) - self.assertIn("--xattrs", source) - self.assertIn("--hard-links", source) - self.assertIn("--numeric-ids", source) - self.assertIn("--one-file-system", source) - - def test_backup_script_rsync_no_delete(self): - """Rsync must NOT use --delete or --delete-delay. - Accidental source deletion must not silently wipe the backup copy.""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn("--delete", source, - "rsync must not use --delete; accidental source deletion must not erase backup") - - def test_backup_script_uses_stable_current_mirror_path(self): - """Backup must write to a stable 'current/' path, not timestamped directories. - Later runs should update the same mirror.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("Sovran_SystemsOS_Backup/current", source, - "backup must use a stable 'current' mirror path") - self.assertIn("BACKUP_SUBPATH", source, - "stable sub-path must be defined in BACKUP_SUBPATH variable") - # Must not create new timestamped directories per run - self.assertNotIn( - "date '+%Y%m%d_%H%M%S'", - source, - "backup must not create timestamped per-run directories", - ) - - def test_backup_script_source_destination_mapping(self): - """Each source tree must be synced to a matching destination path.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("/etc/nixos/", source) - self.assertIn('"$BACKUP_DIR/etc/nixos/"', source) - self.assertIn("/home/", source) - self.assertIn('"$BACKUP_DIR/home/"', source) - self.assertIn("/var/lib/", source) - self.assertIn('"$BACKUP_DIR/var/lib/"', source) - - # ── ext4 filesystem validation ───────────────────────────────────────────── - - def test_backup_script_requires_ext4_filesystem(self): - """Backup script must reject non-ext4 filesystems and require ext4.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - 'fstype" != "ext4"', - source, - "backup script must check for ext4 filesystem", - ) - self.assertIn("ext4", source, "backup script must reference ext4") - self.assertNotIn( - '"exfat"', - source, - "backup script must not accept exFAT (old requirement)", - ) - self.assertNotIn( - '"fuseblk"', - source, - "backup script must not accept fuseblk/NTFS", - ) - - def test_backup_script_rejects_unsupported_filesystems_in_error_message(self): - """The ext4 rejection message must mention the unsupported filesystem types.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - "exFAT, FAT32, and NTFS are not supported", - source, - "error message must clearly list unsupported filesystem types", - ) - - def test_backend_accepts_ext4_rejects_exfat(self): - """Backend _is_supported_backup_fstype must accept ext4 and reject exFAT.""" - server_source = SERVER_FILE.read_text() - # Must accept ext4 - self.assertIn( - 'return fstype == "ext4"', - server_source, - "backend must accept only ext4", - ) - # Must not accept exFAT - self.assertNotIn( - 'fstype == "exfat"', - server_source, - "backend must not accept exFAT", - ) - self.assertNotIn( - 'fuseblk', - server_source.split("_is_supported_backup_fstype")[1][:500], - "backend must not accept fuseblk", - ) - - def test_frontend_requires_ext4_not_exfat(self): - """Frontend UI copy must require ext4 and not mention exFAT as the requirement.""" - support_source = SUPPORT_JS.read_text() - self.assertIn( - "ext4", - support_source, - "support.js must mention ext4 as the required filesystem", - ) - # The word exFAT must not appear as a requirement (it may appear in a - # note about unsupported formats, but the requirement itself must say ext4) - requirements_section = re.search( - r"Requirements.*?What gets backed up", - support_source, - re.DOTALL, - ) - if requirements_section: - req_text = requirements_section.group(0) - self.assertNotIn( - "Drive must be formatted as exFAT", - req_text, - "Requirements section must not say 'formatted as exFAT'", - ) - self.assertIn( - "ext4", - req_text, - "Requirements section must say ext4", - ) - - def test_frontend_failure_message_says_ext4(self): - """Failure message in renderBackupDone must say ext4, not exFAT.""" - support_source = SUPPORT_JS.read_text() - self.assertNotIn( - "formatted as exFAT", - support_source, - "failure message must not say 'formatted as exFAT'", - ) - self.assertIn( - "formatted as ext4", - support_source, - "failure message must say 'formatted as ext4'", - ) - - # ── Database exclusions ──────────────────────────────────────────────────── - - def test_backup_script_excludes_postgresql_and_mariadb(self): - """PostgreSQL and MariaDB raw database directories must be excluded.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("--exclude='postgresql/'", source, - "rsync must exclude postgresql directory") - self.assertIn("--exclude='mysql/'", source, - "rsync must exclude mysql directory") - self.assertIn("--exclude='mariadb/'", source, - "rsync must exclude mariadb directory") - - def test_backup_script_excludes_bitcoin_and_electrs(self): - """Bitcoin blockchain and Electrs index data must be excluded.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("--exclude='bitcoind/'", source, - "rsync must exclude bitcoind directory") - self.assertIn("--exclude='electrs/'", source, - "rsync must exclude electrs directory") - - def test_manifest_states_database_exclusion(self): - """The manifest must explicitly state that PostgreSQL and MariaDB are excluded.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - "PostgreSQL and MariaDB/MySQL application databases are NOT included", - source, - "manifest must state that databases are not included", - ) - self.assertIn( - "Bitcoin blockchain data and Electrs indexes are NOT included", - source, - "manifest must state that blockchain data is not included", - ) - - def test_manifest_has_no_tar_restore_instructions(self): - """Manifest must not mention tar extraction, pg_restore, or LND SCB.""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn( - "tar --acls --xattrs", - source, - "manifest must not give tar restore instructions", - ) - self.assertNotIn( - "pg_restore", - source, - "manifest must not reference pg_restore", - ) - self.assertNotIn( - "lnd-static-channel-backup.scb", - source, - "manifest must not reference LND SCB restore procedures", - ) - - def test_manifest_has_rsync_restore_guidance(self): - """Manifest must provide rsync-based restore guidance.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - "rsync -aAXH --numeric-ids", - source, - "manifest must show rsync restore command", - ) - - # ── sync_tree helper design ──────────────────────────────────────────────── - - def test_backup_script_uses_sync_tree_not_run_rsync(self): - """Script must define sync_tree (new helper) and not the old run_rsync.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("sync_tree()", source, "script must define sync_tree helper") - self.assertNotIn("run_rsync()", source, "old run_rsync function must be removed") - self.assertNotIn("run_rsync ", source, "old run_rsync call sites must be removed") - - def test_sync_tree_signature_has_explicit_source_and_destination(self): - """sync_tree must have explicit parameters.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("local source=", source, - "sync_tree must declare explicit 'source' local variable") - self.assertIn("local destination=", source, - "sync_tree must declare explicit 'destination' local variable") - - def test_sync_tree_creates_destination_with_mkdir_p(self): - """sync_tree must run mkdir -p on the destination before rsync.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn('mkdir -p -- "$destination"', source, - "sync_tree must create destination hierarchy with 'mkdir -p -- \"$destination\"'") - - def test_sync_tree_mkdir_failure_is_fatal(self): - """sync_tree must fail with a logged message if mkdir -p fails.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("failed to create destination directory", source, - "sync_tree must log a descriptive failure message when mkdir fails") - - def test_sync_tree_re_verifies_mountpoint_before_rsync(self): - """sync_tree must re-verify $TARGET is a mount point before each rsync stage.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn('mountpoint -q "$TARGET"', source, - "sync_tree must call mountpoint -q \"$TARGET\" to re-verify mount before each stage") - self.assertIn("no longer mounted", source, - "sync_tree must emit a clear message when the drive is no longer mounted") - - def test_sync_tree_checks_destination_within_backup_dir(self): - """sync_tree must verify destination is beneath BACKUP_DIR.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("outside BACKUP_DIR", source, - "sync_tree must reject destinations outside BACKUP_DIR") - - def test_new_run_removes_stale_backup_complete(self): - """A new backup run must remove a stale BACKUP_COMPLETE before starting.""" - source = BACKUP_SCRIPT.read_text() - # rm -f BACKUP_COMPLETE must appear BEFORE the touch INCOMPLETE line - complete_pos = source.find('rm -f "$BACKUP_DIR/BACKUP_COMPLETE"') - incomplete_pos = source.find('touch "$BACKUP_DIR/INCOMPLETE"') - self.assertGreater(complete_pos, 0, - "script must remove stale BACKUP_COMPLETE at the start of each run") - self.assertGreater(incomplete_pos, 0, - "script must touch INCOMPLETE after removing stale BACKUP_COMPLETE") - self.assertLess(complete_pos, incomplete_pos, - "BACKUP_COMPLETE removal must come before INCOMPLETE marker is written") - - def test_backup_script_exit_code_24_nonfatal_for_home(self): - """Rsync exit code 24 (vanished files) must be nonfatal for /home only.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - '"$rc" -eq 24', - source, - "backup script must handle rsync exit code 24", - ) - self.assertIn( - 'allow_vanished" == "yes"', - source, - "exit 24 acceptance must be gated on allow_vanished flag", - ) - - def test_backup_script_home_stage_allows_vanished(self): - """Stage 3 (/home) must call sync_tree with allow_vanished=yes.""" - source = BACKUP_SCRIPT.read_text() - # The /home call must pass "yes" as the allow_vanished argument - self.assertIn( - 'sync_tree "/home" yes /home/', - source, - "/home stage must pass allow_vanished=yes to sync_tree", - ) - - def test_backup_script_other_stages_disallow_vanished(self): - """Stages other than /home must call sync_tree with allow_vanished=no.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - 'sync_tree "/etc/nixos" no /etc/nixos/', - source, - "/etc/nixos stage must use allow_vanished=no", - ) - self.assertIn( - 'sync_tree "/var/lib" no /var/lib/', - source, - "/var/lib stage must use allow_vanished=no", - ) - - # ── INCOMPLETE / BACKUP_COMPLETE markers ────────────────────────────────── - - def test_backup_script_writes_incomplete_marker(self): - """A backup directory must be marked INCOMPLETE immediately after - creation, so interrupted or failed runs are identifiable.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - 'touch "$BACKUP_DIR/INCOMPLETE"', - source, - "backup script must create INCOMPLETE marker after mkdir", - ) - - def test_backup_script_writes_backup_complete_marker(self): - """A BACKUP_COMPLETE file must be written only after all work succeeds.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn( - 'BACKUP_COMPLETE"', - source, - "backup script must write BACKUP_COMPLETE file on success", - ) - self.assertIn( - 'rm -f "$BACKUP_DIR/INCOMPLETE"', - source, - "backup script must remove INCOMPLETE marker on success", - ) - - # ── Concurrency lock ────────────────────────────────────────────────────── - - def test_backup_script_uses_flock_concurrency_lock(self): - """The script must acquire an exclusive flock before starting work.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("flock", source, "backup script must use flock") - self.assertIn("LOCK_FILE", source, "backup script must define LOCK_FILE") - - # ── Status states ───────────────────────────────────────────────────────── - - def test_backup_script_uses_running_success_failed_states(self): - """Status values must be RUNNING, SUCCESS, and FAILED.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn('set_status "RUNNING"', source) - self.assertIn('set_status "SUCCESS"', source) - self.assertIn('set_status "FAILED"', source) - - def test_backend_and_frontend_use_explicit_backup_terminal_states(self): - """Backend and frontend must use consistent terminal state handling.""" - server_source = SERVER_FILE.read_text() - support_source = SUPPORT_JS.read_text() - - self.assertIn("_write_backup_status, \"RUNNING\"", server_source) - self.assertIn("_monitor_backup_subprocess", server_source) - self.assertIn("asyncio.create_task(_monitor_backup_subprocess(proc))", server_source) - self.assertIn("result === \"success\" || result === \"failed\"", support_source) - - # ── Nix service PATH ────────────────────────────────────────────────────── - - def test_nix_service_path_includes_rsync_and_acl(self): - """pkgs.rsync and pkgs.acl must appear in the sovran-hub-web service path - so that rsync with ACL/xattr support is available at runtime.""" - nix_source = NIX_HUB_FILE.read_text() - self.assertIn( - "pkgs.rsync", - nix_source, - "pkgs.rsync must be declared in the sovran-hub-web service path", - ) - self.assertIn( - "pkgs.acl", - nix_source, - "pkgs.acl must be declared in the sovran-hub-web service path", - ) - - def test_nix_service_path_includes_bash_and_gawk(self): - """Regression: pkgs.bash and pkgs.gawk must appear in the service path.""" - nix_source = NIX_HUB_FILE.read_text() - self.assertIn("pkgs.bash", nix_source, - "pkgs.bash must be declared in the sovran-hub-web service path") - self.assertIn("pkgs.gawk", nix_source, - "pkgs.gawk must be declared in the sovran-hub-web service path") - - def test_nix_service_path_has_no_gnutar(self): - """pkgs.gnutar must be removed from the service path (no longer needed).""" - nix_source = NIX_HUB_FILE.read_text() - self.assertNotIn( - "pkgs.gnutar", - nix_source, - "pkgs.gnutar must be removed from the service path (tar is no longer used)", - ) - - # ── Regression tests for exit-code-127 / missing-interpreter bug ────────── - - def test_server_resolves_bash_via_shutil_which(self): - """Regression: server must locate bash with shutil.which().""" - source = SERVER_FILE.read_text() - self.assertIn('shutil.which("bash")', source) - self.assertNotIn('"/usr/bin/env", "bash"', source) - - def test_server_logs_actionable_message_when_bash_missing(self): - """Regression: when bash is absent the server must write an actionable log.""" - source = SERVER_FILE.read_text() - self.assertIn("bash_path is None", source) - self.assertIn("interpreter", source) - - def test_server_captures_stderr_from_backup_subprocess(self): - """Regression: backup subprocess must use stderr=PIPE.""" - source = SERVER_FILE.read_text() - self.assertIn("stderr=asyncio.subprocess.PIPE", source) - self.assertIn("stderr_text", source) - - def test_monitor_includes_stderr_detail_in_failed_message(self): - """Regression: _monitor_backup_subprocess must append captured stderr.""" - source = SERVER_FILE.read_text() - self.assertIn("stderr_chunks", source) - self.assertIn("stderr_text", source) - self.assertIn('detail = f" — stderr: {stderr_text}"', source) - - def test_exit_code_127_subprocess_stderr_drain(self): - """Behavioral regression: a subprocess that exits 127 drains stderr without deadlock.""" - async def _run(): - proc = await asyncio.create_subprocess_exec( - "bash", "-c", "echo 'bash: command not found' >&2; exit 127", - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, - ) - chunks: list[bytes] = [] - - async def _drain(): - async for line in proc.stderr: - chunks.append(line) - - drain_task = asyncio.create_task(_drain()) - rc = await proc.wait() - await drain_task - return rc, b"".join(chunks).decode() - - rc, stderr_out = asyncio.run(_run()) - self.assertEqual(rc, 127) - self.assertIn("bash: command not found", stderr_out) - - # ── Rsync exit code 24 behavioral tests ────────────────────────────────── - - def test_run_rsync_exit_24_nonfatal_for_home(self): - """Behavioral: run_rsync with allow_vanished=yes treats exit 24 as a - nonfatal warning (recorded in RSYNC_WARNINGS) and does not fail.""" - with tempfile.TemporaryDirectory() as tmpdir: - dest = os.path.join(tmpdir, "backup") - os.makedirs(dest, exist_ok=True) - - script = f"""#!/usr/bin/env bash -set -euo pipefail -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "FAILED: $*" >&2; exit 1; }} - -run_rsync() {{ - local label="$1" - local allow_vanished="$2" - shift 2 - - local rc=24 # simulate rsync exit 24 - - if [[ "$rc" -eq 0 ]]; then - return 0 - elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then - log "NOTE: $label — some files vanished during sync (normal on an active desktop)." - RSYNC_WARNINGS+=("$label: some files vanished during sync") - return 0 - else - fail "rsync failed for $label (exit code $rc)" - fi -}} - -run_rsync "/home" yes /home/ "{dest}/home/" -echo "WARNINGS: ${{#RSYNC_WARNINGS[@]}}" -echo "SUCCESS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}") - self.assertIn("SUCCESS", result.stdout) - self.assertIn("WARNINGS: 1", result.stdout) - - def test_run_rsync_exit_24_fatal_for_non_home(self): - """Behavioral: run_rsync with allow_vanished=no treats exit 24 as fatal.""" - with tempfile.TemporaryDirectory() as tmpdir: - dest = os.path.join(tmpdir, "backup") - os.makedirs(dest, exist_ok=True) - - script = f"""#!/usr/bin/env bash -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }} - -run_rsync() {{ - local label="$1" - local allow_vanished="$2" - shift 2 - - local rc=24 # simulate rsync exit 24 - - if [[ "$rc" -eq 0 ]]; then - return 0 - elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then - RSYNC_WARNINGS+=("$label: vanished") - return 0 - else - fail "rsync failed for $label (exit code $rc)" - fi -}} - -run_rsync "/etc/nixos" no /etc/nixos/ "{dest}/etc/nixos/" -echo "SHOULD_NOT_REACH_HERE" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertIn("CORRECTLY_FAILED", result.stdout) - self.assertNotIn("SHOULD_NOT_REACH_HERE", result.stdout) - - def test_run_rsync_other_nonzero_codes_always_fatal(self): - """Behavioral: rsync exit codes other than 0 and 24 (for home) are fatal.""" - for rc in [1, 10, 11, 12, 23, 25]: - with tempfile.TemporaryDirectory() as tmpdir: - dest = os.path.join(tmpdir, "backup") - os.makedirs(dest, exist_ok=True) - - script = f"""#!/usr/bin/env bash -RSYNC_WARNINGS=() - -fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }} -log() {{ echo "$*"; }} - -run_rsync() {{ - local label="$1" - local allow_vanished="$2" - shift 2 - - local rc={rc} # simulated exit code - - if [[ "$rc" -eq 0 ]]; then - return 0 - elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then - RSYNC_WARNINGS+=("$label: vanished") - return 0 - else - fail "rsync failed for $label (exit code $rc)" - fi -}} - -run_rsync "/home" yes /home/ "{dest}/home/" -echo "SHOULD_NOT_REACH_HERE" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 1, - f"Exit code {rc} should fail: {result.stdout}") - self.assertIn("CORRECTLY_FAILED", result.stdout) - - # ── Behavioral: repeated runs target same 'current' mirror ────────────── - - def test_repeated_runs_use_same_current_directory(self): - """Behavioral: a second call to the backup logic must sync to the same - BACKUP_DIR (not create a new timestamped directory) so only changed - files are transferred.""" - with tempfile.TemporaryDirectory() as tmpdir: - target = os.path.join(tmpdir, "drive") - os.makedirs(target, exist_ok=True) - - script = f"""#!/usr/bin/env bash -set -euo pipefail -TARGET="{target}" -BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current" -BACKUP_DIR="${{TARGET}}/${{BACKUP_SUBPATH}}" -mkdir -p "$BACKUP_DIR" -echo "$BACKUP_DIR" -""" - for _ in range(2): - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0) - backup_dir = result.stdout.strip() - self.assertEqual( - backup_dir, - os.path.join(target, "Sovran_SystemsOS_Backup", "current"), - ) - - # ── Behavioral: INCOMPLETE/BACKUP_COMPLETE marker lifecycle ───────────── - - def test_incomplete_marker_written_before_work_starts(self): - """Behavioral: INCOMPLETE marker must exist during backup and be removed on success.""" - with tempfile.TemporaryDirectory() as tmpdir: - backup_dir = os.path.join(tmpdir, "current") - os.makedirs(backup_dir, exist_ok=True) - - script = f"""#!/usr/bin/env bash -set -euo pipefail -BACKUP_DIR="{backup_dir}" -BACKUP_COMPLETE=0 - -touch "$BACKUP_DIR/INCOMPLETE" -[[ -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_EXISTS" - -# Simulate successful completion -rm -f "$BACKUP_DIR/INCOMPLETE" -echo "done" > "$BACKUP_DIR/BACKUP_COMPLETE" -BACKUP_COMPLETE=1 - -[[ ! -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_REMOVED" -[[ -f "$BACKUP_DIR/BACKUP_COMPLETE" ]] && echo "COMPLETE_EXISTS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}") - self.assertIn("INCOMPLETE_EXISTS", result.stdout) - self.assertIn("INCOMPLETE_REMOVED", result.stdout) - self.assertIn("COMPLETE_EXISTS", result.stdout) - - # ── Behavioral: actual rsync with real source/dest trees ───────────────── - - def test_rsync_mirrors_source_to_dest(self): - """Behavioral: rsync correctly mirrors a source tree to a destination.""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "etc", "nixos") - dest = os.path.join(tmpdir, "backup", "etc", "nixos") - os.makedirs(src, exist_ok=True) - os.makedirs(os.path.join(tmpdir, "backup", "etc"), exist_ok=True) - - # Write test files - with open(os.path.join(src, "configuration.nix"), "w") as f: - f.write("{ ... }: {}\n") - with open(os.path.join(src, "custom.nix"), "w") as f: - f.write("{ ... }: {}\n") - - result = subprocess.run( - ["rsync", "--archive", "--one-file-system", - src + "/", dest + "/"], - capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 0, f"rsync failed: {result.stderr}") - self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix"))) - self.assertTrue(os.path.exists(os.path.join(dest, "custom.nix"))) - - def test_rsync_second_run_only_transfers_changes(self): - """Behavioral: a second rsync run to the same dest only copies changed files.""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "source") - dest = os.path.join(tmpdir, "backup") - os.makedirs(src) - os.makedirs(dest) - - with open(os.path.join(src, "file.txt"), "w") as f: - f.write("initial content\n") - - # First run - r1 = subprocess.run( - ["rsync", "--archive", "--one-file-system", src + "/", dest + "/"], - capture_output=True, text=True, - ) - self.assertEqual(r1.returncode, 0) - - # Modify one file and add another; advance mtime so rsync detects the change - with open(os.path.join(src, "file.txt"), "w") as f: - f.write("updated content\n") - import time as _time - future_mtime = _time.time() + 2 - os.utime(os.path.join(src, "file.txt"), (future_mtime, future_mtime)) - - with open(os.path.join(src, "new.txt"), "w") as f: - f.write("new file\n") - - # Second run - r2 = subprocess.run( - ["rsync", "--archive", "--one-file-system", src + "/", dest + "/"], - capture_output=True, text=True, - ) - self.assertEqual(r2.returncode, 0) - - # Both files exist in dest - with open(os.path.join(dest, "file.txt")) as fh: - self.assertEqual(fh.read(), "updated content\n") - self.assertTrue(os.path.exists(os.path.join(dest, "new.txt"))) - - # ── Script syntax check ────────────────────────────────────────────────── - - def test_backup_script_passes_bash_syntax_check(self): - """bash -n must report no syntax errors in the backup script.""" - result = subprocess.run( - ["bash", "-n", str(BACKUP_SCRIPT)], - capture_output=True, text=True, - ) - self.assertEqual( - result.returncode, 0, - f"bash -n failed:\n{result.stderr}", - ) - - # ── Home exclusions ────────────────────────────────────────────────────── - - def test_backup_script_home_excludes_browser_caches(self): - """Home sync must exclude browser disk caches.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("--exclude='.mozilla/firefox/*/cache2/'", source) - self.assertIn("--exclude='.config/google-chrome/*/Cache/'", source) - self.assertIn("--exclude='.config/chromium/*/Cache/'", source) - self.assertIn("--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/'", source) - - def test_backup_script_home_excludes_other_volatile_caches(self): - """Home sync must exclude baloo, thumbnails, and X session error logs.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("--exclude='.local/share/baloo/'", source) - self.assertIn("--exclude='.thumbnails/'", source) - self.assertIn("--exclude='.xsession-errors'", source) - - # ── require_cmd ────────────────────────────────────────────────────────── - - def test_backup_script_requires_rsync_and_flock(self): - """Script must require rsync and flock via require_cmd.""" - source = BACKUP_SCRIPT.read_text() - self.assertIn("require_cmd rsync", source) - self.assertIn("require_cmd flock", source) - - def test_backup_script_does_not_require_tar_or_sha256sum(self): - """Script must not require tar or sha256sum (no longer used).""" - source = BACKUP_SCRIPT.read_text() - self.assertNotIn("require_cmd tar", source) - self.assertNotIn("require_cmd sha256sum", source) - - # ── Frontend database limitation notice ────────────────────────────────── - - def test_frontend_mentions_database_limitation(self): - """Frontend must explain that PostgreSQL/MariaDB databases are excluded.""" - support_source = SUPPORT_JS.read_text() - self.assertIn( - "PostgreSQL", - support_source, - "UI must mention PostgreSQL exclusion", - ) - self.assertIn( - "not included", - support_source, - "UI must explain that databases are not included", - ) - - # ── Behavioral: sync_tree creates nested destination dirs (regression) ───── - - def test_sync_tree_creates_nested_dest_dirs_regression(self): - """Regression: sync_tree must create the full destination hierarchy before - calling rsync so that rsync never fails with - 'mkdir current/etc/nixos failed: No such file or directory'. - - Initial state: only Sovran_SystemsOS_Backup/current/ exists. - Expected: current/etc/nixos/ is created and populated by sync_tree.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Source tree - src = os.path.join(tmpdir, "src", "etc", "nixos") - os.makedirs(src) - with open(os.path.join(src, "configuration.nix"), "w") as f: - f.write("{ }: {}\n") - - # Backup target: only current/ exists, NOT current/etc/ - target = os.path.join(tmpdir, "target") - backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current") - os.makedirs(backup_dir) - dest = os.path.join(backup_dir, "etc", "nixos") - - self.assertFalse(os.path.exists(dest), - "destination must not exist before sync_tree runs") - self.assertFalse(os.path.exists(os.path.join(backup_dir, "etc")), - "intermediate parent current/etc/ must not exist before sync_tree runs") - - script = f"""#!/usr/bin/env bash -set -euo pipefail -TARGET="{target}" -BACKUP_DIR="{backup_dir}" -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "FAIL: $*" >&2; exit 1; }} - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - - # Path-safety check - case "$destination" in - "$BACKUP_DIR"/*|"$BACKUP_DIR") ;; - *) fail "Stage $label: destination outside BACKUP_DIR"; ;; - esac - - # THE FIX: create full hierarchy before rsync - mkdir -p -- "$destination" || fail "Stage $label: mkdir failed for '$destination'" - - local rc=0 - rsync --archive --one-file-system "$@" "$source" "$destination" || rc=$? - - if [[ "$rc" -eq 0 ]]; then return 0 - elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then - RSYNC_WARNINGS+=("$label: vanished"); return 0 - else - fail "rsync failed for $label (exit code $rc)" - fi -}} - -sync_tree "/etc/nixos" no "{src}/" "{dest}/" -echo "SUCCESS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, - f"sync_tree failed:\nstdout: {result.stdout}\nstderr: {result.stderr}") - self.assertIn("SUCCESS", result.stdout) - self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix")), - "configuration.nix must be present after sync_tree") - - def test_sync_tree_all_four_stages_create_nested_dirs(self): - """End-to-end: all four production stage mappings create and populate - their nested destination directories starting from an empty current/.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Source trees - src_etc_nixos = os.path.join(tmpdir, "src", "etc", "nixos") - src_secrets = os.path.join(tmpdir, "src", "etc", "nix-bitcoin-secrets") - src_home = os.path.join(tmpdir, "src", "home") - src_varlib = os.path.join(tmpdir, "src", "var", "lib") - for d in (src_etc_nixos, src_secrets, src_home, src_varlib): - os.makedirs(d) - - with open(os.path.join(src_etc_nixos, "configuration.nix"), "w") as f: - f.write("{ }: {}\n") - with open(os.path.join(src_secrets, "bitcoin-key.txt"), "w") as f: - f.write("test-key-data\n") - with open(os.path.join(src_home, "user.txt"), "w") as f: - f.write("home\n") - with open(os.path.join(src_varlib, "app.db"), "w") as f: - f.write("db\n") - - # Backup target: only current/ exists - target = os.path.join(tmpdir, "target") - backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current") - os.makedirs(backup_dir) - - dest_nixos = os.path.join(backup_dir, "etc", "nixos") - dest_secrets = os.path.join(backup_dir, "etc", "nix-bitcoin-secrets") - dest_home = os.path.join(backup_dir, "home") - dest_varlib = os.path.join(backup_dir, "var", "lib") - - script = f"""#!/usr/bin/env bash -set -euo pipefail -TARGET="{target}" -BACKUP_DIR="{backup_dir}" -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "FAIL: $*" >&2; exit 1; }} - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - case "$destination" in - "$BACKUP_DIR"/*|"$BACKUP_DIR") ;; - *) fail "unsafe destination"; ;; - esac - mkdir -p -- "$destination" || fail "mkdir failed for $destination" - local rc=0 - rsync --archive --one-file-system "$@" "$source" "$destination" || rc=$? - [[ "$rc" -eq 0 ]] || ([[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]] && return 0) || \ - fail "rsync failed (exit $rc)" -}} - -sync_tree "/etc/nixos" no "{src_etc_nixos}/" "{dest_nixos}/" -sync_tree "/etc/nix-bitcoin-secrets" no "{src_secrets}/" "{dest_secrets}/" -sync_tree "/home" yes "{src_home}/" "{dest_home}/" -sync_tree "/var/lib" no "{src_varlib}/" "{dest_varlib}/" -echo "ALL_STAGES_OK" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, - f"stdout: {result.stdout}\nstderr: {result.stderr}") - self.assertIn("ALL_STAGES_OK", result.stdout) - self.assertTrue(os.path.exists(os.path.join(dest_nixos, "configuration.nix"))) - self.assertTrue(os.path.exists(os.path.join(dest_secrets, "bitcoin-key.txt"))) - self.assertTrue(os.path.exists(os.path.join(dest_home, "user.txt"))) - self.assertTrue(os.path.exists(os.path.join(dest_varlib, "app.db"))) - - def test_sync_tree_spaces_in_paths_work(self): - """sync_tree must handle spaces in target and backup-dir paths correctly.""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "src with spaces", "etc", "nixos") - os.makedirs(src) - with open(os.path.join(src, "configuration.nix"), "w") as f: - f.write("{ }: {}\n") - - target = os.path.join(tmpdir, "S_S Backup") - backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current") - os.makedirs(backup_dir) - dest = os.path.join(backup_dir, "etc", "nixos") - - script = f"""#!/usr/bin/env bash -set -euo pipefail -TARGET="{target}" -BACKUP_DIR="{backup_dir}" -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "FAIL: $*" >&2; exit 1; }} - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - mkdir -p -- "$destination" || fail "mkdir failed" - rsync --archive --one-file-system "$@" "$source" "$destination" || fail "rsync failed" -}} - -sync_tree "/etc/nixos" no "{src}/" "{dest}/" -echo "SUCCESS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, - f"stdout: {result.stdout}\nstderr: {result.stderr}") - self.assertIn("SUCCESS", result.stdout) - self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix"))) - - def test_no_delete_dest_only_file_survives_second_run(self): - """Behavioral: without --delete, a file present only in the destination - must survive a second sync_tree run. Accidental source-side deletion - must NOT silently wipe the backup copy.""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "src") - dest = os.path.join(tmpdir, "dest") - os.makedirs(src) - os.makedirs(dest) - - with open(os.path.join(src, "keep.txt"), "w") as f: - f.write("keep\n") - with open(os.path.join(dest, "dest_only.txt"), "w") as f: - f.write("destination-only file\n") - - # First run - r1 = subprocess.run( - ["rsync", "--archive", "--one-file-system", src + "/", dest + "/"], - capture_output=True, text=True, - ) - self.assertEqual(r1.returncode, 0, r1.stderr) - - # Second run (no new source files added) - r2 = subprocess.run( - ["rsync", "--archive", "--one-file-system", src + "/", dest + "/"], - capture_output=True, text=True, - ) - self.assertEqual(r2.returncode, 0, r2.stderr) - - # dest_only.txt must still be present — no --delete was used - self.assertTrue(os.path.exists(os.path.join(dest, "dest_only.txt")), - "destination-only file must survive because --delete is not used") - self.assertTrue(os.path.exists(os.path.join(dest, "keep.txt"))) - - def test_sync_tree_mkdir_failure_is_fatal_with_context(self): - """Behavioral: when mkdir -p fails, sync_tree must exit non-zero with a - message that includes the stage label, source, and destination.""" - with tempfile.TemporaryDirectory() as tmpdir: - target = os.path.join(tmpdir, "target") - backup_dir = os.path.join(target, "current") - os.makedirs(backup_dir) - - # Make the destination parent read-only so mkdir -p fails - os.chmod(backup_dir, 0o555) - - dest = os.path.join(backup_dir, "nested", "dir") - - script = f"""#!/usr/bin/env bash -TARGET="{target}" -BACKUP_DIR="{backup_dir}" - -log() {{ echo "$*"; }} -fail() {{ echo "MKDIR_FAIL_MSG: $*" >&2; exit 1; }} - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - mkdir -p -- "$destination" 2>/dev/null || \ - fail "Stage $label: failed to create destination directory '$destination' (source: '$source')." - echo "SHOULD_NOT_REACH" -}} - -sync_tree "test-stage" no /some/source "{dest}" -""" - try: - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0, - "sync_tree must fail when mkdir -p fails") - self.assertIn("MKDIR_FAIL_MSG", result.stderr, - "failure message must be present in stderr") - self.assertIn("test-stage", result.stderr, - "failure message must include the stage label") - self.assertNotIn("SHOULD_NOT_REACH", result.stdout, - "rsync must not run after mkdir failure") - finally: - os.chmod(backup_dir, 0o755) - - def test_sync_tree_mountpoint_check_prevents_write_on_disconnect(self): - """Behavioral: sync_tree must abort with a clear message if $TARGET is - no longer a mount point (simulating drive disconnection).""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "src") - target = os.path.join(tmpdir, "target") - backup_dir = os.path.join(target, "current") - dest = os.path.join(backup_dir, "etc", "nixos") - os.makedirs(src) - os.makedirs(backup_dir) - with open(os.path.join(src, "file.nix"), "w") as f: - f.write("{ }: {}\n") - - # Inject a fake 'mountpoint' that always returns 1 (drive gone) - script = f"""#!/usr/bin/env bash -set -euo pipefail -TARGET="{target}" -BACKUP_DIR="{backup_dir}" -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "MOUNT_FAIL: $*" >&2; exit 1; }} - -# Fake mountpoint: always returns failure (simulates drive disconnection) -mountpoint() {{ return 1; }} -export -f mountpoint - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - mountpoint -q "$TARGET" 2>/dev/null || \ - fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write." - mkdir -p -- "$destination" || fail "mkdir failed" - rsync --archive "$@" "$source" "$destination" || fail "rsync failed" - echo "RSYNC_RAN" -}} - -sync_tree "/etc/nixos" no "{src}/" "{dest}/" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0, - "sync_tree must fail when mount check fails") - self.assertIn("MOUNT_FAIL", result.stderr, - "failure message must be in stderr") - self.assertIn("no longer mounted", result.stderr, - "failure message must say 'no longer mounted'") - self.assertNotIn("RSYNC_RAN", result.stdout, - "rsync must not run after mount check fails") - - def test_failed_run_leaves_incomplete_not_backup_complete(self): - """Behavioral: a failed run must leave INCOMPLETE and must NOT create - BACKUP_COMPLETE.""" - with tempfile.TemporaryDirectory() as tmpdir: - backup_dir = os.path.join(tmpdir, "current") - os.makedirs(backup_dir) - - script = f"""#!/usr/bin/env bash -BACKUP_DIR="{backup_dir}" -BACKUP_COMPLETE=0 -FAILED_ALREADY=0 - -log() {{ echo "$*"; }} -set_status() {{ echo "STATUS: $1"; }} - -fail() {{ - FAILED_ALREADY=1 - log "ERROR: $*" - set_status "FAILED" - exit 1 -}} - -cleanup() {{ - local rc=$? - if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then return; fi - if [[ "$FAILED_ALREADY" -eq 0 ]]; then - log "ERROR: unexpected exit $rc" - set_status "FAILED" - fi - if [[ -n "${{BACKUP_DIR:-}}" && -d "${{BACKUP_DIR:-}}" && ! -f "${{BACKUP_DIR:-}}/BACKUP_COMPLETE" ]]; then - touch "${{BACKUP_DIR}}/INCOMPLETE" 2>/dev/null || true - fi -}} -trap cleanup EXIT - -# Remove stale BACKUP_COMPLETE from prior run -rm -f "$BACKUP_DIR/BACKUP_COMPLETE" -touch "$BACKUP_DIR/INCOMPLETE" - -# Simulate a stage failure -fail "stage failed" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0) - self.assertTrue(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")), - "INCOMPLETE marker must exist after a failed run") - self.assertFalse(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")), - "BACKUP_COMPLETE must NOT be created after a failed run") - - def test_successful_run_removes_incomplete_writes_backup_complete(self): - """Behavioral: a successful run must remove INCOMPLETE and write BACKUP_COMPLETE.""" - with tempfile.TemporaryDirectory() as tmpdir: - backup_dir = os.path.join(tmpdir, "current") - os.makedirs(backup_dir) - - script = f"""#!/usr/bin/env bash -set -euo pipefail -BACKUP_DIR="{backup_dir}" -BACKUP_COMPLETE=0 - -rm -f "$BACKUP_DIR/BACKUP_COMPLETE" -touch "$BACKUP_DIR/INCOMPLETE" - -# Simulate all work succeeding -rm -f "$BACKUP_DIR/INCOMPLETE" -date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE" -BACKUP_COMPLETE=1 -echo "SUCCESS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, - f"stderr: {result.stderr}") - self.assertFalse(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")), - "INCOMPLETE must be removed after success") - self.assertTrue(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")), - "BACKUP_COMPLETE must be written after success") - - def test_stale_backup_complete_removed_before_new_run(self): - """Behavioral: a stale BACKUP_COMPLETE from a prior successful run must - be removed at the start of the new run so the run is unambiguously marked - as INCOMPLETE while in progress.""" - with tempfile.TemporaryDirectory() as tmpdir: - backup_dir = os.path.join(tmpdir, "current") - os.makedirs(backup_dir) - - # Simulate state after a prior successful run - with open(os.path.join(backup_dir, "BACKUP_COMPLETE"), "w") as f: - f.write("2026-07-01T00:00:00Z\n") - - script = f"""#!/usr/bin/env bash -set -euo pipefail -BACKUP_DIR="{backup_dir}" -BACKUP_COMPLETE=0 - -# New run starts: remove stale BACKUP_COMPLETE, write INCOMPLETE -rm -f "$BACKUP_DIR/BACKUP_COMPLETE" -touch "$BACKUP_DIR/INCOMPLETE" - -[[ ! -f "$BACKUP_DIR/BACKUP_COMPLETE" ]] && echo "STALE_COMPLETE_REMOVED" -[[ -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_PRESENT" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0) - self.assertIn("STALE_COMPLETE_REMOVED", result.stdout, - "stale BACKUP_COMPLETE must be removed at run start") - self.assertIn("INCOMPLETE_PRESENT", result.stdout, - "INCOMPLETE must be present during the run") - - def test_rerun_after_failed_run_succeeds_without_manual_cleanup(self): - """Behavioral: a directory left with only INCOMPLETE (prior failed run) - can be rerun successfully without any manual cleanup.""" - with tempfile.TemporaryDirectory() as tmpdir: - src = os.path.join(tmpdir, "src") - os.makedirs(src) - with open(os.path.join(src, "file.txt"), "w") as f: - f.write("data\n") - - backup_dir = os.path.join(tmpdir, "backup", "current") - os.makedirs(backup_dir) - - # Simulate state after a prior failed run: only INCOMPLETE exists - with open(os.path.join(backup_dir, "INCOMPLETE"), "w") as f: - f.write("") - - dest = os.path.join(backup_dir, "data") - - script = f"""#!/usr/bin/env bash -set -euo pipefail -BACKUP_DIR="{backup_dir}" -BACKUP_COMPLETE=0 -RSYNC_WARNINGS=() - -log() {{ echo "$*"; }} -fail() {{ echo "FAIL: $*" >&2; exit 1; }} - -sync_tree() {{ - local label="$1" allow_vanished="$2" source="$3" destination="$4" - shift 4 - mkdir -p -- "$destination" || fail "mkdir failed" - rsync --archive --one-file-system "$@" "$source" "$destination" || fail "rsync failed" -}} - -# New run: remove stale BACKUP_COMPLETE (none here), write INCOMPLETE -rm -f "$BACKUP_DIR/BACKUP_COMPLETE" -touch "$BACKUP_DIR/INCOMPLETE" - -# Run stage -sync_tree "data" no "{src}/" "{dest}/" - -# Mark success -rm -f "$BACKUP_DIR/INCOMPLETE" -date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE" -BACKUP_COMPLETE=1 -echo "RERUN_SUCCESS" -""" - script_file = os.path.join(tmpdir, "test.sh") - with open(script_file, "w") as sf: - sf.write(script) - result = subprocess.run(["bash", script_file], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, - f"stdout: {result.stdout}\nstderr: {result.stderr}") - self.assertIn("RERUN_SUCCESS", result.stdout) - self.assertFalse(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")), - "INCOMPLETE must be removed after successful rerun") - self.assertTrue(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")), - "BACKUP_COMPLETE must exist after successful rerun") - self.assertTrue(os.path.exists(os.path.join(dest, "file.txt")), - "backed-up file must exist after successful rerun") - - def test_behavioral_database_exclusions(self): - """Behavioral: postgresql, mysql, mariadb, bitcoind, electrs directories - must be excluded from the var/lib rsync stage.""" - with tempfile.TemporaryDirectory() as tmpdir: - src_varlib = os.path.join(tmpdir, "var", "lib") - for d in ("postgresql", "mysql", "mariadb", "bitcoind", "electrs", "myapp"): - os.makedirs(os.path.join(src_varlib, d)) - with open(os.path.join(src_varlib, d, "data.bin"), "w") as f: - f.write("data\n") - - dest_varlib = os.path.join(tmpdir, "backup", "var", "lib") - os.makedirs(dest_varlib) - - result = subprocess.run( - [ - "rsync", "--archive", "--one-file-system", - "--exclude=postgresql/", - "--exclude=mysql/", - "--exclude=mariadb/", - "--exclude=bitcoind/", - "--exclude=electrs/", - src_varlib + "/", dest_varlib + "/", - ], - capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - # Excluded directories must NOT appear in dest - for excluded in ("postgresql", "mysql", "mariadb", "bitcoind", "electrs"): - self.assertFalse( - os.path.exists(os.path.join(dest_varlib, excluded)), - f"{excluded}/ must be excluded from the backup", - ) - # Non-excluded directory must appear - self.assertTrue( - os.path.exists(os.path.join(dest_varlib, "myapp", "data.bin")), - "non-excluded directories must be backed up", - ) - - def test_behavioral_home_cache_exclusion(self): - """Behavioral: .cache/ and browser cache dirs must be excluded from /home.""" - with tempfile.TemporaryDirectory() as tmpdir: - user_home = os.path.join(tmpdir, "home", "user") - cache_dir = os.path.join(user_home, ".cache", "something") - docs_dir = os.path.join(user_home, "Documents") - ff_cache = os.path.join(user_home, ".mozilla", "firefox", "default", "cache2") - brave_cache = os.path.join( - user_home, ".config", "BraveSoftware", "Brave-Browser", "Default", "Cache" - ) - for d in (cache_dir, docs_dir, ff_cache, brave_cache): - os.makedirs(d) - with open(os.path.join(cache_dir, "tmp.bin"), "w") as f: f.write("cache\n") - with open(os.path.join(docs_dir, "note.txt"), "w") as f: f.write("note\n") - with open(os.path.join(ff_cache, "entry"), "w") as f: f.write("ff\n") - with open(os.path.join(brave_cache, "entry"), "w") as f: f.write("brave\n") - - src = os.path.join(tmpdir, "home") - dest = os.path.join(tmpdir, "backup", "home") - os.makedirs(dest) - - result = subprocess.run( - [ - "rsync", "--archive", "--one-file-system", - "--exclude=.cache/", - "--exclude=.mozilla/firefox/*/cache2/", - "--exclude=.config/BraveSoftware/Brave-Browser/*/Cache/", - src + "/", dest + "/", - ], - capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - dest_user = os.path.join(dest, "user") - self.assertFalse( - os.path.exists(os.path.join(dest_user, ".cache")), - ".cache/ must be excluded", - ) - self.assertFalse( - os.path.exists(os.path.join(dest_user, ".mozilla", "firefox", - "default", "cache2")), - "Firefox cache2/ must be excluded", - ) - self.assertFalse( - os.path.exists(os.path.join(dest_user, ".config", "BraveSoftware", - "Brave-Browser", "Default", "Cache")), - "Brave Cache/ must be excluded", - ) - self.assertTrue( - os.path.exists(os.path.join(dest_user, "Documents", "note.txt")), - "Documents must be backed up", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_rdp_module_boot_setup.py b/app/tests/test_rdp_module_boot_setup.py deleted file mode 100644 index 4948b47..0000000 --- a/app/tests/test_rdp_module_boot_setup.py +++ /dev/null @@ -1,122 +0,0 @@ -import unittest -from pathlib import Path - - -RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix" -USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\"" -USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then" -SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then' - - -def _section(source: str, start: str, end: str) -> str: - start_idx = source.find(start) - if start_idx == -1: - raise AssertionError(f"Expected section start not found: {start!r}") - end_idx = source.find(end, start_idx) - if end_idx == -1: - raise AssertionError(f"Expected section end not found: {end!r}") - return source[start_idx:end_idx] - - -class RdpModuleBootSetupTests(unittest.TestCase): - def setUp(self): - self.source = RDP_NIX.read_text() - self.gnome_service = _section( - self.source, - "systemd.services.gnome-remote-desktop = {", - "systemd.tmpfiles.rules = [", - ) - self.setup_service = _section( - self.source, - "systemd.services.gnome-remote-desktop-setup = {", - "};\n}", - ) - - def test_does_not_redeclare_gnome_remote_desktop_user(self): - self.assertNotIn("users.users.gnome-remote-desktop", self.source) - self.assertNotIn("createHome = true;", self.source) - - def test_main_service_requires_setup_before_starting(self): - self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service) - self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service) - self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service) - - def test_setup_waits_for_configuration_service_and_bounded_timeout(self): - self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service) - self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service) - self.assertIn('"dbus.service"', self.setup_service) - self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service) - self.assertNotIn("RemainAfterExit", self.setup_service) - self.assertIn('TimeoutStartSec = "2min";', self.setup_service) - self.assertIn('timeout --kill-after=5s 10s', self.setup_service) - self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service) - self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service) - - def test_setup_runs_grdctl_directly_as_root(self): - # The oneshot service runs as root; grdctl --system is called directly. - # GRD 50.x invokes pkexec internally, but the call itself is plain - # grdctl --system, not a manual pkexec invocation. - self.assertIn('grdctl --system "$@"', self.setup_service) - self.assertNotIn("runuser", self.setup_service) - self.assertNotIn("sudo", self.setup_service) - # No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec). - self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service) - - def test_privilege_escalation_packages_absent_from_setup_path(self): - self.assertNotIn("pkgs.polkit", self.setup_service) - self.assertNotIn("pkgs.util-linux", self.setup_service) - - def test_run_wrappers_bin_prepended_to_path(self): - # /run/wrappers/bin must be prepended to PATH before any grdctl_system - # invocation so that grdctl --system resolves the NixOS setuid pkexec. - path_export = 'export PATH="/run/wrappers/bin:$PATH"' - grdctl_marker = "grdctl_system" - script = self.setup_service - path_idx = script.find(path_export) - grdctl_idx = script.find(grdctl_marker) - self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script") - self.assertGreater( - grdctl_idx, path_idx, - "PATH export must appear before the first grdctl_system usage", - ) - - def test_pkexec_preflight_check(self): - # A preflight must confirm /run/wrappers/bin/pkexec is executable - # with a clear error message before any GRD configuration changes. - self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service) - self.assertIn( - "/run/wrappers/bin/pkexec is absent or not executable", - self.setup_service, - ) - - def test_hub_files_are_the_source_of_truth_for_username_and_password(self): - self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service) - self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service) - self.assertIn(USERNAME_READ, self.setup_service) - self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service) - self.assertIn('case "$USERNAME" in', self.setup_service) - self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service) - self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service) - self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service) - self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service) - self.assertIn("tr -d '\\n'", self.setup_service) - self.assertIn('"$PASSWORD_FILE"', self.setup_service) - self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service) - self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service) - self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service) - self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service) - - def test_secure_permissions_are_enforced_for_state_and_secret_files(self): - self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source) - self.assertIn("chmod 700", self.setup_service) - self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service) - self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service) - self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service) - self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service) - self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service) - self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service) - self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_service_detail_router_wording.py b/app/tests/test_service_detail_router_wording.py deleted file mode 100644 index 1894ab6..0000000 --- a/app/tests/test_service_detail_router_wording.py +++ /dev/null @@ -1,171 +0,0 @@ -import unittest -from pathlib import Path -from unittest.mock import mock_open, patch -import sys -import types - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - - -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, *args, **kwargs): - pass - - def mount(self, *args, **kwargs): - return None - - def add_middleware(self, *args, **kwargs): - return None - - def __getattr__(self, _name): - def _decorator_factory(*args, **kwargs): - def _decorator(func): - return func - - return _decorator - - return _decorator_factory - - class _BaseModel: - pass - - class _StaticFiles: - def __init__(self, *args, **kwargs): - pass - - class _Jinja2Templates: - def __init__(self, *args, **kwargs): - pass - - class _BaseHTTPMiddleware: - pass - - fastapi_module = types.ModuleType("fastapi") - fastapi_module.FastAPI = _FastAPI - fastapi_module.HTTPException = _HTTPException - sys.modules["fastapi"] = fastapi_module - - responses_module = types.ModuleType("fastapi.responses") - responses_module.HTMLResponse = object - responses_module.JSONResponse = object - responses_module.RedirectResponse = object - sys.modules["fastapi.responses"] = responses_module - - staticfiles_module = types.ModuleType("fastapi.staticfiles") - staticfiles_module.StaticFiles = _StaticFiles - sys.modules["fastapi.staticfiles"] = staticfiles_module - - templating_module = types.ModuleType("fastapi.templating") - templating_module.Jinja2Templates = _Jinja2Templates - sys.modules["fastapi.templating"] = templating_module - - requests_module = types.ModuleType("fastapi.requests") - requests_module.Request = object - sys.modules["fastapi.requests"] = requests_module - - pydantic_module = types.ModuleType("pydantic") - pydantic_module.BaseModel = _BaseModel - sys.modules["pydantic"] = pydantic_module - - starlette_base_module = types.ModuleType("starlette.middleware.base") - starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware - sys.modules["starlette.middleware.base"] = starlette_base_module - - starlette_middleware_module = types.ModuleType("starlette.middleware") - starlette_middleware_module.base = starlette_base_module - sys.modules["starlette.middleware"] = starlette_middleware_module - - starlette_module = types.ModuleType("starlette") - starlette_module.middleware = starlette_middleware_module - sys.modules["starlette"] = starlette_module - - -_install_web_stubs() -from sovran_systemsos_web import server - - -class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase): - async def test_livekit_service_detail_includes_internal_ip(self): - service_cfg = { - "services": [ - {"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"} - ] - } - domain_eval = { - "domain_status": {"status": "ok"}, - "domain_reachable": {"reachable": True}, - "domain_check_steps": [], - "has_issues": False, - } - - with ( - patch.object(server, "load_config", return_value=service_cfg), - patch.object(server, "_read_hub_overrides", return_value=({}, None, None)), - patch.object(server.sysctl, "is_active", return_value="active"), - patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False), - patch.dict( - server.SERVICE_PORT_REQUIREMENTS, - {"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]}, - clear=False, - ), - patch("builtins.open", mock_open(read_data="call.example.com\n")), - patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval), - patch.object(server, "_get_internal_ip", return_value="192.168.1.44"), - patch.object(server, "_save_internal_ip"), - patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}), - patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}), - ): - result = await server.api_service_detail("livekit.service") - - self.assertEqual(result["internal_ip"], "192.168.1.44") - self.assertEqual(result["extra_ports"][0]["status"], "listening") - self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed") - - async def test_livekit_router_step_uses_not_ready_yet_wording(self): - service_cfg = { - "services": [ - {"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"} - ] - } - domain_eval = { - "domain_status": {"status": "ok"}, - "domain_reachable": {"reachable": True}, - "domain_check_steps": [], - "has_issues": False, - } - - with ( - patch.object(server, "load_config", return_value=service_cfg), - patch.object(server, "_read_hub_overrides", return_value=({}, None, None)), - patch.object(server.sysctl, "is_active", return_value="active"), - patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False), - patch.dict( - server.SERVICE_PORT_REQUIREMENTS, - {"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]}, - clear=False, - ), - patch("builtins.open", mock_open(read_data="call.example.com\n")), - patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval), - patch.object(server, "_get_internal_ip", return_value="192.168.1.44"), - patch.object(server, "_save_internal_ip"), - patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}), - patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}), - ): - result = await server.api_service_detail("livekit.service") - - self.assertEqual(result["extra_ports"][0]["status"], "closed") - self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_template_response_signature.py b/app/tests/test_template_response_signature.py deleted file mode 100644 index 4be6620..0000000 --- a/app/tests/test_template_response_signature.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style. - -Prior to this fix, the three HTML routes called: - templates.TemplateResponse("name.html", {"request": request, ...}) -which passes the context dict as the second positional argument. With the -updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0, -FastAPI 0.136.3) that positional argument is the template name, causing Jinja2 -to receive a dict as a cache key and raise: - TypeError: unhashable type: 'dict' - -The fix updates every call to use keyword arguments: - templates.TemplateResponse(request=request, name="name.html", context={...}) -""" - -import ast -import unittest -from pathlib import Path - - -SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py" - - -def _template_response_calls(source: str): - """Return a list of ast.Call nodes that are TemplateResponse calls.""" - tree = ast.parse(source) - calls = [] - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - func = node.func - if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse": - calls.append(node) - return calls - - -class TemplateResponseSignatureTests(unittest.TestCase): - def setUp(self): - self.source = SERVER_PY.read_text() - self.calls = _template_response_calls(self.source) - - def test_at_least_one_template_response_call_found(self): - self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py") - - def test_no_old_style_positional_dict_context(self): - """No TemplateResponse call should pass a dict literal as its second positional arg. - - The old style was: - templates.TemplateResponse("name.html", {"request": request, ...}) - where args[0] is a string and args[1] is a Dict node. That pattern - triggers the Starlette 1.1.0 bug. - """ - for call in self.calls: - positional = call.args - if len(positional) >= 2 and isinstance(positional[1], ast.Dict): - self.fail( - f"Found old-style TemplateResponse call at line {call.lineno}: " - "second positional argument is a dict literal. " - "Use keyword arguments (request=, name=, context=) instead." - ) - - def test_request_not_duplicated_in_context(self): - """The 'request' key must not appear inside the context= dict when - request= is already passed as a dedicated keyword argument.""" - for call in self.calls: - kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)} - - if "request" not in kw_dict: - continue # no request= kwarg, nothing to check - - context_node = kw_dict.get("context") - if not isinstance(context_node, ast.Dict): - continue - - for key_node in context_node.keys: - if isinstance(key_node, ast.Constant) and key_node.value == "request": - self.fail( - f"TemplateResponse at line {call.lineno} passes 'request' both as " - "request= keyword argument and inside the context dict." - ) - - def test_all_calls_use_keyword_arguments(self): - """Every TemplateResponse call should use keyword arguments for request, name, - and context rather than relying on positional ordering.""" - for call in self.calls: - kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)} - self.assertIn( - "request", - kw_args, - f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.", - ) - self.assertIn( - "name", - kw_args, - f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py deleted file mode 100644 index 04f6ec7..0000000 --- a/app/tests/test_wallet_connections.py +++ /dev/null @@ -1,1399 +0,0 @@ -""" -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 importlib -import os -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", - ) - - -class ManagerApiBaseConfigurationTests(unittest.TestCase): - def setUp(self): - self._original_api_base = os.environ.get("NWC_ALBY_HUB_API_BASE") - - def tearDown(self): - if self._original_api_base is None: - os.environ.pop("NWC_ALBY_HUB_API_BASE", None) - else: - os.environ["NWC_ALBY_HUB_API_BASE"] = self._original_api_base - importlib.reload(mgr) - - def test_default_api_base_falls_back_to_18080(self): - os.environ.pop("NWC_ALBY_HUB_API_BASE", None) - reloaded_mgr = importlib.reload(mgr) - self.assertEqual(reloaded_mgr.DEFAULT_API_BASE, "http://127.0.0.1:18080") - - def test_env_api_base_overrides_default(self): - os.environ["NWC_ALBY_HUB_API_BASE"] = "http://127.0.0.1:19999" - reloaded_mgr = importlib.reload(mgr) - self.assertEqual(reloaded_mgr.DEFAULT_API_BASE, "http://127.0.0.1:19999") - - -# ── 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 " - "./packages/albyhub/0003-loopback-bind-host.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.assertIn("../packages/albyhub/0003-loopback-bind-host.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) - self.assertIn("albyHubPort = 18080;", text) - self.assertIn('albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";', 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_loopback_bind_host_patch_contains_required_changes(self): - repo_root = Path(__file__).resolve().parents[2] - patch_path = repo_root / "packages" / "albyhub" / "0003-loopback-bind-host.patch" - text = patch_path.read_text() - self.assertIn("diff --git a/cmd/http/main.go b/cmd/http/main.go", text) - self.assertIn("diff --git a/config/models.go b/config/models.go", text) - self.assertIn('Host string `envconfig:"HOST" default:"127.0.0.1"`', text) - self.assertIn("bindAddress := net.JoinHostPort", text) - self.assertIn("if err := e.Start(bindAddress);", text) - - def test_nwc_services_share_authoritative_alby_hub_api_base(self): - repo_root = Path(__file__).resolve().parents[2] - module_path = repo_root / "modules" / "nwc-wallets.nix" - text = module_path.read_text() - self.assertIn("environment.NWC_ALBY_HUB_API_BASE = albyHubApiBase;", text) - self.assertIn("NWC_ALBY_HUB_API_BASE = albyHubApiBase;", text) - - def test_albyhub_port_contract_and_assertions(self): - repo_root = Path(__file__).resolve().parents[2] - module_path = repo_root / "modules" / "nwc-wallets.nix" - text = module_path.read_text() - self.assertIn("PORT = toString albyHubPort;", text) - self.assertNotIn('PORT = "8080";', text) - self.assertIn("assertion = albyHubPort != config.services.lnd.restPort;", text) - self.assertIn("assertion = albyHubPort != 8181;", text) - self.assertIn("assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts);", text) - - def test_recovery_cli_uses_manager_default_endpoint(self): - repo_root = Path(__file__).resolve().parents[2] - cli_path = repo_root / "app" / "sovran_systemsos_web" / "nwc_wallet_cli.py" - manager_path = repo_root / "app" / "sovran_systemsos_web" / "nwc_hub_manager.py" - cli_text = cli_path.read_text() - manager_text = manager_path.read_text() - self.assertIn("get_manager()", cli_text) - self.assertNotIn("127.0.0.1:8080", cli_text) - self.assertIn('"http://127.0.0.1:18080"', manager_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()