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