123 lines
4.7 KiB
Python
123 lines
4.7 KiB
Python
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
|