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()