Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py
Claude Agent 7abefa2b46 feat(discord-bridge): dashboard de control si restart, dupa modelul agentului echo
Panou web pe 127.0.0.1:18790, unit systemd separat de al puntii. Server stdlib
(fara dependinte noi), tokenii de design si tiparul de endpoint-uri preluate din
/home/moltbot/echo-core/dashboard (handlers/eco.py) de pe LXC 110.

Arata: starea unitatii (uptime, PID, memoria cgroup, restarturi), firele din
state.json cu tur in zbor si cost, costul zilei fata de plafon, confirmarile
PreToolUse in asteptare (aprobabile direct din pagina), bot.log / infra.log si
opt verificari de diagnostic.

Face: start / stop / restart pe punte, cautarea si curatarea orfanilor prin
cleanup.py, repornirea propriului serviciu.

Garantii, cu teste:
- unitatea controlata e fixa in cod; un {"unit": "ssh.service"} in cerere nu
  schimba nimic, altfel panoul ar fi systemctl remote fara parola;
- stop/restart intorc 409 cu lista firelor active si cer force explicit, fiindca
  KillMode=control-group taie tururile in desfasurare;
- state.json se citeste fara lock: panoul nu are voie sa blocheze botul;
- diagnosticul pica daca reapare Bash(ssh:*) in deny (regresia de azi).

Uptime-ul se calculeaza din time.monotonic(), nu din /proc/uptime: in LXC acela
e virtualizat de lxcfs si da diferenta negativa fata de monotonic-ul systemd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 13:18:38 +00:00

337 lines
12 KiB
Python

