Files
OPNexus/backend/tests/test_mcp.py
T
Julian Boldt 218a1f14ac
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.18.0
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.

- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
  Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
  Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
  Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
  Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
  `api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
  offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
  Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
  erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
  0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
  Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
  auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
  Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
  Anfrage neu geladen.
2026-10-19 23:54:02 +02:00

106 lines
5.1 KiB
Python

"""Read-only MCP endpoint: protocol basics, tools, and that it cannot be used to escalate."""
import pytest
from tests.test_api_tokens import _bearer, _make
def _rpc(method, params=None, id_=1):
message = {"jsonrpc": "2.0", "method": method}
if id_ is not None:
message["id"] = id_
if params is not None:
message["params"] = params
return message
async def _call(api, name, **arguments):
resp = await api.post("/api/mcp", json=_rpc("tools/call", {"name": name, "arguments": arguments}))
assert resp.status_code == 200, resp.text
return resp.json()["result"]
@pytest.mark.asyncio
async def test_requires_authentication(anon_client):
assert (await anon_client.post("/api/mcp", json=_rpc("ping"))).status_code == 401
@pytest.mark.asyncio
async def test_handshake_and_tool_list_with_a_read_token(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
init = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2025-06-18"}))).json()["result"]
assert init["protocolVersion"] == "2025-06-18"
assert init["serverInfo"]["name"] == "opnexus" and "tools" in init["capabilities"]
# Unknown future version: answer with one we speak.
other = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2099-01-01"}))).json()["result"]
assert other["protocolVersion"] == "2025-06-18"
# A notification gets no body.
note = await api.post("/api/mcp", json=_rpc("notifications/initialized", id_=None))
assert note.status_code == 202 and note.content == b""
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
assert {"fleet_overview", "list_hosts", "recent_audit", "search", "api_get"} <= {t["name"] for t in tools}
assert all("inputSchema" in t and "path" not in t for t in tools)
assert (await api.post("/api/mcp", json=_rpc("ping"))).json()["result"] == {}
unknown = (await api.post("/api/mcp", json=_rpc("resources/list"))).json()
assert unknown["error"]["code"] == -32601
@pytest.mark.asyncio
async def test_get_is_405_and_bad_json_is_a_parse_error(client):
assert (await client.get("/api/mcp")).status_code == 405
bad = await client.post("/api/mcp", content=b"{nope", headers={"Content-Type": "application/json"})
assert bad.status_code == 400 and bad.json()["error"]["code"] == -32700
@pytest.mark.asyncio
async def test_tools_read_through_the_callers_own_permissions(client):
await client.post("/api/groups", json={"name": "findable-group"})
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
hosts = await _call(api, "list_hosts")
assert hosts["isError"] is False and hosts["content"][0]["text"] == "[]"
found = await _call(api, "search", q="findable")
assert "findable-group" in found["content"][0]["text"]
audit = await _call(api, "recent_audit", limit=5)
assert audit["isError"] is False
overview = await _call(api, "fleet_overview")
assert "pending_actions" in overview["content"][0]["text"]
@pytest.mark.asyncio
async def test_generic_reader_cannot_reach_credentials_or_secrets_and_never_writes(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
for path in ("/api/tokens", "/api/auth/me", "/api/mcp", "/etc/passwd", "/api/../x", "/api/hosts?x=1", "relative"):
result = await _call(api, "api_get", path=path)
assert result["isError"] is True, path
# Secret-bearing endpoint: refused by the caller's own (read) scope, not by the tool.
denied = await _call(api, "api_get", path="/api/notifications/settings")
assert denied["isError"] is True and "API_TOKEN_SCOPE_FORBIDDEN" in denied["content"][0]["text"]
# api_get is GET-only by construction: there is no way to name another method.
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
schema = next(t for t in tools if t["name"] == "api_get")["inputSchema"]
assert set(schema["properties"]) == {"path", "query"}
@pytest.mark.asyncio
async def test_unknown_tool_and_bad_arguments_are_tool_errors(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
assert (await _call(api, "delete_everything"))["isError"] is True
assert (await _call(api, "host_resources"))["isError"] is True
bad = await api.post("/api/mcp", json=_rpc("tools/call", {"arguments": {}}))
assert bad.json()["error"]["code"] == -32602
@pytest.mark.asyncio
async def test_read_only_users_session_can_use_it_too(client):
resp = await client.post("/api/users", json={"username": "mcpviewer", "password": "viewer-password-123", "role": "read_only"})
assert resp.status_code == 201
from tests.test_api_tokens import _login
viewer = await _login("mcpviewer", "viewer-password-123")
try:
assert (await viewer.post("/api/mcp", json=_rpc("ping"))).status_code == 200
finally:
await viewer.aclose()