Route de diagnostic dashboard (read-only) : restitue les logs poussés par un poste, rangés par machine_id. Bearer global ; volontairement sans garde fleet (consultation d'un poste révoqué/en panne). limit=tail pour borner la réponse. 4 tests d'intégration verts ; store inchangé (briques 1-2 figées). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Tests d'intégration de l'endpoint POST /api/v1/agents/logs (push-log-DGX).
|
|
|
|
Le client Léa pousse ses logs (batch JSON) vers le DGX ; le serveur les range
|
|
par machine_id (AgentLogsStore) pour consultation au dashboard — diagnostic des
|
|
postes sans AnyDesk. Mêmes garde-fous fleet que stream/poll (agent actif).
|
|
|
|
Branche feat/push-log-dgx — DETTE-020/021.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_ROOT = str(Path(__file__).resolve().parents[2])
|
|
if _ROOT not in sys.path:
|
|
sys.path.insert(0, _ROOT)
|
|
|
|
|
|
_TEST_API_TOKEN = "test_token_logs_endpoint_0123456789abcdef"
|
|
|
|
|
|
@pytest.fixture
|
|
def logs_client(monkeypatch, tmp_path):
|
|
"""Client FastAPI de test avec registre ET store de logs isolés sur disque."""
|
|
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
|
monkeypatch.setenv("RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db"))
|
|
|
|
from fastapi.testclient import TestClient
|
|
from agent_v0.server_v1 import api_stream
|
|
from agent_v0.server_v1.agent_registry import AgentRegistry
|
|
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
|
|
|
|
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
|
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
|
|
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
|
|
test_store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
|
|
monkeypatch.setattr(api_stream, "agent_logs_store", test_store, raising=False)
|
|
|
|
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
|
yield client, _TEST_API_TOKEN, test_store
|
|
|
|
|
|
def _auth_headers(token: str) -> dict:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _enroll(client, token, machine_id):
|
|
return client.post(
|
|
"/api/v1/agents/enroll",
|
|
json={"machine_id": machine_id, "user_name": machine_id},
|
|
headers=_auth_headers(token),
|
|
)
|
|
|
|
|
|
def test_post_logs_persists_for_active_agent(logs_client):
|
|
client, token, store = logs_client
|
|
_enroll(client, token, "lea-emilie-001")
|
|
|
|
payload = {
|
|
"machine_id": "lea-emilie-001",
|
|
"logs": [
|
|
{"ts": "2026-06-26T16:00:00", "level": "WARNING",
|
|
"logger": "agent_v1.core.executor", "message": "popup detectee"},
|
|
],
|
|
}
|
|
resp = client.post(
|
|
"/api/v1/agents/logs", json=payload, headers=_auth_headers(token)
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["received"] == 1
|
|
stored = store.read("lea-emilie-001")
|
|
assert len(stored) == 1
|
|
assert stored[0]["message"] == "popup detectee"
|
|
assert stored[0]["level"] == "WARNING"
|
|
|
|
|
|
def test_post_logs_without_token_returns_401(logs_client):
|
|
client, _, _ = logs_client
|
|
resp = client.post(
|
|
"/api/v1/agents/logs", json={"machine_id": "lea-001", "logs": []}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_post_logs_rejected_for_revoked_agent(logs_client):
|
|
"""Un poste révoqué ne peut plus pousser de logs (même garde-fou que stream/poll)."""
|
|
client, token, store = logs_client
|
|
_enroll(client, token, "lea-revoked")
|
|
client.post(
|
|
"/api/v1/agents/uninstall",
|
|
json={"machine_id": "lea-revoked", "reason": "admin_revoke"},
|
|
headers=_auth_headers(token),
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/v1/agents/logs",
|
|
json={"machine_id": "lea-revoked", "logs": [{"message": "x"}]},
|
|
headers=_auth_headers(token),
|
|
)
|
|
|
|
assert resp.status_code == 403, resp.text
|
|
assert resp.json()["detail"]["error"] == "agent_not_active"
|
|
assert store.read("lea-revoked") == [] # rien persisté
|
|
|
|
|
|
def test_post_logs_rejects_oversized_batch(logs_client):
|
|
"""Anti-flood (G3) : un batch trop volumineux est rejeté (413), rien persisté."""
|
|
client, token, store = logs_client
|
|
_enroll(client, token, "lea-flood")
|
|
big = [{"level": "INFO", "message": f"l{i}"} for i in range(1001)]
|
|
|
|
resp = client.post(
|
|
"/api/v1/agents/logs",
|
|
json={"machine_id": "lea-flood", "logs": big},
|
|
headers=_auth_headers(token),
|
|
)
|
|
|
|
assert resp.status_code == 413, resp.text
|
|
assert store.read("lea-flood") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Brique 3 — lecture des logs par machine_id (route dashboard, read-only).
|
|
# Lecture admin/diagnostic : PAS de garde fleet (on veut justement pouvoir
|
|
# consulter un poste révoqué ou en panne) ; seul le Bearer protège.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_logs_returns_persisted_for_machine(logs_client):
|
|
"""GET /agents/logs/{machine_id} restitue les logs stockés, dans l'ordre."""
|
|
client, token, store = logs_client
|
|
store.append(
|
|
"lea-emilie-001",
|
|
[
|
|
{"ts": "2026-06-26T16:00:00", "level": "INFO", "message": "demarrage"},
|
|
{"ts": "2026-06-26T16:00:01", "level": "WARNING", "message": "popup"},
|
|
],
|
|
)
|
|
|
|
resp = client.get(
|
|
"/api/v1/agents/logs/lea-emilie-001", headers=_auth_headers(token)
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["machine_id"] == "lea-emilie-001"
|
|
assert body["count"] == 2
|
|
assert body["total"] == 2
|
|
assert body["logs"][0]["message"] == "demarrage"
|
|
assert body["logs"][1]["level"] == "WARNING"
|
|
|
|
|
|
def test_get_logs_without_token_returns_401(logs_client):
|
|
client, _, _ = logs_client
|
|
resp = client.get("/api/v1/agents/logs/lea-emilie-001")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_get_logs_empty_for_unknown_machine(logs_client):
|
|
"""Un poste sans log remonte une liste vide (200), pas une erreur."""
|
|
client, token, _ = logs_client
|
|
resp = client.get(
|
|
"/api/v1/agents/logs/lea-inconnu", headers=_auth_headers(token)
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["count"] == 0
|
|
assert body["total"] == 0
|
|
assert body["logs"] == []
|
|
|
|
|
|
def test_get_logs_limit_returns_tail(logs_client):
|
|
"""`limit` borne la réponse aux N entrées les plus récentes (tail)."""
|
|
client, token, store = logs_client
|
|
store.append(
|
|
"lea-tail",
|
|
[{"level": "INFO", "message": f"m{i}"} for i in range(5)],
|
|
)
|
|
|
|
resp = client.get(
|
|
"/api/v1/agents/logs/lea-tail?limit=2", headers=_auth_headers(token)
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["total"] == 5
|
|
assert body["count"] == 2
|
|
assert [e["message"] for e in body["logs"]] == ["m3", "m4"]
|