from __future__ import annotations from pathlib import Path import pytest from pve_backup_report.config import AppConfig from pve_backup_report.pve_client import PveClient, PveConnectionError, PveHttpError class FakeResponse: def __init__(self, status_code: int, payload: dict, reason: str = "OK") -> None: self.status_code = status_code self._payload = payload self.reason = reason def json(self) -> dict: return self._payload class FakeSession: def __init__(self, response: FakeResponse) -> None: self.headers: dict[str, str] = {} self.response = response self.calls: list[tuple[str, dict[str, object] | None, int, bool | str]] = [] def get( self, url: str, params: dict[str, object] | None, timeout: int, verify: bool | str, ) -> FakeResponse: self.calls.append((url, params, timeout, verify)) return self.response def close(self) -> None: pass def make_config() -> AppConfig: return AppConfig( pve_api_url="https://pve.example.invalid:8006", pve_api_token_id="backup-report@pve!report", pve_api_token_secret="secret", report_output_dir=Path("reports"), report_timezone="Europe/Paris", pve_verify_tls=True, pve_ca_bundle=None, pve_timeout_seconds=30, pve_backup_jobs_endpoint="/cluster/backup", pve_task_history_limit=500, pve_task_log_limit=5000, pbs_hostnames={}, pbs_servers=(), log_level="INFO", report_filename_prefix="rapport-sauvegardes-pve", ) def test_get_uses_token_auth_and_returns_data() -> None: session = FakeSession(FakeResponse(200, {"data": [{"node": "pve1"}]})) client = PveClient(make_config(), session=session) # type: ignore[arg-type] data = client.get_nodes() assert data == [{"node": "pve1"}] assert session.calls == [ ("https://pve.example.invalid:8006/api2/json/nodes", None, 30, True) ] assert session.headers["Authorization"].startswith( "PVEAPIToken=backup-report@pve!report=" ) assert "secret" in session.headers["Authorization"] def test_http_error_keeps_endpoint_and_status() -> None: session = FakeSession(FakeResponse(401, {"message": "permission denied"}, "Unauthorized")) client = PveClient(make_config(), session=session) # type: ignore[arg-type] with pytest.raises(PveHttpError) as exc_info: client.get_storages() assert exc_info.value.endpoint == "/storage" assert exc_info.value.status_code == 401 def test_http_error_message_is_sanitized() -> None: session = FakeSession( FakeResponse( 500, {"message": "PVEAPIToken=backup@pve!report=secret secret=secret2"}, "Server Error", ) ) client = PveClient(make_config(), session=session) # type: ignore[arg-type] with pytest.raises(PveHttpError) as exc_info: client.get_nodes() assert "report=secret" not in exc_info.value.message assert "secret2" not in exc_info.value.message assert exc_info.value.message == "PVEAPIToken=*** secret=***" def test_ca_bundle_overrides_boolean_verify_tls(tmp_path: Path) -> None: ca_bundle = tmp_path / "internal-ca.pem" ca_bundle.write_text("test ca", encoding="utf-8") config = make_config() config = AppConfig( **{ **config.__dict__, "pve_ca_bundle": ca_bundle, } ) session = FakeSession(FakeResponse(200, {"data": []})) client = PveClient(config, session=session) # type: ignore[arg-type] client.get_backup_jobs() assert session.calls[0][3] == str(ca_bundle) def test_check_required_endpoints_keeps_testing_after_error() -> None: class MultiResponseSession(FakeSession): def __init__(self) -> None: super().__init__(FakeResponse(200, {"data": []})) self.responses = [ FakeResponse(200, {"data": [{"node": "pve1"}]}), FakeResponse(200, {"data": [{"storage": "pbs"}]}), FakeResponse(200, {"data": [{"subdir": "jobs"}]}), FakeResponse(404, {"message": "No such endpoint"}), ] def get( self, url: str, params: dict[str, object] | None, timeout: int, verify: bool | str, ) -> FakeResponse: self.calls.append((url, params, timeout, verify)) return self.responses.pop(0) client = PveClient(make_config(), session=MultiResponseSession()) # type: ignore[arg-type] results = client.check_required_endpoints() assert [result.endpoint for result in results] == [ "/nodes", "/storage", "/cluster", "/cluster/backup", ] assert [result.ok for result in results] == [True, True, True, False] assert results[2].detail == "sous-endpoints: jobs" def test_get_cluster_tasks_without_params() -> None: session = FakeSession(FakeResponse(200, {"data": []})) client = PveClient(make_config(), session=session) # type: ignore[arg-type] assert client.get_cluster_tasks() == [] assert session.calls == [ ( "https://pve.example.invalid:8006/api2/json/cluster/tasks", None, 30, True, ) ] def test_get_node_tasks_without_params() -> None: session = FakeSession(FakeResponse(200, {"data": []})) client = PveClient(make_config(), session=session) # type: ignore[arg-type] assert client.get_node_tasks("pve01") == [] assert session.calls == [ ( "https://pve.example.invalid:8006/api2/json/nodes/pve01/tasks", None, 30, True, ) ] def test_get_task_log_encodes_upid() -> None: session = FakeSession(FakeResponse(200, {"data": []})) client = PveClient(make_config(), session=session) # type: ignore[arg-type] upid = "UPID:pve01:123:456:789:vzdump:100:root@pam:" assert client.get_task_log("pve01", upid) == [] assert session.calls == [ ( "https://pve.example.invalid:8006/api2/json/nodes/pve01/tasks/UPID%3Apve01%3A123%3A456%3A789%3Avzdump%3A100%3Aroot%40pam%3A/log", {"start": 0, "limit": 5000}, 30, True, ) ] def test_get_guest_config_endpoints() -> None: session = FakeSession(FakeResponse(200, {"data": {"description": "note"}})) client = PveClient(make_config(), session=session) # type: ignore[arg-type] assert client.get_qemu_config("pve01", 100) == {"description": "note"} assert client.get_lxc_config("pve02", 200) == {"description": "note"} assert session.calls == [ ( "https://pve.example.invalid:8006/api2/json/nodes/pve01/qemu/100/config", None, 30, True, ), ( "https://pve.example.invalid:8006/api2/json/nodes/pve02/lxc/200/config", None, 30, True, ), ] def test_pve_sanitize_exception_masks_sensitive_values() -> None: message = PveClient._sanitize_exception( PveConnectionError( "PVEAPIToken=backup@pve!report=secret PVE_API_TOKEN_SECRET=secret2" ) ) assert "report=secret" not in message assert "secret2" not in message assert "PVEAPIToken=***" in message assert "PVE_API_TOKEN_SECRET=***" in message