"""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"]