diff --git a/agent_v0/server_v1/agent_registry.py b/agent_v0/server_v1/agent_registry.py new file mode 100644 index 000000000..9fa325052 --- /dev/null +++ b/agent_v0/server_v1/agent_registry.py @@ -0,0 +1,296 @@ +# agent_v0/server_v1/agent_registry.py +""" +Registre des agents Lea enrolles sur le parc. + +Alimente par les endpoints /api/v1/agents/enroll et /api/v1/agents/uninstall +que l'installeur Inno Setup (`deploy/installer/Lea.iss`) appelle a +l'installation et a la desinstallation sur chaque poste collaborateur. + +Stockage : SQLite simple, cohabite avec rpa_data.db dans data/databases/. +Aucune dependance GPU/LLM — ce module doit rester leger (juste sqlite3 + +stdlib) pour pouvoir etre importe par le serveur HTTP. + +Schema de la table `enrolled_agents` : + id INTEGER PK AUTOINCREMENT + machine_id TEXT UNIQUE NOT NULL — identifiant genere par l'installeur + user_name TEXT — nom affichage collaborateur + user_email TEXT + user_id TEXT — identifiant metier (ex: AIVA-001) + hostname TEXT + os_info TEXT + version TEXT — version du client Lea + status TEXT DEFAULT 'active' — 'active' | 'uninstalled' + enrolled_at TEXT NOT NULL — ISO 8601 UTC + last_seen_at TEXT — ISO 8601 UTC (heartbeat / stream) + uninstalled_at TEXT + uninstall_reason TEXT +""" + +from __future__ import annotations + +import logging +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Verrou global : SQLite tolere plusieurs threads mais on serialise +# les ecritures pour eviter les races sur _init_db + upserts concurrents. +_DB_LOCK = threading.Lock() + + +def _utc_now_iso() -> str: + """Horodatage ISO 8601 UTC (compatible toutes les autres tables).""" + return datetime.now(timezone.utc).isoformat() + + +class AgentRegistry: + """Gestion CRUD des agents enrolles (SQLite).""" + + def __init__(self, db_path: str | Path = "data/databases/rpa_data.db"): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + # ------------------------------------------------------------------ + # Infra SQLite + # ------------------------------------------------------------------ + + def _connect(self) -> sqlite3.Connection: + # check_same_thread=False : on protege nous-memes via _DB_LOCK, + # indispensable car FastAPI appelle les endpoints sur threads + # differents (thread pool). + conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_db(self) -> None: + """Cree la table et ses index si absents (idempotent).""" + with _DB_LOCK, self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS enrolled_agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id TEXT NOT NULL UNIQUE, + user_name TEXT, + user_email TEXT, + user_id TEXT, + hostname TEXT, + os_info TEXT, + version TEXT, + status TEXT NOT NULL DEFAULT 'active', + enrolled_at TEXT NOT NULL, + last_seen_at TEXT, + uninstalled_at TEXT, + uninstall_reason TEXT + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_enrolled_agents_status " + "ON enrolled_agents(status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine " + "ON enrolled_agents(machine_id)" + ) + + # ------------------------------------------------------------------ + # Lecture + # ------------------------------------------------------------------ + + def get(self, machine_id: str) -> Optional[Dict[str, Any]]: + """Recupere un agent par machine_id (ou None).""" + with _DB_LOCK, self._connect() as conn: + row = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + return dict(row) if row else None + + def list_by_status(self, status: str) -> List[Dict[str, Any]]: + """Liste les agents par statut ('active' | 'uninstalled').""" + with _DB_LOCK, self._connect() as conn: + rows = conn.execute( + "SELECT * FROM enrolled_agents WHERE status = ? " + "ORDER BY enrolled_at DESC", + (status,), + ).fetchall() + return [dict(r) for r in rows] + + def count_by_status(self, status: str) -> int: + with _DB_LOCK, self._connect() as conn: + row = conn.execute( + "SELECT COUNT(*) AS n FROM enrolled_agents WHERE status = ?", + (status,), + ).fetchone() + return int(row["n"]) if row else 0 + + # ------------------------------------------------------------------ + # Ecriture + # ------------------------------------------------------------------ + + def enroll( + self, + *, + machine_id: str, + user_name: str | None = None, + user_email: str | None = None, + user_id: str | None = None, + hostname: str | None = None, + os_info: str | None = None, + version: str | None = None, + allow_reactivate: bool = True, + ) -> Dict[str, Any]: + """Enregistre un nouvel agent ou reactive un agent desinstalle. + + Returns: + dict avec clefs {"created": bool, "reactivated": bool, "agent": row} + + Raises: + ValueError: si machine_id est vide. + AgentAlreadyEnrolledError: si deja actif (status=active). + """ + if not machine_id or not machine_id.strip(): + raise ValueError("machine_id est obligatoire") + machine_id = machine_id.strip() + + now = _utc_now_iso() + + with _DB_LOCK, self._connect() as conn: + existing = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + + if existing is not None: + if existing["status"] == "active": + # Deja enrolle et actif -> conflit explicit + raise AgentAlreadyEnrolledError(dict(existing)) + + # Agent desinstalle : reactivation si autorise (defaut) + if not allow_reactivate: + raise AgentAlreadyEnrolledError(dict(existing)) + + conn.execute( + """ + UPDATE enrolled_agents + SET user_name = COALESCE(?, user_name), + user_email = COALESCE(?, user_email), + user_id = COALESCE(?, user_id), + hostname = COALESCE(?, hostname), + os_info = COALESCE(?, os_info), + version = COALESCE(?, version), + status = 'active', + enrolled_at = ?, + last_seen_at = ?, + uninstalled_at = NULL, + uninstall_reason = NULL + WHERE machine_id = ? + """, + ( + user_name, user_email, user_id, + hostname, os_info, version, + now, now, machine_id, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + return {"created": False, "reactivated": True, "agent": dict(row)} + + # Nouvelle inscription + conn.execute( + """ + INSERT INTO enrolled_agents ( + machine_id, user_name, user_email, user_id, + hostname, os_info, version, + status, enrolled_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + """, + ( + machine_id, user_name, user_email, user_id, + hostname, os_info, version, + now, now, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + return {"created": True, "reactivated": False, "agent": dict(row)} + + def uninstall( + self, + *, + machine_id: str, + reason: str | None = None, + ) -> Optional[Dict[str, Any]]: + """Marque un agent comme desinstalle (soft delete). + + Returns: + Le row mis a jour, ou None si l'agent n'existe pas. + """ + if not machine_id or not machine_id.strip(): + raise ValueError("machine_id est obligatoire") + machine_id = machine_id.strip() + + now = _utc_now_iso() + with _DB_LOCK, self._connect() as conn: + existing = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + if existing is None: + return None + + conn.execute( + """ + UPDATE enrolled_agents + SET status = 'uninstalled', + uninstalled_at = ?, + uninstall_reason = ? + WHERE machine_id = ? + """, + (now, reason, machine_id), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", + (machine_id,), + ).fetchone() + return dict(row) + + def touch_last_seen(self, machine_id: str) -> None: + """Met a jour last_seen_at (appel depuis le stream / heartbeat). + + Silencieux si l'agent est inconnu (evite les erreurs sur vieux clients). + """ + if not machine_id: + return + now = _utc_now_iso() + with _DB_LOCK, self._connect() as conn: + conn.execute( + "UPDATE enrolled_agents SET last_seen_at = ? WHERE machine_id = ?", + (now, machine_id), + ) + conn.commit() + + +class AgentAlreadyEnrolledError(Exception): + """Levee si on tente d'enrouler une machine deja active.""" + + def __init__(self, existing_row: Dict[str, Any]): + self.existing = existing_row + super().__init__( + f"machine_id={existing_row.get('machine_id')} deja enrole " + f"(status={existing_row.get('status')})" + ) diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index 08f74065d..b285c17f1 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -30,6 +30,7 @@ from .replay_failure_logger import log_replay_failure from .replay_verifier import ReplayVerifier, VerificationResult from .replay_learner import ReplayLearner from .audit_trail import AuditTrail, AuditEntry +from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot from .worker_stream import StreamWorker from .execution_plan_runner import ( @@ -340,6 +341,14 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock" processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR)) worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor) +# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db) +# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests. +_AGENTS_DB_PATH = os.environ.get( + "RPA_AGENTS_DB_PATH", + str(ROOT_DIR / "data" / "databases" / "rpa_data.db"), +) +agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH) + # ========================================================================= # Flush garanti à l'arrêt — signal handler + atexit (ceinture et bretelles) @@ -563,6 +572,28 @@ class ErrorCallbackConfig(BaseModel): callback_url: str # URL à appeler en cas d'erreur non-récupérable +# ------------------------------------------------------------------------- +# Agent Fleet — enrollment / desinstallation +# Consommes par l'installeur Lea.iss (voir deploy/installer/) +# ------------------------------------------------------------------------- +class AgentEnrollRequest(BaseModel): + """Enregistrement d'un nouveau poste lors de l'installation Lea.""" + machine_id: str + user_name: Optional[str] = None + user_email: Optional[str] = None + user_id: Optional[str] = None + hostname: Optional[str] = None + os_info: Optional[str] = None + version: Optional[str] = None + + +class AgentUninstallRequest(BaseModel): + """Notification de desinstallation d'un poste.""" + machine_id: str + # reason = user_uninstall | admin_revoke | machine_retired (libre) + reason: Optional[str] = None + + # Thread de nettoyage périodique des replays terminés et sessions expirées _cleanup_thread: Optional[threading.Thread] = None _cleanup_running = False @@ -4694,6 +4725,149 @@ async def list_chat_sessions(): } +# ========================================================================= +# Fleet management — enrollment des postes collaborateurs +# Consommes par deploy/installer/Lea.iss et deploy/installer/uninstall_lea.ps1 +# ========================================================================= + +def _agent_row_public(row: Dict[str, Any]) -> Dict[str, Any]: + """Projette un row de la table enrolled_agents pour l'API publique. + + On ne renvoie PAS l'id SQL interne : machine_id est l'identifiant public. + """ + return { + "machine_id": row.get("machine_id"), + "user_name": row.get("user_name"), + "user_email": row.get("user_email"), + "user_id": row.get("user_id"), + "hostname": row.get("hostname"), + "os_info": row.get("os_info"), + "version": row.get("version"), + "status": row.get("status"), + "enrolled_at": row.get("enrolled_at"), + "last_seen_at": row.get("last_seen_at"), + "uninstalled_at": row.get("uninstalled_at"), + "uninstall_reason": row.get("uninstall_reason"), + } + + +@app.post("/api/v1/agents/enroll", status_code=201) +async def agents_enroll(request: AgentEnrollRequest): + """Enregistre un nouveau poste collaborateur (appele par l'installeur). + + Comportement : + - machine_id unique et obligatoire. + - Si deja enrole et actif -> 409 Conflict (avec infos de l'enrollement existant). + - Si deja enrole mais desinstalle -> reactive automatiquement (return 201 + reactivated=True). + - Token Bearer global obligatoire (un seul token partage entre tous les postes). + Une phase 2 pourra emettre un token par poste si besoin. + """ + machine_id = (request.machine_id or "").strip() + if not machine_id: + raise HTTPException(status_code=400, detail="machine_id est obligatoire") + + try: + result = agent_registry.enroll( + machine_id=machine_id, + user_name=request.user_name, + user_email=request.user_email, + user_id=request.user_id, + hostname=request.hostname, + os_info=request.os_info, + version=request.version, + ) + except AgentAlreadyEnrolledError as exc: + existing = _agent_row_public(exc.existing) + logger.warning( + f"[FLEET] Tentative de reenrollement machine_id={machine_id} " + f"(deja actif depuis {existing.get('enrolled_at')})" + ) + raise HTTPException( + status_code=409, + detail={ + "error": "already_enrolled", + "message": "machine_id deja enrole et actif", + "existing": existing, + }, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + agent = _agent_row_public(result["agent"]) + event_kind = "reactivated" if result["reactivated"] else "created" + logger.info( + f"[FLEET] Agent enrole ({event_kind}) : machine_id={machine_id} " + f"user={request.user_name!r} hostname={request.hostname!r} " + f"version={request.version!r}" + ) + + return { + "status": "enrolled", + "created": result["created"], + "reactivated": result["reactivated"], + "machine_id": machine_id, + # Phase 1 : on renvoie le token global pour que le client puisse + # verifier qu'il est bien aligne avec le serveur. Phase 2 pourra + # emettre un token par poste (issued_token != API_TOKEN global). + "api_token": API_TOKEN, + "agent": agent, + } + + +@app.post("/api/v1/agents/uninstall") +async def agents_uninstall(request: AgentUninstallRequest): + """Marque un poste comme desinstalle (soft delete, garde l'historique). + + Appele par deploy/installer/uninstall_lea.ps1 en best-effort. Si le + machine_id est inconnu -> 404 (le client l'ignore silencieusement). + """ + machine_id = (request.machine_id or "").strip() + if not machine_id: + raise HTTPException(status_code=400, detail="machine_id est obligatoire") + + reason = (request.reason or "").strip() or None + + try: + row = agent_registry.uninstall(machine_id=machine_id, reason=reason) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + if row is None: + logger.warning( + f"[FLEET] Desinstallation d'un machine_id inconnu : {machine_id}" + ) + raise HTTPException( + status_code=404, + detail=f"machine_id={machine_id} introuvable dans le registre", + ) + + logger.info( + f"[FLEET] Agent desinstalle : machine_id={machine_id} reason={reason!r}" + ) + return { + "status": "uninstalled", + "machine_id": machine_id, + "agent": _agent_row_public(row), + } + + +@app.get("/api/v1/agents/fleet") +async def agents_fleet(): + """Liste les agents enroles, separes par statut (active / uninstalled). + + Futur dashboard fleet : synthese des postes deployes + ceux disparus. + """ + active_rows = agent_registry.list_by_status("active") + uninstalled_rows = agent_registry.list_by_status("uninstalled") + + return { + "active": [_agent_row_public(r) for r in active_rows], + "uninstalled": [_agent_row_public(r) for r in uninstalled_rows], + "total_active": len(active_rows), + "total_uninstalled": len(uninstalled_rows), + } + + if __name__ == "__main__": import uvicorn diff --git a/tests/integration/test_agents_enroll_api.py b/tests/integration/test_agents_enroll_api.py new file mode 100644 index 000000000..b06fd89fd --- /dev/null +++ b/tests/integration/test_agents_enroll_api.py @@ -0,0 +1,333 @@ +""" +Tests d'integration pour les endpoints /api/v1/agents/* (fleet management). + +Couvre : +- POST /api/v1/agents/enroll (201, 409 duplicate, 401 sans token, + reenrollement apres uninstall) +- POST /api/v1/agents/uninstall (200, 404 inconnu) +- GET /api/v1/agents/fleet (listing actif / desinstalle) + +Le module `agent_v0.server_v1.api_stream` applique un fail-closed a +l'import si RPA_API_TOKEN est absent : la fixture `_ensure_api_token` +garantit que l'env est defini AVANT tout import. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +# Racine du projet pour les imports locaux (meme pattern que les autres +# tests d'integration) +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +_TEST_API_TOKEN = "test_token_fleet_endpoints_0123456789abcdef" + + +@pytest.fixture +def agents_client(monkeypatch, tmp_path): + """Client FastAPI de test avec un AgentRegistry isole sur disque. + + Remplace le `agent_registry` global par une instance pointant sur une + DB temporaire, pour ne pas polluer la vraie rpa_data.db du workspace. + """ + # Garantir que le module peut s'importer (RPA_API_TOKEN sinon sys.exit 1) + monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN) + monkeypatch.setenv( + "RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db") + ) + + # Import tardif apres config de l'env + from fastapi.testclient import TestClient + from agent_v0.server_v1 import api_stream + from agent_v0.server_v1.agent_registry import AgentRegistry + + # Aligner le token attendu par le middleware Bearer avec notre token de test + monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN) + + # Substituer le registre global par une instance dediee au test + original_registry = api_stream.agent_registry + test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db")) + monkeypatch.setattr(api_stream, "agent_registry", test_registry) + + client = TestClient(api_stream.app, raise_server_exceptions=False) + yield client, _TEST_API_TOKEN, test_registry + + # Restauration + monkeypatch.setattr(api_stream, "agent_registry", original_registry) + + +def _auth_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# POST /api/v1/agents/enroll +# --------------------------------------------------------------------------- + + +def test_enroll_new_agent_returns_201(agents_client): + client, token, _ = agents_client + payload = { + "machine_id": "aivanov-jdoe-a3f2b718", + "user_name": "Jean Doe", + "user_email": "jdoe@aivanov.fr", + "user_id": "AIVA-001", + "hostname": "DESKTOP-ABC123", + "os_info": "Windows 11", + "version": "1.0.0", + } + + resp = client.post( + "/api/v1/agents/enroll", json=payload, headers=_auth_headers(token) + ) + + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["status"] == "enrolled" + assert data["created"] is True + assert data["reactivated"] is False + assert data["machine_id"] == "aivanov-jdoe-a3f2b718" + # Phase 1 : token global renvoye pour confirmation + assert data["api_token"] == token + agent = data["agent"] + assert agent["user_name"] == "Jean Doe" + assert agent["hostname"] == "DESKTOP-ABC123" + assert agent["status"] == "active" + assert agent["enrolled_at"] + assert agent["uninstalled_at"] is None + + +def test_enroll_duplicate_returns_409(agents_client): + client, token, _ = agents_client + payload = { + "machine_id": "dup-machine-001", + "user_name": "Alice", + "hostname": "PC-ALICE", + "version": "1.0.0", + } + first = client.post( + "/api/v1/agents/enroll", json=payload, headers=_auth_headers(token) + ) + assert first.status_code == 201 + + # Reenrollement sur machine encore active -> 409 + second = client.post( + "/api/v1/agents/enroll", json=payload, headers=_auth_headers(token) + ) + assert second.status_code == 409, second.text + body = second.json() + # FastAPI enveloppe notre detail dans "detail" + detail = body["detail"] + assert detail["error"] == "already_enrolled" + assert detail["existing"]["machine_id"] == "dup-machine-001" + + +def test_enroll_without_token_returns_401(agents_client): + client, _, _ = agents_client + payload = {"machine_id": "no-auth-001"} + resp = client.post("/api/v1/agents/enroll", json=payload) + assert resp.status_code == 401 + + +def test_enroll_with_wrong_token_returns_401(agents_client): + client, _, _ = agents_client + payload = {"machine_id": "bad-token-001"} + resp = client.post( + "/api/v1/agents/enroll", + json=payload, + headers={"Authorization": "Bearer WRONG_TOKEN"}, + ) + assert resp.status_code == 401 + + +def test_enroll_missing_machine_id_returns_422(agents_client): + """Pydantic renvoie 422 si machine_id est absent (validation automatique).""" + client, token, _ = agents_client + resp = client.post( + "/api/v1/agents/enroll", json={}, headers=_auth_headers(token) + ) + assert resp.status_code == 422 + + +def test_enroll_blank_machine_id_returns_400(agents_client): + """Un machine_id vide (whitespace) est rejete avec un 400 explicite.""" + client, token, _ = agents_client + resp = client.post( + "/api/v1/agents/enroll", + json={"machine_id": " "}, + headers=_auth_headers(token), + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/v1/agents/uninstall +# --------------------------------------------------------------------------- + + +def test_uninstall_existing_returns_200_and_soft_deletes(agents_client): + client, token, registry = agents_client + + # Preparer un agent actif + client.post( + "/api/v1/agents/enroll", + json={ + "machine_id": "uninst-001", + "user_name": "Bob", + "hostname": "PC-BOB", + }, + headers=_auth_headers(token), + ) + + resp = client.post( + "/api/v1/agents/uninstall", + json={"machine_id": "uninst-001", "reason": "user_uninstall"}, + headers=_auth_headers(token), + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "uninstalled" + assert data["machine_id"] == "uninst-001" + assert data["agent"]["status"] == "uninstalled" + assert data["agent"]["uninstall_reason"] == "user_uninstall" + assert data["agent"]["uninstalled_at"] + + # Verifier en base : pas de suppression physique (soft delete) + row = registry.get("uninst-001") + assert row is not None + assert row["status"] == "uninstalled" + + +def test_uninstall_unknown_returns_404(agents_client): + client, token, _ = agents_client + resp = client.post( + "/api/v1/agents/uninstall", + json={"machine_id": "never-seen-001", "reason": "admin_revoke"}, + headers=_auth_headers(token), + ) + assert resp.status_code == 404 + + +def test_uninstall_without_token_returns_401(agents_client): + client, _, _ = agents_client + resp = client.post( + "/api/v1/agents/uninstall", + json={"machine_id": "anything"}, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Reenrollement apres uninstall = reactivation +# --------------------------------------------------------------------------- + + +def test_reenroll_after_uninstall_reactivates(agents_client): + client, token, _ = agents_client + + client.post( + "/api/v1/agents/enroll", + json={ + "machine_id": "reenroll-001", + "user_name": "Carol", + "hostname": "PC-CAROL", + "version": "1.0.0", + }, + headers=_auth_headers(token), + ) + client.post( + "/api/v1/agents/uninstall", + json={"machine_id": "reenroll-001", "reason": "user_uninstall"}, + headers=_auth_headers(token), + ) + + # Nouvelle installation -> reactivation OK (meme machine_id, maj des champs) + resp = client.post( + "/api/v1/agents/enroll", + json={ + "machine_id": "reenroll-001", + "user_name": "Carol Durand", + "hostname": "PC-CAROL", + "version": "1.1.0", + }, + headers=_auth_headers(token), + ) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["created"] is False + assert data["reactivated"] is True + agent = data["agent"] + assert agent["status"] == "active" + assert agent["uninstalled_at"] is None + assert agent["uninstall_reason"] is None + # Les champs ont bien ete mis a jour + assert agent["user_name"] == "Carol Durand" + assert agent["version"] == "1.1.0" + + +# --------------------------------------------------------------------------- +# GET /api/v1/agents/fleet +# --------------------------------------------------------------------------- + + +def test_fleet_lists_active_and_uninstalled(agents_client): + client, token, _ = agents_client + + # 2 agents actifs + 1 desinstalle + for mid in ("fleet-a", "fleet-b"): + client.post( + "/api/v1/agents/enroll", + json={"machine_id": mid, "user_name": mid, "hostname": mid.upper()}, + headers=_auth_headers(token), + ) + + client.post( + "/api/v1/agents/enroll", + json={"machine_id": "fleet-c", "user_name": "Cleo"}, + headers=_auth_headers(token), + ) + client.post( + "/api/v1/agents/uninstall", + json={"machine_id": "fleet-c", "reason": "machine_retired"}, + headers=_auth_headers(token), + ) + + resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token)) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["total_active"] == 2 + assert data["total_uninstalled"] == 1 + + active_ids = {a["machine_id"] for a in data["active"]} + assert active_ids == {"fleet-a", "fleet-b"} + + uninstalled_ids = {a["machine_id"] for a in data["uninstalled"]} + assert uninstalled_ids == {"fleet-c"} + assert data["uninstalled"][0]["uninstall_reason"] == "machine_retired" + + +def test_fleet_empty(agents_client): + client, token, _ = agents_client + resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token)) + assert resp.status_code == 200 + data = resp.json() + assert data == { + "active": [], + "uninstalled": [], + "total_active": 0, + "total_uninstalled": 0, + } + + +def test_fleet_without_token_returns_401(agents_client): + client, _, _ = agents_client + resp = client.get("/api/v1/agents/fleet") + assert resp.status_code == 401 diff --git a/tests/integration/test_stream_processor.py b/tests/integration/test_stream_processor.py index 0a76eb697..8002da2b5 100644 --- a/tests/integration/test_stream_processor.py +++ b/tests/integration/test_stream_processor.py @@ -6,6 +6,7 @@ Sans GPU/modèles lourds (mocks pour ScreenAnalyzer et CLIP). """ import json +import os import shutil import sys import tempfile @@ -457,6 +458,27 @@ class TestStreamProcessorListMethods: class TestAPIEndpoints: """Tests pour les endpoints GET sessions et workflows.""" + # Token de test fixe utilisé pour tous les tests d'API. + # Doit être défini AVANT le premier import de agent_v0.server_v1.api_stream + # car le module fail-closed (sys.exit 1) si RPA_API_TOKEN est absent. + _TEST_API_TOKEN = "test_token_for_api_endpoints_0123456789abcdef" + + @pytest.fixture(autouse=True) + def _ensure_api_token(self, monkeypatch): + """Garantit que RPA_API_TOKEN est défini avant l'import de api_stream. + + Le module agent_v0.server_v1.api_stream applique un fail-closed P0-C + (sys.exit 1) à l'import si RPA_API_TOKEN est absent. On force donc + une valeur de test ici avant tout import lazy dans la fixture client. + """ + monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN) + # Si api_stream est déjà chargé dans sys.modules avec un autre token + # (par ex. depuis un précédent test), on aligne sa valeur API_TOKEN + # pour que les requêtes Bearer du test passent l'auth. + api_stream_mod = sys.modules.get("agent_v0.server_v1.api_stream") + if api_stream_mod is not None: + monkeypatch.setattr(api_stream_mod, "API_TOKEN", self._TEST_API_TOKEN) + @pytest.fixture def client(self, temp_dir): """Client de test FastAPI."""