from pathlib import Path from threading import BoundedSemaphore import pytest from fastapi.testclient import TestClient from app import main as main_module from app.store import ScanStore def _repository(root: Path) -> Path: repository = root / "repo" repository.mkdir() (repository / "README.md").write_text("# Demo\n", encoding="utf-8") (repository / "LICENSE").write_text("MIT\n", encoding="utf-8") (repository / ".gitignore").write_text(".env\n", encoding="utf-8") (repository / "app.py").write_text("print('hello')\n", encoding="utf-8") return repository def test_scan_api_respects_root_and_exports_reports(tmp_path: Path) -> None: repository = _repository(tmp_path) main_module.SCAN_ROOT = str(tmp_path) main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.SCAN_SLOTS = BoundedSemaphore(1) client = TestClient(main_module.app) first_response = client.post("/api/v1/scans", json={"path": "repo"}) assert first_response.status_code == 201 first = first_response.json() assert first["repository_path"] == str(repository.resolve()) html = client.get(f"/api/v1/scans/{first['id']}/export?format=html") assert html.status_code == 200 assert "RepoPulse report" in html.text assert "repo" in html.text blocked = client.post("/api/v1/scans", json={"path": str(tmp_path.parent)}) assert blocked.status_code == 422 def test_scan_api_comparison_delta_and_trend(tmp_path: Path) -> None: repository = _repository(tmp_path) main_module.SCAN_ROOT = str(tmp_path) main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.SCAN_SLOTS = BoundedSemaphore(1) client = TestClient(main_module.app) first = client.post("/api/v1/scans", json={"path": "repo"}).json() (repository / "tests").mkdir() (repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") second = client.post("/api/v1/scans", json={"path": "repo"}).json() comparison = client.get(f"/api/v1/scans/{second['id']}/comparison") assert comparison.status_code == 200 assert comparison.json()["previous_scan_id"] == first["id"] delta = client.get(f"/api/v1/scans/{second['id']}/delta") assert delta.status_code == 200 assert delta.json()["previous_health_score"] == first["health_score"] assert delta.json()["previous_finding_count"] == len(first["findings"]) trend = client.get(f"/api/v1/trend?path={repository}") assert trend.status_code == 200 assert [item["scan_id"] for item in trend.json()] == [first["id"], second["id"]] def test_scan_api_rejects_when_scan_capacity_is_busy(tmp_path: Path) -> None: _repository(tmp_path) main_module.SCAN_ROOT = str(tmp_path) main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.SCAN_SLOTS = BoundedSemaphore(1) client = TestClient(main_module.app) assert main_module.SCAN_SLOTS.acquire(blocking=False) try: response = client.post("/api/v1/scans", json={"path": "repo"}) finally: main_module.SCAN_SLOTS.release() assert response.status_code == 429 assert response.headers["retry-after"] == "2" def test_scan_api_rejects_symlinked_repository_outside_root(tmp_path: Path) -> None: outside = tmp_path.parent / f"{tmp_path.name}-outside-repository" outside.mkdir() (outside / "app.py").write_text("print('outside')\n", encoding="utf-8") linked = tmp_path / "linked-repository" try: linked.symlink_to(outside, target_is_directory=True) except (NotImplementedError, OSError): pytest.skip("directory symlinks are unavailable in this environment") main_module.SCAN_ROOT = str(tmp_path) main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.SCAN_SLOTS = BoundedSemaphore(1) client = TestClient(main_module.app) response = client.post("/api/v1/scans", json={"path": "linked-repository"}) assert response.status_code == 422 def test_mutating_api_requires_operator_token_only_when_configured(tmp_path: Path, monkeypatch) -> None: _repository(tmp_path) main_module.SCAN_ROOT = str(tmp_path) main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.SCAN_SLOTS = BoundedSemaphore(1) monkeypatch.setenv("REPOPULSE_OPERATOR_TOKEN", "test-operator-token") client = TestClient(main_module.app) assert client.get("/api/v1/meta").status_code == 200 denied = client.post("/api/v1/scans", json={"path": "repo"}) assert denied.status_code == 401 allowed = client.post( "/api/v1/scans", json={"path": "repo"}, headers={"X-RepoPulse-Token": "test-operator-token"}, ) assert allowed.status_code == 201