From 768f26027e96d5423c5258e4b5f03d1926e61723 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:36:02 +0000 Subject: [PATCH] Require unique hostname for Wallet Connections; add conflict validation and UI guidance --- app/sovran_systemsos_web/server.py | 80 ++- .../static/js/features.js | 48 +- app/sovran_systemsos_web/static/js/helpers.js | 11 +- app/tests/test_domain_conflict.py | 506 ++++++++++++++++++ 4 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 app/tests/test_domain_conflict.py diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2ecfa9d..b7c08ca 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -4091,15 +4091,93 @@ def _validate_safe_name(name: str) -> bool: return bool(name) and _SAFE_NAME_RE.match(name) is not None +# Hostname characters: letters, digits, hyphens, dots (no underscores in FQDNs) +_HOSTNAME_RE = re.compile(r'^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$') + +# Managed domain keys that produce Caddy virtual-host blocks (excluding sslemail) +_MANAGED_DOMAIN_KEYS: frozenset[str] = frozenset([ + "matrix", "haven", "element-calling", "vaultwarden", + "btcpayserver", "nextcloud", "wordpress", "lightning", +]) + +# Any save involving lightning must be unique across all managed service domains. +_LIGHTNING_DOMAIN_KEY = "lightning" + + +def _normalize_hostname(raw: str) -> str: + """Trim, lowercase, and remove exactly one trailing dot.""" + h = raw.strip().lower() + if h.endswith("."): + h = h[:-1] + return h + + +def _validate_hostname(hostname: str) -> bool: + """Return True if hostname is a valid safe FQDN-style value.""" + return bool(hostname) and _HOSTNAME_RE.match(hostname) is not None + + +def _read_managed_domain(key: str) -> str | None: + """Read the stored hostname for a managed domain key, or None if absent/empty.""" + try: + with open(os.path.join(DOMAINS_DIR, key), "r") as fh: + val = fh.read().strip() + return _normalize_hostname(val) if val else None + except OSError: + return None + + +def _check_domain_conflict(domain_name: str, new_hostname: str) -> str | None: + """Return the conflicting managed key if new_hostname is already used by another key. + + The uniqueness rule is applied symmetrically: if either the target key or the + conflicting candidate key is 'lightning', the check is enforced. + """ + for key in _MANAGED_DOMAIN_KEYS: + if key == domain_name: + continue # skip self; re-saving the same hostname is allowed + existing = _read_managed_domain(key) + if existing is None: + continue + if existing == new_hostname: + # Enforce when lightning is involved (either side) + if domain_name == _LIGHTNING_DOMAIN_KEY or key == _LIGHTNING_DOMAIN_KEY: + return key + return None + + @app.post("/api/domains/set") async def api_domains_set(req: DomainSetRequest): """Save a domain and optionally register a DDNS URL.""" if not _validate_safe_name(req.domain_name): raise HTTPException(status_code=400, detail="Invalid domain_name") + + # Normalize and validate the submitted hostname before any mutation. + normalized = _normalize_hostname(req.domain) + if not _validate_hostname(normalized): + raise HTTPException(status_code=400, detail="Invalid hostname value") + + # Reject duplicate managed-domain hostnames when lightning is involved. + if req.domain_name in _MANAGED_DOMAIN_KEYS: + conflicting_key = _check_domain_conflict(req.domain_name, normalized) + if conflicting_key is not None: + raise HTTPException( + status_code=409, + detail={ + "error": "domain_conflict", + "conflicting_domain_key": conflicting_key, + "message": ( + "Wallet Connections requires its own unique hostname. " + "Choose a new subdomain such as lightning.yourdomain.com. " + f"This hostname is already assigned to: {conflicting_key}." + ), + }, + ) + _ensure_domains_dir() domain_path = os.path.join(DOMAINS_DIR, req.domain_name) with open(domain_path, "w") as f: - f.write(req.domain.strip()) + f.write(normalized) _chown_to_caddy(domain_path) if req.ddns_url: diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 666f9c7..8b4339c 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -59,6 +59,8 @@ function openDomainSetupModal(feat, onSaved) { if (!$domainSetupModal) return; if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning"); + var npubField = ""; if (feat.id === "haven") { var currentNpub = ""; @@ -73,6 +75,17 @@ function openDomainSetupModal(feat, onSaved) { npubField = '
'; } + var nwcWarning = isWalletConnections + ? '
' + + '⚠ Wallet Connections requires its own unique hostname. ' + + 'Use a new subdomain such as lightning.yourdomain.com, or a separate domain. ' + + 'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' + + '
' + : ''; + + var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com"; + var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com"; + var introHtml; if (_currentRole === "node") { introHtml = @@ -89,6 +102,7 @@ function openDomainSetupModal(feat, onSaved) { $domainSetupBody.innerHTML = '
' + + nwcWarning + introHtml + '
' + 'Option A — Free subdomain (recommended)' + @@ -96,11 +110,11 @@ function openDomainSetupModal(feat, onSaved) { '
  • In Njal.la, open a domain you own and click "Add record".
  • ' + '
  • Set record type to Dynamic.
  • ' + '
  • In the Name field, type ONLY the host part — the word before your domain.
    ' + - '(Example only, your choice — for "call.yourdomain.com" you'd type just:  call)
    ' + + '(Example only, your choice — for "' + domainLabelExample + '" you'd type just:  ' + (isWalletConnections ? 'lightning' : 'call') + ')
    ' + '⚠ Do NOT type the full domain here — Njal.la adds it automatically.
  • ' + '
  • A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.
  • ' + '
  • Copy the curl command Njal.la gives you, e.g.:
    ' + - 'curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"
  • ' + + 'curl "https://njal.la/update/?h=' + domainLabelExample + '&k=abc123&auto"' + '' + '
    ' + '
    ' + @@ -111,10 +125,10 @@ function openDomainSetupModal(feat, onSaved) { '
  • Copy the curl command Njal.la gives you.
  • ' + '' + '
    ' + - '

    Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.

    ' + + '

    Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.

    ' + '
    ' + - '
    ' + - '

    ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

    ' + + '
    ' + + '

    ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

    ' + npubField + '
    '; @@ -150,7 +164,8 @@ function openDomainSetupModal(feat, onSaved) { } catch (err) { saveBtn.disabled = false; saveBtn.textContent = "Save & Enable"; - alert("Failed to save domain. Please try again."); + var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again."; + alert(msg); } }); @@ -161,6 +176,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) { if (!$domainSetupModal) return; if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name; + var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning"); + var npubField = ""; if (feat.id === "haven") { var currentNpub = ""; @@ -175,11 +192,23 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) { npubField = '
    '; } + var nwcWarning = isWalletConnections + ? '
    ' + + '⚠ Wallet Connections requires its own unique hostname. ' + + 'Use a new subdomain such as lightning.yourdomain.com, or a separate domain. ' + + 'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' + + '
    ' + : ''; + + var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com"; + var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com"; + var externalIp = _cachedExternalIp || "your external IP"; var currentDomain = existingDomain || ""; $domainSetupBody.innerHTML = '
    ' + + nwcWarning + '

    Your domain ' + escHtml(currentDomain || "this domain") + ' is configured but isn\'t resolving correctly.

    ' + '

    Troubleshooting steps:

    ' + '
      ' + @@ -191,8 +220,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) { '
    1. If you changed the DDNS curl command, paste the updated one below
    2. ' + '
    ' + '
    ' + - '
    ' + - '

    ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

    ' + + '
    ' + + '

    ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

    ' + npubField + '
    '; @@ -228,7 +257,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) { } catch (err) { saveBtn.disabled = false; saveBtn.textContent = "Save & Update"; - alert("Failed to save domain. Please try again."); + var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again."; + alert(msg); } }); diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index 24f8caf..120a1e1 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -55,7 +55,16 @@ async function apiFetch(path, options) { const res = await fetch(path, options || {}); if (!res.ok) { let detail = res.status + " " + res.statusText; - try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} + try { + const body = await res.json(); + if (body && body.detail) { + if (typeof body.detail === "string") { + detail = body.detail; + } else if (body.detail && typeof body.detail.message === "string") { + detail = body.detail.message; + } + } + } catch (e) {} throw new Error(detail); } return res.json(); diff --git a/app/tests/test_domain_conflict.py b/app/tests/test_domain_conflict.py new file mode 100644 index 0000000..f28bffd --- /dev/null +++ b/app/tests/test_domain_conflict.py @@ -0,0 +1,506 @@ +""" +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): + # After normalization a trailing dot should have been removed. + 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()