"""Teste pentru dashboard-ul de control (dashboard/api.py).
Zero retea catre exterior si zero systemctl real: `_sysctl` e inlocuit in fiecare
test cu un dublu care inregistreaza argumentele. Serverul HTTP porneste pe un port
efemer legat de 127.0.0.1, exact cum ruleaza in productie.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
import pytest
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "dashboard"))
import config # noqa: E402
api = pytest.importorskip("api", reason="dashboard/api.py")
# --- ajutoare ---------------------------------------------------------------
def _cp(stdout: str = "", rc: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr=stderr)
@pytest.fixture()
def systemctl_fals(monkeypatch):
"""Inlocuieste systemctl. `apeluri` retine ce s-ar fi executat."""
apeluri: list[tuple[str, ...]] = []
props = {
"ActiveState": "active",
"SubState": "running",
"UnitFileState": "enabled",
"MainPID": "4242",
"MemoryCurrent": "1048576",
"NRestarts": "3",
"ActiveEnterTimestampMonotonic": "0",
"ActiveEnterTimestamp": "",
}
def fake(*args, timeout=30.0):
apeluri.append(tuple(args))
if args[0] == "show":
return _cp(props.get(args[2], ""))
return _cp("")
monkeypatch.setattr(api, "_sysctl", fake)
fake.apeluri = apeluri # type: ignore[attr-defined]
fake.props = props # type: ignore[attr-defined]
return fake
@pytest.fixture()
def server(state_dir, systemctl_fals, monkeypatch):
"""Dashboard-ul pe un port efemer + un client mic cu cookie."""
(state_dir / "env").write_text("DASHBOARD_TOKEN=secret-de-test\n", encoding="utf-8")
config.reload(state_dir)
api.reset_token_cache()
srv = ThreadingHTTPServer(("127.0.0.1", 0), api.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_address[1]}"
class Client:
def __init__(self):
self.cookie = ""
def call(self, path, data=None, method=None):
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
method=method or ("POST" if data is not None else "GET"),
)
req.add_header("Content-Type", "application/json")
if self.cookie:
req.add_header("Cookie", self.cookie)
def _json(body: str):
"""Rutele /api/ intorc JSON; paginile intorc HTML — nu esuam pe ele."""
try:
return json.loads(body) if body else {}
except ValueError:
return {"_html": body}
try:
with urllib.request.urlopen(req, timeout=10) as r:
sc = r.headers.get("Set-Cookie")
if sc:
self.cookie = sc.split(";", 1)[0]
return r.status, _json(r.read().decode()), r
except urllib.error.HTTPError as e:
return e.code, _json(e.read().decode()), e
def login(self, token="secret-de-test"):
return self.call("/api/auth/login", {"token": token})
try:
yield Client()
finally:
srv.shutdown()
srv.server_close()
api.reset_token_cache()
def _scrie_stare(state_dir, threads: dict, cost: float = 1.5):
(state_dir / "state.json").write_text(json.dumps({
"version": 1,
"threads": threads,
"cost": {"day": "2026-08-30", "usd": cost},
}), encoding="utf-8")
# --- autentificare ----------------------------------------------------------
def test_api_fara_cookie_da_401(server):
assert server.call("/api/status")[0] == 401
def test_index_fara_cookie_redirectioneaza_la_login(server):
status, data, resp = server.call("/")
# urllib urmareste redirectul singur: ajungem pe pagina de login, nu pe index
assert status == 200
assert resp.url.endswith("/login.html")
assert "DASHBOARD_TOKEN" in data["_html"]
def test_token_gresit_e_refuzat(server):
assert server.login("gresit")[0] == 401
assert server.call("/api/status")[0] == 401
def test_login_apoi_status(server):
assert server.login()[0] == 200
status, data, _ = server.call("/api/status")
assert status == 200
assert data["service"]["unit"] == api.SERVICE
assert data["service"]["restarts"] == 3
assert data["service"]["memory_bytes"] == 1048576
def test_logout_invalideaza_cookie(server):
server.login()
server.call("/api/auth/logout", {})
server.cookie = "dashboard="
assert server.call("/api/status")[0] == 401
def test_token_lipsa_din_env_nu_deschide_dashboardul(state_dir, monkeypatch):
"""Fara DASHBOARD_TOKEN se genereaza unul aleator, nu se sare peste auth."""
(state_dir / "env").write_text("", encoding="utf-8")
config.reload(state_dir)
api.reset_token_cache()
try:
tok = api.dashboard_token()
assert len(tok) >= 20
assert api.dashboard_token() == tok # stabil in cadrul procesului
finally:
api.reset_token_cache()
# --- control de serviciu ----------------------------------------------------
def test_actiune_necunoscuta_e_respinsa(server):
server.login()
status, data, _ = server.call("/api/service", {"action": "mask"})
assert status == 400
assert not data["ok"]
def test_nu_se_poate_alege_unitatea_din_request(server, systemctl_fals):
"""Chiar daca cererea cere alt unit, se actioneaza tot pe puntea Discord."""
server.login()
server.call("/api/service", {"action": "restart", "service": "ssh.service",
"unit": "ssh.service"})
actiuni = [a for a in systemctl_fals.apeluri if a[0] == "restart"]
assert actiuni == [("restart", api.SERVICE)]
def test_restart_blocat_de_tur_in_zbor(server, state_dir, systemctl_fals):
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
status, data, _ = server.call("/api/service", {"action": "restart"})
assert status == 409
assert data["inflight"] == ["111"]
assert not [a for a in systemctl_fals.apeluri if a[0] == "restart"]
def test_restart_cu_force_trece_peste_tur(server, state_dir, systemctl_fals):
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
status, data, _ = server.call("/api/service", {"action": "restart", "force": True})
assert status == 200 and data["ok"]
assert ("restart", api.SERVICE) in systemctl_fals.apeluri
def test_start_nu_cere_force(server, state_dir, systemctl_fals):
"""`start` nu poate intrerupe nimic, deci nu are de ce sa fie blocat."""
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
assert server.call("/api/service", {"action": "start"})[0] == 200
def test_esecul_systemctl_ajunge_la_client(server, monkeypatch):
server.login()
monkeypatch.setattr(api, "_sysctl",
lambda *a, timeout=30.0: _cp(rc=1, stderr="Unit not found"))
status, data, _ = server.call("/api/service", {"action": "restart"})
assert status == 500 and "Unit not found" in data["error"]
# --- stare, jurnale, diagnostic --------------------------------------------
def test_threads_view_marcheaza_tur_in_zbor(state_dir):
_scrie_stare(state_dir, {
"a": {"cwd": "/workspace/x", "model": "sonnet", "cost_usd_total": 2.5,
"last_active": 10, "inflight": {"turn_id": "t"}},
"b": {"cwd": "/workspace/y", "model": "opus", "last_active": 20},
})
view = api.threads_view(api.read_state())
assert [t["thread_id"] for t in view] == ["b", "a"] # sortare desc dupa activitate
assert view[1]["inflight"] and not view[0]["inflight"]
assert view[1]["cost_usd"] == 2.5
def test_state_corupt_nu_arunca(state_dir):
(state_dir / "state.json").write_text("{ nu e json", encoding="utf-8")
assert api.read_state() == {}
assert api.threads_view(api.read_state()) == []
def test_logs_taie_si_plafoneaza(server, state_dir):
server.login()
(config.LOG_DIR).mkdir(parents=True, exist_ok=True)
api.bot_log().write_text("\n".join(f"linia {i}" for i in range(500)), encoding="utf-8")
_, data, _ = server.call("/api/logs?lines=5")
assert data["lines"] == [f"linia {i}" for i in range(495, 500)]
def test_logs_fisier_inexistent(server):
server.login()
_, data, _ = server.call("/api/logs?file=infra")
assert "nu exista" in data["lines"][0]
def test_doctor_prinde_deny_ul_care_taie_ssh(server, state_dir):
"""Regresia din 2026-08-30: Bash(ssh:*) in deny a taiat accesul la infra."""
server.login()
(state_dir / "bot-settings.json").write_text(json.dumps(
{"permissions": {"deny": ["Bash(ssh:*)", "Bash(qm destroy:*)"]}}), encoding="utf-8")
_, data, _ = server.call("/api/doctor")
check = next(c for c in data["checks"] if "deny" in c["name"])
assert not check["pass"] and "Bash(ssh:*)" in check["detail"]
def test_doctor_accepta_deny_ul_curatat(server, state_dir):
server.login()
(state_dir / "bot-settings.json").write_text(json.dumps(
{"permissions": {"deny": ["Bash(qm destroy:*)"]}}), encoding="utf-8")
_, data, _ = server.call("/api/doctor")
check = next(c for c in data["checks"] if "deny" in c["name"])
assert check["pass"]
# --- aprobari ---------------------------------------------------------------
def _cerere(state_dir, rid="abc123", status="pending"):
config.APPROVALS_DIR.mkdir(parents=True, exist_ok=True)
(config.APPROVALS_DIR / f"{rid}.json").write_text(json.dumps({
"request_id": rid, "thread_id": "111", "tool_name": "Bash",
"command": "rm -rf /var/tmp/x", "rule": "rm_recursiv",
"reason": "stergere recursiva", "created_at": time.time(),
"expires_at": time.time() + 300, "status": status,
}), encoding="utf-8")
def test_approvals_arata_doar_pending(server, state_dir):
server.login()
_cerere(state_dir, "aaa", "pending")
_cerere(state_dir, "bbb", "allow")
_, data, _ = server.call("/api/approvals")
assert [a["request_id"] for a in data["approvals"]] == ["aaa"]
assert data["approvals"][0]["expires_in"] > 0
def test_status_numara_aprobarile(server, state_dir):
server.login()
_cerere(state_dir, "aaa")
_, data, _ = server.call("/api/status")
assert data["pending_approvals"] == 1
def test_decizie_invalida_e_respinsa(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "aaa", "decision": "poate"})[0] == 400
def test_request_id_cu_traversare_e_respins(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "../../etc/passwd", "decision": "allow"})[0] == 400
def test_decizia_ajunge_in_fisierul_cererii(server, state_dir):
server.login()
_cerere(state_dir, "aaa")
status, data, _ = server.call("/api/approvals/decide",
{"request_id": "aaa", "decision": "allow"})
assert status == 200 and data["ok"]
scris = json.loads((config.APPROVALS_DIR / "aaa.json").read_text())
assert scris["status"] == "allow"
def test_cerere_inexistenta_da_404(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "nuexista", "decision": "allow"})[0] == 404
# --- rute -------------------------------------------------------------------
def test_ruta_necunoscuta(server):
server.login()
assert server.call("/api/nope")[0] == 404
assert server.call("/api/nope", {})[0] == 404