mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
i18n: refine Slovak linked nodes and localize validation errors
This commit is contained in:
@@ -13,6 +13,8 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
@@ -28,6 +30,7 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
await refreshCache("/api/monitor-links")
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setAddError(monitorLinkError(err, t))
|
||||
try { await refreshCache("/api/monitor-links") }
|
||||
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
const notNow = () => {
|
||||
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setError(monitorLinkError(err, t))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const own = link.manual || link.custom_url
|
||||
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
monitor_link_address_required: "addressRequired",
|
||||
monitor_link_invalid_address: "invalidAddress",
|
||||
monitor_link_address_credentials: "addressCredentials",
|
||||
monitor_link_invalid_port: "invalidPort",
|
||||
monitor_link_invalid_request: "invalidRequest",
|
||||
monitor_link_duplicate: "duplicate",
|
||||
monitor_link_not_found: "notFound",
|
||||
monitor_link_detected_remove: "detectedRemove",
|
||||
}
|
||||
|
||||
// fetchApi preserves the structured response on Error.body. Never expose raw
|
||||
// server/network messages or treat arbitrary error codes as translation keys.
|
||||
export function monitorLinkError(error: unknown, t: Translate): string {
|
||||
const fallback = () => t("monitorLinks.saveFailed")
|
||||
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
|
||||
const body = error.body
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
|
||||
if (body.code === "monitor_link_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? t("monitorLinks.errors.limit", { count }) : fallback()
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
|
||||
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
|
||||
}
|
||||
@@ -6616,6 +6616,17 @@
|
||||
"addSelected": "Add selected",
|
||||
"pbsWebInterface": "PBS web interface",
|
||||
"changeAddress": "Change address",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"addressRequired": "Enter the address of the Monitor.",
|
||||
"invalidAddress": "The address must start with http:// or https:// and name a host.",
|
||||
"addressCredentials": "The address must not contain a user or password.",
|
||||
"invalidPort": "The port of the address is not valid.",
|
||||
"invalidRequest": "The request is not valid. Reload the page and try again.",
|
||||
"limit": "At most {count} nodes can be added.",
|
||||
"duplicate": "This address is already in the list.",
|
||||
"notFound": "The node was not found. Reload the list and try again.",
|
||||
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7372,41 +7372,52 @@
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"cluster": "Klaster",
|
||||
"standalone": "Samostatné uzly",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Tento uzol",
|
||||
"checking": "Kontrolujú sa uzly…",
|
||||
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
|
||||
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
|
||||
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"name": "Názov uzla",
|
||||
"address": "Adresa Monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"visible": "Zobraziť",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"detected": "Zistený",
|
||||
"added": "Pridaný",
|
||||
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
|
||||
"backTo": "Späť na {name}",
|
||||
"cameFromTitle": "Pridať uzol monitora?",
|
||||
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
|
||||
"cameFromTitle": "Pridať uzol Monitora?",
|
||||
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
|
||||
"notNow": "Teraz nie",
|
||||
"knownTitle": "Pridať prepojené uzly?",
|
||||
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
|
||||
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
|
||||
"addSelected": "Pridať vybrané",
|
||||
"pbsWebInterface": "Webové rozhranie PBS",
|
||||
"changeAddress": "Zmeniť adresu",
|
||||
"cancel": "Zrušiť"
|
||||
"cancel": "Zrušiť",
|
||||
"errors": {
|
||||
"addressRequired": "Zadaj adresu Monitora.",
|
||||
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
|
||||
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
|
||||
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
|
||||
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
|
||||
"limit": "Môžeš pridať najviac {count} uzlov.",
|
||||
"duplicate": "Táto adresa už je v zozname.",
|
||||
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
|
||||
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
def link_error(exc):
|
||||
payload = {"error": str(exc), "code": exc.code}
|
||||
if exc.params:
|
||||
payload["params"] = exc.params
|
||||
return jsonify(payload), 400
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,9 +34,9 @@ def add_link():
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@@ -40,9 +47,9 @@ def update_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@@ -53,6 +60,6 @@ def delete_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -63,19 +66,19 @@ def _save(data):
|
||||
def normalize_url(value):
|
||||
"""Return the origin of an http(s) address, or raise LinkError."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LinkError("Enter the address of the Monitor.")
|
||||
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
|
||||
text = value.strip()
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.")
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
@@ -301,16 +304,16 @@ def start_prewarmer():
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
name = str(payload.get("name") or "").strip()[:64]
|
||||
url = normalize_url(payload.get("url"))
|
||||
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
|
||||
with _lock:
|
||||
data = _load()
|
||||
if len(data["manual"]) >= MAX_MANUAL:
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
@@ -320,13 +323,13 @@ def add_link(payload):
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
with _lock:
|
||||
data = _load()
|
||||
if key.startswith("manual:"):
|
||||
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
|
||||
if row is None:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
@@ -345,7 +348,7 @@ def update_link(key, payload):
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
_save(data)
|
||||
|
||||
|
||||
@@ -354,6 +357,6 @@ def delete_link(key):
|
||||
data = _load()
|
||||
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
|
||||
if len(remaining) == len(data["manual"]):
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Exercise the shipped routes and persistence; localization is additive."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.path = Path(temporary.name) / "links.json"
|
||||
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
|
||||
self.path.write_text(json.dumps(self.previous))
|
||||
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
|
||||
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
auth_provider = types.SimpleNamespace(
|
||||
load_auth_config=lambda: {"enabled": True},
|
||||
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
|
||||
if token in ("admin", "reader") else (None, None))
|
||||
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(middleware_spec)
|
||||
# Execute the shipped decorators; only credential verification is faked.
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
|
||||
middleware_spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
# Import isolation keeps the real JWT module and other suites untouched.
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.monitor_links_bp)
|
||||
self.client = app.test_client()
|
||||
|
||||
def send(self, method, path="", payload=None, role="admin"):
|
||||
return self.client.open("/api/monitor-links" + path, method=method,
|
||||
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def rejected(self, method, path, payload, message, code, params=None):
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch.object(links, "_save") as save:
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
expected = {"error": message, "code": code}
|
||||
if params:
|
||||
expected["params"] = params
|
||||
self.assertEqual(response.json, expected)
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
|
||||
cases = [
|
||||
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
|
||||
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
|
||||
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
|
||||
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
|
||||
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
|
||||
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
|
||||
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
|
||||
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
|
||||
]
|
||||
for case in cases:
|
||||
with self.subTest(case=case):
|
||||
self.rejected(*case)
|
||||
|
||||
def test_limit_uses_actual_count_and_preserves_previous_file(self):
|
||||
with mock.patch.object(links, "MAX_MANUAL", 1):
|
||||
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
|
||||
"monitor_link_limit", {"count": 1})
|
||||
|
||||
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
|
||||
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved"}),
|
||||
("DELETE", "/manual:saved", None)]:
|
||||
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
|
||||
before = self.path.read_bytes()
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
|
||||
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(set(response.json), {"success", "key"})
|
||||
key = response.json["key"]
|
||||
self.assertTrue(key.startswith("manual:"))
|
||||
row = links._load()["manual"][-1]
|
||||
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
|
||||
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
|
||||
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
|
||||
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"], self.previous["manual"])
|
||||
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(links.refresh.call_count, 5)
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
|
||||
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
|
||||
mock.patch.object(links, "_save") as save:
|
||||
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
|
||||
self.assertEqual(self.send("GET", role=None).status_code, 401)
|
||||
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
|
||||
cached.assert_called_once_with(include_hidden=True)
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(json.loads(self.path.read_text()), self.previous)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run the shipped translation adapter, transport and both dialog components.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText;
|
||||
const translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match);
|
||||
};
|
||||
const adapter = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter);
|
||||
const {monitorLinkError} = adapter.exports;
|
||||
const codes = {
|
||||
monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress',
|
||||
monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort',
|
||||
monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate',
|
||||
monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove',
|
||||
};
|
||||
const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}});
|
||||
|
||||
const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten)
|
||||
: typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree];
|
||||
const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
const jsx = (type, props) => ({type, props});
|
||||
const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};');
|
||||
|
||||
function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) {
|
||||
let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false;
|
||||
const calls = [], refreshes = [], storage = new Map();
|
||||
const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}];
|
||||
const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url');
|
||||
const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '',
|
||||
search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''};
|
||||
const react = {
|
||||
useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];},
|
||||
useEffect(fn, deps) {const slot = cursor++; const old = slots[slot];
|
||||
if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn);
|
||||
slots[slot] = deps;
|
||||
},
|
||||
};
|
||||
const exported = {};
|
||||
vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape,
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
||||
window: {location, history: {replaceState() {location.search = ''}}},
|
||||
sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)},
|
||||
require(name) {
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'};
|
||||
if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading,
|
||||
mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}};
|
||||
if (name.includes('i18n/provider')) return {useT: () => translate(locale)};
|
||||
if (name.includes('monitor-link-errors')) return {monitorLinkError};
|
||||
if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}};
|
||||
return new Proxy({}, {get: (_, key) => key});
|
||||
},
|
||||
});
|
||||
return {
|
||||
render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;},
|
||||
button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;},
|
||||
input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;},
|
||||
nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale);
|
||||
for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`));
|
||||
assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64}));
|
||||
for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}},
|
||||
...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)),
|
||||
...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) {
|
||||
assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed'));
|
||||
}
|
||||
const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]});
|
||||
f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render();
|
||||
f.button(t('monitorLinks.cancel')).props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 0, 'Cancel must not write');
|
||||
f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft');
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render();
|
||||
await f.button(t('monitorLinks.save')).props.onClick(); f.render();
|
||||
assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'});
|
||||
assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted');
|
||||
f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render();
|
||||
f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress')));
|
||||
assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft');
|
||||
assert.ok(!f.text().includes('RAW ENGLISH'));
|
||||
f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.saveFailed')));
|
||||
const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render();
|
||||
assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed')));
|
||||
const pendingRead = fixture({locale, loading: true}); pendingRead.render();
|
||||
assert.ok(pendingRead.text().includes(t('monitorLinks.checking')));
|
||||
|
||||
const declined = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher');
|
||||
declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher');
|
||||
assert.equal(declined.calls.length, 0, 'Not now must not write');
|
||||
const carried = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher');
|
||||
const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2);
|
||||
boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher');
|
||||
carried.fail(failure('monitor_link_duplicate'));
|
||||
carried.failRefresh();
|
||||
await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher');
|
||||
assert.equal(carried.calls.length, 1, 'Only selected origin is attempted');
|
||||
assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test');
|
||||
assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate')));
|
||||
assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list');
|
||||
}
|
||||
// Real authenticated transport preserves additive fields for the adapter.
|
||||
const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api);
|
||||
try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');}
|
||||
catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);}
|
||||
const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks;
|
||||
assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort());
|
||||
for (const key of Object.keys(en.errors)) {
|
||||
assert.ok(sk.errors[key]);
|
||||
assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key);
|
||||
}
|
||||
console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure');
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
Reference in New Issue
Block a user