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 = '
Nostr Public Key (npub1...):
';
}
+ 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.
' +
'
' +
- 'Service domain (e.g. call.yourdomain.com):
' +
- '' +
+ 'Service domain (e.g. ' + domainLabelExample + '):
' +
+ '' +
npubField +
'Cancel Save & Enable
';
@@ -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 = 'Nostr Public Key (npub1...):
';
}
+ 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) {
'If you changed the DDNS curl command, paste the updated one below ' +
' ' +
'
' +
- 'Service domain (e.g. call.yourdomain.com):
' +
- '' +
+ 'Service domain (e.g. ' + domainLabelExample + '):
' +
+ '' +
npubField +
'Cancel Save & Update
';
@@ -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()