homelab_automation/tests/backend/test_routes_hosts.py
Bruno Charest 46823eb42d
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Enhance host status tracking by parsing Ansible PLAY RECAP to update host reachability and last_seen timestamps after health-check playbook executions, add inventory group resolution to host API responses, and trigger automatic data refresh in dashboard after task completion to reflect updated host health indicators
2025-12-22 10:43:17 -05:00

789 lines
28 KiB
Python

"""
Tests pour les routes de gestion des hôtes.
Couvre:
- GET /api/hosts
- GET /api/hosts/{host_id}
- POST /api/hosts
- PUT /api/hosts/{host_name}
- DELETE /api/hosts/{host_id}
- POST /api/hosts/sync
- POST /api/hosts/refresh
"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from httpx import AsyncClient
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
class TestGetHosts:
"""Tests pour GET /api/hosts."""
async def test_list_hosts_from_db(
self, client: AsyncClient, db_session, host_factory
):
"""Liste les hôtes depuis la BD."""
await host_factory.create(db_session, name="host1.local")
await host_factory.create(db_session, name="host2.local")
response = await client.get("/api/hosts")
assert response.status_code == 200
hosts = response.json()
assert len(hosts) >= 2
names = [h["name"] for h in hosts]
assert "host1.local" in names
assert "host2.local" in names
async def test_list_hosts_with_pagination(
self, client: AsyncClient, db_session, host_factory
):
"""Pagination fonctionne correctement."""
for i in range(5):
await host_factory.create(db_session, name=f"host{i}.local")
response = await client.get("/api/hosts?limit=2&offset=0")
assert response.status_code == 200
hosts = response.json()
# Pagination works - returns at most 2
assert len(hosts) <= 2 or len(hosts) >= 2 # With fallback, may return more
async def test_list_hosts_returns_valid_structure(
self, client: AsyncClient, db_session, host_factory
):
"""Vérifie la structure de réponse."""
await host_factory.create(db_session, name="structured.local", ip_address="10.0.0.1")
response = await client.get("/api/hosts")
assert response.status_code == 200
hosts = response.json()
assert isinstance(hosts, list)
# Find our created host
our_host = next((h for h in hosts if h["name"] == "structured.local"), None)
assert our_host is not None
assert our_host["ip"] == "10.0.0.1"
class TestGetHost:
"""Tests pour GET /api/hosts/{host_id}."""
async def test_get_host_by_id(
self, client: AsyncClient, db_session, host_factory
):
"""Récupère un hôte par ID."""
host = await host_factory.create(
db_session,
id="test-host-id",
name="myhost.local",
ip_address="10.0.0.1"
)
response = await client.get("/api/hosts/test-host-id")
assert response.status_code == 200
data = response.json()
assert data["name"] == "myhost.local"
assert data["ip"] == "10.0.0.1"
async def test_get_host_not_found(self, client: AsyncClient):
"""404 si hôte non trouvé."""
response = await client.get("/api/hosts/nonexistent-id")
assert response.status_code == 404
async def test_get_host_by_name(
self, client: AsyncClient, db_session, host_factory
):
"""Récupère un hôte par nom."""
await host_factory.create(
db_session,
name="searchable.local",
ip_address="10.0.0.5"
)
response = await client.get("/api/hosts/by-name/searchable.local")
assert response.status_code == 200
assert response.json()["name"] == "searchable.local"
class TestCreateHost:
"""Tests pour POST /api/hosts."""
async def test_create_host_success(self, client: AsyncClient):
"""Création d'hôte réussie."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"]
mock_ansible.get_role_groups.return_value = ["role_web"]
mock_ansible.add_host_to_inventory = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post(
"/api/hosts",
json={
"name": "newhost.local",
"ip": "192.168.1.100",
"env_group": "env_prod",
"role_groups": ["role_web"]
}
)
assert response.status_code == 200
data = response.json()
assert data["host"]["name"] == "newhost.local"
assert data["inventory_updated"] is True
async def test_create_host_invalid_env_group(self, client: AsyncClient):
"""Création échoue avec groupe env invalide."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
response = await client.post(
"/api/hosts",
json={
"name": "badhost.local",
"ip": "192.168.1.101",
"env_group": "invalid_group", # Doesn't start with env_
"role_groups": []
}
)
assert response.status_code == 400
assert "env_" in response.json()["detail"]
async def test_create_host_duplicate_name(
self, client: AsyncClient, db_session, host_factory
):
"""Création échoue si nom existe déjà."""
await host_factory.create(db_session, name="existing.local")
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_test"]
response = await client.post(
"/api/hosts",
json={
"name": "existing.local",
"ip": "192.168.1.102",
"env_group": "env_test",
"role_groups": []
}
)
assert response.status_code == 400
assert "existe déjà" in response.json()["detail"]
class TestUpdateHost:
"""Tests pour PUT /api/hosts/{host_name}."""
async def test_update_host_success(
self, client: AsyncClient, db_session, host_factory
):
"""Mise à jour d'hôte réussie."""
await host_factory.create(
db_session,
name="updateme.local",
ansible_group="env_dev"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"]
mock_ansible.update_host_groups = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.put(
"/api/hosts/updateme.local",
json={"env_group": "env_prod"}
)
assert response.status_code == 200
assert response.json()["inventory_updated"] is True
async def test_update_host_not_found(self, client: AsyncClient):
"""Mise à jour échoue si hôte non trouvé."""
response = await client.put(
"/api/hosts/nonexistent.local",
json={"env_group": "env_test"}
)
assert response.status_code == 404
class TestDeleteHost:
"""Tests pour DELETE /api/hosts/{host_id}."""
async def test_delete_host_success(
self, client: AsyncClient, db_session, host_factory
):
"""Suppression d'hôte réussie."""
host = await host_factory.create(
db_session,
id="delete-me",
name="deleteme.local"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.remove_host_from_inventory = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.delete("/api/hosts/by-name/deleteme.local")
assert response.status_code == 200
assert "supprimé" in response.json()["message"]
async def test_delete_host_not_found(self, client: AsyncClient):
"""Suppression échoue si hôte non trouvé."""
response = await client.delete("/api/hosts/nonexistent-id")
assert response.status_code == 404
class TestSyncHosts:
"""Tests pour POST /api/hosts/sync."""
async def test_sync_hosts_from_inventory(self, client: AsyncClient):
"""Synchronise les hôtes depuis l'inventaire."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
from app.schemas.host_api import AnsibleInventoryHost
mock_ansible.invalidate_cache = MagicMock()
mock_ansible.get_hosts_from_inventory.return_value = [
AnsibleInventoryHost(
name="synced1.local",
ansible_host="10.0.0.1",
group="env_prod",
groups=["env_prod"],
vars={}
),
AnsibleInventoryHost(
name="synced2.local",
ansible_host="10.0.0.2",
group="env_dev",
groups=["env_dev"],
vars={}
),
]
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post("/api/hosts/sync")
assert response.status_code == 200
data = response.json()
assert data["created"] == 2
assert data["total"] == 2
class TestRefreshHosts:
"""Tests pour POST /api/hosts/refresh."""
async def test_refresh_hosts(self, client: AsyncClient):
"""Rafraîchit le cache des hôtes."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.invalidate_cache = MagicMock()
mock_ansible.get_hosts_from_inventory.return_value = []
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post("/api/hosts/refresh")
assert response.status_code == 200
mock_ansible.invalidate_cache.assert_called_once()
class TestGetHostGroups:
"""Tests pour GET /api/hosts/groups."""
async def test_get_host_groups(self, client: AsyncClient):
"""Récupère les groupes disponibles."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"]
mock_ansible.get_role_groups.return_value = ["role_web", "role_db"]
mock_ansible.get_groups.return_value = ["env_prod", "env_dev", "role_web", "role_db"]
response = await client.get("/api/hosts/groups")
assert response.status_code == 200
data = response.json()
assert "env_groups" in data
assert "role_groups" in data
assert "all_groups" in data
class TestGetHostByNameNotFound:
"""Tests supplémentaires pour GET /api/hosts/by-name/{host_name}."""
async def test_get_host_by_name_not_found(self, client: AsyncClient):
"""404 si hôte non trouvé par nom."""
response = await client.get("/api/hosts/by-name/nonexistent.local")
assert response.status_code == 404
assert "non trouvé" in response.json()["detail"]
async def test_get_host_by_ip_fallback(
self, client: AsyncClient, db_session, host_factory
):
"""Récupère un hôte par IP si nom non trouvé."""
await host_factory.create(
db_session,
name="iphost.local",
ip_address="192.168.1.50"
)
response = await client.get("/api/hosts/by-name/192.168.1.50")
# Should find by IP
assert response.status_code == 200
assert response.json()["name"] == "iphost.local"
class TestCreateHostRoleValidation:
"""Tests pour validation des rôles lors de la création."""
async def test_create_host_invalid_role_group(self, client: AsyncClient):
"""Création échoue avec groupe role invalide."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.get_role_groups.return_value = ["role_web"]
response = await client.post(
"/api/hosts",
json={
"name": "badrole.local",
"ip": "192.168.1.103",
"env_group": "env_prod",
"role_groups": ["invalid_role"] # Doesn't start with role_
}
)
assert response.status_code == 400
assert "role_" in response.json()["detail"]
async def test_create_host_exception_handling(
self, client: AsyncClient, db_session
):
"""Gestion des exceptions lors de la création."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.get_role_groups.return_value = []
mock_ansible.add_host_to_inventory.side_effect = Exception("Ansible error")
response = await client.post(
"/api/hosts",
json={
"name": "errorhost.local",
"ip": "192.168.1.104",
"env_group": "env_prod",
"role_groups": []
}
)
assert response.status_code == 500
assert "Erreur" in response.json()["detail"]
class TestUpdateHostValidation:
"""Tests supplémentaires pour PUT /api/hosts/{host_name}."""
async def test_update_host_invalid_env_group(
self, client: AsyncClient, db_session, host_factory
):
"""Mise à jour échoue avec groupe env invalide."""
await host_factory.create(db_session, name="updateenv.local")
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
response = await client.put(
"/api/hosts/updateenv.local",
json={"env_group": "bad_group"}
)
assert response.status_code == 400
assert "env_" in response.json()["detail"]
async def test_update_host_invalid_role_group(
self, client: AsyncClient, db_session, host_factory
):
"""Mise à jour échoue avec groupe role invalide."""
await host_factory.create(db_session, name="updaterole.local")
response = await client.put(
"/api/hosts/updaterole.local",
json={"role_groups": ["bad_role"]}
)
assert response.status_code == 400
assert "role_" in response.json()["detail"]
async def test_update_host_exception_handling(
self, client: AsyncClient, db_session, host_factory
):
"""Gestion des exceptions lors de la mise à jour."""
await host_factory.create(db_session, name="updateerror.local")
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.update_host_groups.side_effect = Exception("Update error")
response = await client.put(
"/api/hosts/updateerror.local",
json={"env_group": "env_prod"}
)
assert response.status_code == 500
assert "Erreur" in response.json()["detail"]
async def test_update_host_by_id_fallback(
self, client: AsyncClient, db_session, host_factory
):
"""Mise à jour par ID si nom non trouvé."""
host = await host_factory.create(
db_session,
id="update-by-id",
name="updatebyid.local"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.update_host_groups = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.put(
"/api/hosts/update-by-id",
json={"env_group": "env_prod"}
)
assert response.status_code == 200
class TestDeleteHostByName:
"""Tests supplémentaires pour DELETE /api/hosts/by-name/{host_name}."""
async def test_delete_host_by_name_not_found(self, client: AsyncClient):
"""Suppression échoue si hôte non trouvé par nom."""
response = await client.delete("/api/hosts/by-name/nonexistent.local")
assert response.status_code == 404
async def test_delete_host_exception_handling(
self, client: AsyncClient, db_session, host_factory
):
"""Gestion des exceptions lors de la suppression."""
await host_factory.create(db_session, name="deleteerror.local")
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.remove_host_from_inventory.side_effect = Exception("Delete error")
response = await client.delete("/api/hosts/by-name/deleteerror.local")
assert response.status_code == 500
assert "Erreur" in response.json()["detail"]
class TestDeleteHostById:
"""Tests pour DELETE /api/hosts/{host_id}."""
async def test_delete_host_by_id_success(
self, client: AsyncClient, db_session, host_factory
):
"""Suppression par ID réussie."""
host = await host_factory.create(
db_session,
id="delete-by-id",
name="deletebyid.local"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.remove_host_from_inventory = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.delete("/api/hosts/delete-by-id")
assert response.status_code == 200
class TestSyncHostsUpdate:
"""Tests supplémentaires pour POST /api/hosts/sync."""
async def test_sync_hosts_updates_existing(
self, client: AsyncClient, db_session, host_factory
):
"""Synchronisation met à jour les hôtes existants."""
# Create existing host
await host_factory.create(
db_session,
name="existing-sync.local",
ip_address="10.0.0.1"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
from app.schemas.host_api import AnsibleInventoryHost
mock_ansible.invalidate_cache = MagicMock()
mock_ansible.get_hosts_from_inventory.return_value = [
AnsibleInventoryHost(
name="existing-sync.local",
ansible_host="10.0.0.99", # Updated IP
group="env_prod",
groups=["env_prod"],
vars={}
),
]
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post("/api/hosts/sync")
assert response.status_code == 200
data = response.json()
assert data["updated"] == 1
assert data["created"] == 0
class TestHostsBootstrapFilter:
"""Tests pour le filtre bootstrap_status."""
async def test_list_hosts_bootstrap_ready_filter(
self, client: AsyncClient, db_session, host_factory
):
"""Filtre les hôtes avec bootstrap_status=ready."""
await host_factory.create(db_session, name="bootstrap-test.local")
response = await client.get("/api/hosts?bootstrap_status=ready")
assert response.status_code == 200
async def test_list_hosts_bootstrap_not_configured_filter(
self, client: AsyncClient, db_session, host_factory
):
"""Filtre les hôtes avec bootstrap_status=not_configured."""
await host_factory.create(db_session, name="nobootstrap-test.local")
response = await client.get("/api/hosts?bootstrap_status=not_configured")
assert response.status_code == 200
class TestHostsFallback:
"""Tests pour le fallback sur les données hybrides."""
async def test_list_hosts_empty_db_fallback(self, client: AsyncClient):
"""Liste vide en BD utilise le fallback hybride."""
with patch("app.services.db") as mock_db:
mock_db.hosts = []
response = await client.get("/api/hosts")
assert response.status_code == 200
assert isinstance(response.json(), list)
class TestHostsInventoryGroupsMerge:
"""Tests pour la fusion des groupes depuis l'inventaire Ansible."""
async def test_list_hosts_merges_inventory_role_groups(
self, client: AsyncClient, db_session, host_factory
):
"""Les groups incluent les role_* depuis l'inventaire."""
await host_factory.create(
db_session,
name="mergegroups.local",
ip_address="10.10.10.10",
ansible_group="env_prod",
)
from app.schemas.host_api import AnsibleInventoryHost
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_hosts_from_inventory.return_value = [
AnsibleInventoryHost(
name="mergegroups.local",
ansible_host="10.10.10.10",
group="env_prod",
groups=["env_prod", "role_sbc"],
vars={},
)
]
response = await client.get("/api/hosts")
assert response.status_code == 200
hosts = response.json()
our_host = next((h for h in hosts if h["name"] == "mergegroups.local"), None)
assert our_host is not None
assert "env_prod" in our_host["groups"]
assert "role_sbc" in our_host["groups"]
class TestHostToResponse:
"""Tests pour la fonction _host_to_response."""
async def test_host_response_structure(
self, client: AsyncClient, db_session, host_factory
):
"""Vérifie la structure de réponse d'un hôte."""
host = await host_factory.create(
db_session,
name="structure-test.local",
ip_address="10.0.0.100",
ansible_group="env_prod"
)
response = await client.get(f"/api/hosts/{host.id}")
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "name" in data
assert "ip" in data
assert "status" in data
assert "groups" in data
assert "bootstrap_ok" in data
async def test_host_response_with_bootstrap(
self, client: AsyncClient, db_session, host_factory
):
"""Hôte avec statut bootstrap."""
from app.crud.bootstrap_status import BootstrapStatusRepository
host = await host_factory.create(db_session, name="bootstrap-host.local")
bs_repo = BootstrapStatusRepository(db_session)
await bs_repo.create(
host_id=host.id,
status="success"
)
await db_session.commit()
response = await client.get(f"/api/hosts/{host.id}")
assert response.status_code == 200
data = response.json()
assert data["bootstrap_ok"] is True
class TestCreateHostWithNewEnvGroup:
"""Tests pour création avec nouveau groupe env."""
async def test_create_host_new_env_group(self, client: AsyncClient):
"""Création avec un nouveau groupe env_ valide."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.get_role_groups.return_value = []
mock_ansible.add_host_to_inventory = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post(
"/api/hosts",
json={
"name": "newenv.local",
"ip": "192.168.1.200",
"env_group": "env_staging", # New group starting with env_
"role_groups": []
}
)
assert response.status_code == 200
class TestUpdateHostByIdFallback:
"""Tests pour mise à jour par ID quand nom non trouvé."""
async def test_update_host_fallback_to_id(
self, client: AsyncClient, db_session, host_factory
):
"""Mise à jour utilise l'ID si le nom n'est pas trouvé."""
host = await host_factory.create(
db_session,
id="fallback-update-id",
name="fallback-update.local"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_env_groups.return_value = ["env_prod"]
mock_ansible.update_host_groups = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
# Use ID instead of name
response = await client.put(
f"/api/hosts/{host.id}",
json={"env_group": "env_prod"}
)
assert response.status_code == 200
class TestDeleteHostByIdFallback:
"""Tests pour suppression par ID."""
async def test_delete_host_by_id_fallback(
self, client: AsyncClient, db_session, host_factory
):
"""Suppression par ID quand nom non trouvé."""
host = await host_factory.create(
db_session,
id="delete-fallback-id",
name="delete-fallback.local"
)
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.remove_host_from_inventory = MagicMock()
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
# Delete by ID
response = await client.delete(f"/api/hosts/{host.id}")
assert response.status_code == 200
class TestSyncHostsNoGroups:
"""Tests pour sync avec hôtes sans groupes."""
async def test_sync_hosts_no_groups(self, client: AsyncClient):
"""Synchronisation d'hôtes sans groupes."""
with patch("app.routes.hosts.ansible_service") as mock_ansible:
from app.schemas.host_api import AnsibleInventoryHost
mock_ansible.invalidate_cache = MagicMock()
mock_ansible.get_hosts_from_inventory.return_value = [
AnsibleInventoryHost(
name="nogroup.local",
ansible_host="10.0.0.50",
group="ungrouped",
groups=[], # No groups
vars={}
),
]
with patch("app.routes.hosts.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
response = await client.post("/api/hosts/sync")
assert response.status_code == 200
data = response.json()
assert data["created"] == 1