Files
rpa_vision_v3/tests/integration/test_agents_enroll_api.py

437 lines
14 KiB
Python

"""
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 sqlite3
import sys
import tempfile
import time
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"
def test_reenroll_after_admin_revoke_is_forbidden(agents_client):
client, token, _ = agents_client
client.post(
"/api/v1/agents/enroll",
json={"machine_id": "revoked-001", "user_name": "Revoked"},
headers=_auth_headers(token),
)
revoke = client.post(
"/api/v1/agents/uninstall",
json={"machine_id": "revoked-001", "reason": "admin_revoke"},
headers=_auth_headers(token),
)
assert revoke.status_code == 200
resp = client.post(
"/api/v1/agents/enroll",
json={"machine_id": "revoked-001", "user_name": "Revoked Again"},
headers=_auth_headers(token),
)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
assert detail["error"] == "agent_revoked"
assert detail["existing"]["machine_id"] == "revoked-001"
assert detail["existing"]["uninstall_reason"] == "admin_revoke"
def test_revoked_agent_cannot_stream_or_poll(agents_client):
client, token, _ = agents_client
client.post(
"/api/v1/agents/enroll",
json={"machine_id": "revoked-runtime-001", "user_name": "Runtime"},
headers=_auth_headers(token),
)
client.post(
"/api/v1/agents/uninstall",
json={"machine_id": "revoked-runtime-001", "reason": "admin_revoke"},
headers=_auth_headers(token),
)
event_resp = client.post(
"/api/v1/traces/stream/event",
json={
"session_id": "sess_revoked_runtime",
"timestamp": time.time(),
"event": {"type": "heartbeat"},
"machine_id": "revoked-runtime-001",
},
headers=_auth_headers(token),
)
assert event_resp.status_code == 403, event_resp.text
assert event_resp.json()["detail"]["error"] == "agent_not_active"
next_resp = client.get(
"/api/v1/traces/stream/replay/next",
params={
"session_id": "sess_revoked_runtime",
"machine_id": "revoked-runtime-001",
},
headers=_auth_headers(token),
)
assert next_resp.status_code == 403, next_resp.text
assert next_resp.json()["detail"]["error"] == "agent_not_active"
def test_active_agent_stream_updates_last_seen(agents_client):
client, token, registry = agents_client
machine_id = "last-seen-001"
client.post(
"/api/v1/agents/enroll",
json={"machine_id": machine_id, "user_name": "Seen"},
headers=_auth_headers(token),
)
stale = "2000-01-01T00:00:00+00:00"
with sqlite3.connect(str(registry.db_path)) as conn:
conn.execute(
"UPDATE enrolled_agents SET last_seen_at = ? WHERE machine_id = ?",
(stale, machine_id),
)
conn.commit()
resp = client.post(
"/api/v1/traces/stream/event",
json={
"session_id": "sess_last_seen",
"timestamp": time.time(),
"event": {"type": "heartbeat"},
"machine_id": machine_id,
},
headers=_auth_headers(token),
)
assert resp.status_code == 200, resp.text
row = registry.get(machine_id)
assert row is not None
assert row["last_seen_at"] != stale
# ---------------------------------------------------------------------------
# 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