Merge pull request #341 from naturallaw777/copilot/require-unique-hostname-for-wallet-connections
Require unique hostname for Wallet Connections; add server-side conflict validation
This commit is contained in:
@@ -4091,15 +4091,96 @@ def _validate_safe_name(name: str) -> bool:
|
|||||||
return bool(name) and _SAFE_NAME_RE.match(name) is not None
|
return bool(name) and _SAFE_NAME_RE.match(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# Hostname characters: letters, digits, hyphens only within labels; dots separate labels.
|
||||||
|
# Each label must start and end with a letter or digit; no consecutive dots.
|
||||||
|
_HOSTNAME_RE = re.compile(
|
||||||
|
r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[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")
|
@app.post("/api/domains/set")
|
||||||
async def api_domains_set(req: DomainSetRequest):
|
async def api_domains_set(req: DomainSetRequest):
|
||||||
"""Save a domain and optionally register a DDNS URL."""
|
"""Save a domain and optionally register a DDNS URL."""
|
||||||
if not _validate_safe_name(req.domain_name):
|
if not _validate_safe_name(req.domain_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid 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()
|
_ensure_domains_dir()
|
||||||
domain_path = os.path.join(DOMAINS_DIR, req.domain_name)
|
domain_path = os.path.join(DOMAINS_DIR, req.domain_name)
|
||||||
with open(domain_path, "w") as f:
|
with open(domain_path, "w") as f:
|
||||||
f.write(req.domain.strip())
|
f.write(normalized)
|
||||||
_chown_to_caddy(domain_path)
|
_chown_to_caddy(domain_path)
|
||||||
|
|
||||||
if req.ddns_url:
|
if req.ddns_url:
|
||||||
|
|||||||
@@ -171,3 +171,15 @@ domain-field-actions {
|
|||||||
.port-req-status {
|
.port-req-status {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Wallet Connections unique-hostname warning ───────────────────── */
|
||||||
|
|
||||||
|
.domain-nwc-warning {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 180, 0, 0.10);
|
||||||
|
border: 1px solid var(--warning-color, #f59e0b);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
if (!$domainSetupModal) return;
|
if (!$domainSetupModal) return;
|
||||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||||
|
|
||||||
|
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||||
|
|
||||||
var npubField = "";
|
var npubField = "";
|
||||||
if (feat.id === "haven") {
|
if (feat.id === "haven") {
|
||||||
var currentNpub = "";
|
var currentNpub = "";
|
||||||
@@ -73,6 +75,17 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nwcWarning = isWalletConnections
|
||||||
|
? '<div class="domain-nwc-warning">' +
|
||||||
|
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
|
||||||
|
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||||
|
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||||
|
'</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||||
|
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||||
|
|
||||||
var introHtml;
|
var introHtml;
|
||||||
if (_currentRole === "node") {
|
if (_currentRole === "node") {
|
||||||
introHtml =
|
introHtml =
|
||||||
@@ -89,6 +102,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
|
nwcWarning +
|
||||||
introHtml +
|
introHtml +
|
||||||
'<details style="margin-top:10px;">' +
|
'<details style="margin-top:10px;">' +
|
||||||
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
||||||
@@ -96,11 +110,11 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
||||||
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
||||||
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
||||||
'(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>)<br>' +
|
'(Example only, your choice — for "' + domainLabelExample + '" you'd type just: <code>' + (isWalletConnections ? 'lightning' : 'call') + '</code>)<br>' +
|
||||||
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
||||||
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
||||||
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
||||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>' +
|
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + domainLabelExample + '&k=abc123&auto"</code></li>' +
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
'</details>' +
|
'</details>' +
|
||||||
'<details style="margin-top:6px;">' +
|
'<details style="margin-top:6px;">' +
|
||||||
@@ -111,10 +125,10 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
'<li>Copy the curl command Njal.la gives you.</li>' +
|
'<li>Copy the curl command Njal.la gives you.</li>' +
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
'</details>' +
|
'</details>' +
|
||||||
'<p style="margin-top:10px;">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.</p>' +
|
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||||
npubField +
|
npubField +
|
||||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||||
|
|
||||||
@@ -150,7 +164,8 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.textContent = "Save & Enable";
|
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 (!$domainSetupModal) return;
|
||||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
|
||||||
|
|
||||||
|
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||||
|
|
||||||
var npubField = "";
|
var npubField = "";
|
||||||
if (feat.id === "haven") {
|
if (feat.id === "haven") {
|
||||||
var currentNpub = "";
|
var currentNpub = "";
|
||||||
@@ -175,11 +192,23 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nwcWarning = isWalletConnections
|
||||||
|
? '<div class="domain-nwc-warning">' +
|
||||||
|
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
|
||||||
|
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||||
|
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||||
|
'</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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 externalIp = _cachedExternalIp || "your external IP";
|
||||||
var currentDomain = existingDomain || "";
|
var currentDomain = existingDomain || "";
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
|
nwcWarning +
|
||||||
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
|
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
|
||||||
'<p><strong>Troubleshooting steps:</strong></p>' +
|
'<p><strong>Troubleshooting steps:</strong></p>' +
|
||||||
'<ol>' +
|
'<ol>' +
|
||||||
@@ -191,8 +220,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||||
npubField +
|
npubField +
|
||||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Update</button></div>';
|
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Update</button></div>';
|
||||||
|
|
||||||
@@ -228,7 +257,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.textContent = "Save & Update";
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,16 @@ async function apiFetch(path, options) {
|
|||||||
const res = await fetch(path, options || {});
|
const res = await fetch(path, options || {});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = res.status + " " + res.statusText;
|
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);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user