i18n: refine Slovak linked nodes and localize validation errors

This commit is contained in:
VAIO73
2026-10-20 06:46:15 +00:00
parent 65f47ec646
commit 9ffd154a2a
9 changed files with 379 additions and 40 deletions
+3
View File
@@ -13,6 +13,8 @@ import sys
import tempfile import tempfile
PYTHON_FILES = ( 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_icon_isolation.py",
"AppImage/scripts/tests/test_local_app_icons.py", "AppImage/scripts/tests/test_local_app_icons.py",
"AppImage/scripts/tests/test_docker_app_discovery.py", "AppImage/scripts/tests/test_docker_app_discovery.py",
@@ -28,6 +30,7 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py", "AppImage/scripts/tests/test_health_persistence_recurrence.py",
) )
NODE_FILES = ( NODE_FILES = (
"tests/test_monitor_links_i18n.cjs",
"tests/lxc_updates/test_local_app_icons_ui.cjs", "tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs", "tests/lxc_updates/test_docker_app_discovery_ui.cjs",
"tests/lxc_updates/test_native_app_discovery_ui.cjs", "tests/lxc_updates/test_native_app_discovery_ui.cjs",
+13 -9
View File
@@ -14,6 +14,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu" } from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { monitorLinkError } from "../lib/monitor-link-errors"
import { useT } from "../lib/i18n/provider" import { useT } from "../lib/i18n/provider"
interface MonitorLink { interface MonitorLink {
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
await refreshCache("/api/monitor-links") await refreshCache("/api/monitor-links")
setOfferOrigin(false) setOfferOrigin(false)
} catch (err) { } catch (err) {
await refreshCache("/api/monitor-links") setAddError(monitorLinkError(err, t))
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) try { await refreshCache("/api/monitor-links") }
catch { /* SWR shows the load error; keep the localized mutation error. */ }
} finally { setAdding(false) } } finally { setAdding(false) }
} }
const notNow = () => { const notNow = () => {
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
</DropdownMenu> </DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />} {manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}> {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> <DialogHeader>
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle> <DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription> <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 }) { function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT() 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 [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({}) const [addresses, setAddresses] = useState<Record<string, string>>({})
const [editing, setEditing] = useState<string | null>(null) const [editing, setEditing] = useState<string | null>(null)
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
await mutate() await mutate()
await refreshCache("/api/monitor-links") await refreshCache("/api/monitor-links")
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) setError(monitorLinkError(err, t))
} finally { setBusy(false) } } finally { setBusy(false) }
} }
const send = (path: string, method: string, body?: unknown) => const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}> 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()}> onClick={event => event.stopPropagation()}>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle> <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"> <div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p> <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"> <div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label> <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 })} /> <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>} {error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3"> <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.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => { {data?.links.map(link => {
const own = link.manual || link.custom_url 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> <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
</div> </div>
: isEditing : isEditing
? <div className="flex gap-2"> ? <div className="grid grid-cols-2 gap-2 sm:flex">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")} <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}` : ""}`} placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} /> 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" <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"
+29
View File
@@ -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()
}
+12 -1
View File
@@ -6616,6 +6616,17 @@
"addSelected": "Add selected", "addSelected": "Add selected",
"pbsWebInterface": "PBS web interface", "pbsWebInterface": "PBS web interface",
"changeAddress": "Change address", "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."
}
} }
} }
+23 -12
View File
@@ -7372,41 +7372,52 @@
}, },
"monitorLinks": { "monitorLinks": {
"groups": { "groups": {
"cluster": "Cluster", "cluster": "Klaster",
"standalone": "Samostatné uzly", "standalone": "Samostatné uzly",
"pbs": "Proxmox Backup Server" "pbs": "Proxmox Backup Server"
}, },
"thisNode": "Tento uzol", "thisNode": "Tento uzol",
"checking": "Kontrolujú sa uzly…", "checking": "Kontrolujú sa uzly…",
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.", "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ď", "noResponse": "Žiadna odpoveď",
"pbsInterface": "rozhranie PBS", "pbsInterface": "rozhranie PBS",
"openNewTab": "Otvoriť na novej karte", "openNewTab": "Otvoriť na novej karte",
"manage": "Pridať alebo spravovať uzly…", "manage": "Pridať alebo spravovať uzly…",
"dialogTitle": "Prepojené 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", "addTitle": "Pridať uzol",
"name": "Meno", "name": "Názov uzla",
"address": "Adresa monitora", "address": "Adresa Monitora",
"type": "Typ", "type": "Typ",
"add": "Pridať", "add": "Pridať",
"save": "Uložiť", "save": "Uložiť",
"remove": "Odstrániť", "remove": "Odstrániť",
"visible": "Viditeľné", "visible": "Zobraziť",
"autoAddress": "Automaticky", "autoAddress": "Automaticky",
"detected": "Zistené", "detected": "Zistený",
"added": "Pridané", "added": "Pridaný",
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.", "saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
"backTo": "Späť na {name}", "backTo": "Späť na {name}",
"cameFromTitle": "Pridať uzol monitora?", "cameFromTitle": "Pridať uzol Monitora?",
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.", "cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
"notNow": "Teraz nie", "notNow": "Teraz nie",
"knownTitle": "Pridať prepojené uzly?", "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é", "addSelected": "Pridať vybrané",
"pbsWebInterface": "Webové rozhranie PBS", "pbsWebInterface": "Webové rozhranie PBS",
"changeAddress": "Zmeniť adresu", "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ť."
}
} }
} }
+13 -6
View File
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
monitor_links_bp = Blueprint("monitor_links", __name__) 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 @monitor_links_bp.after_request
def no_cache(response): def no_cache(response):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
@@ -27,9 +34,9 @@ def add_link():
monitor_links.refresh() monitor_links.refresh()
return jsonify(success=True, key=key) return jsonify(success=True, key=key)
except monitor_links.LinkError as exc: except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400 return link_error(exc)
except OSError: 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"]) @monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
@@ -40,9 +47,9 @@ def update_link(key):
monitor_links.refresh() monitor_links.refresh()
return jsonify(success=True) return jsonify(success=True)
except monitor_links.LinkError as exc: except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400 return link_error(exc)
except OSError: 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"]) @monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
@@ -53,6 +60,6 @@ def delete_link(key):
monitor_links.refresh() monitor_links.refresh()
return jsonify(success=True) return jsonify(success=True)
except monitor_links.LinkError as exc: except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400 return link_error(exc)
except OSError: 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
+15 -12
View File
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
class LinkError(ValueError): 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(): def _load():
@@ -63,19 +66,19 @@ def _save(data):
def normalize_url(value): def normalize_url(value):
"""Return the origin of an http(s) address, or raise LinkError.""" """Return the origin of an http(s) address, or raise LinkError."""
if not isinstance(value, str) or not value.strip(): 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() text = value.strip()
if "://" not in text: if "://" not in text:
text = "https://" + text text = "https://" + text
parsed = urllib.parse.urlsplit(text) parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname: 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: 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: try:
port = parsed.port port = parsed.port
except ValueError as exc: 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 host = parsed.hostname
if ":" in host: if ":" in host:
host = f"[{host}]" host = f"[{host}]"
@@ -301,16 +304,16 @@ def start_prewarmer():
def add_link(payload): def add_link(payload):
if not isinstance(payload, dict): 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] name = str(payload.get("name") or "").strip()[:64]
url = normalize_url(payload.get("url")) url = normalize_url(payload.get("url"))
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve" product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
with _lock: with _lock:
data = _load() data = _load()
if len(data["manual"]) >= MAX_MANUAL: 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"]): 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) identifier = secrets.token_hex(6)
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname, data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
"url": url, "product": product}) "url": url, "product": product})
@@ -320,13 +323,13 @@ def add_link(payload):
def update_link(key, payload): def update_link(key, payload):
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise LinkError("Invalid request.") raise LinkError("Invalid request.", code="monitor_link_invalid_request")
with _lock: with _lock:
data = _load() data = _load()
if key.startswith("manual:"): if key.startswith("manual:"):
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None) row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
if row is None: if row is None:
raise LinkError("Node not found.") raise LinkError("Node not found.", code="monitor_link_not_found")
if "name" in payload: if "name" in payload:
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"] row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
if payload.get("url"): if payload.get("url"):
@@ -345,7 +348,7 @@ def update_link(key, payload):
if not override: if not override:
data["overrides"].pop(key, None) data["overrides"].pop(key, None)
else: else:
raise LinkError("Node not found.") raise LinkError("Node not found.", code="monitor_link_not_found")
_save(data) _save(data)
@@ -354,6 +357,6 @@ def delete_link(key):
data = _load() data = _load()
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key] remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
if len(remaining) == len(data["manual"]): 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 data["manual"] = remaining
_save(data) _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()
+133
View File
@@ -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;});