from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from app import main as main_module from app.gitea import GiteaClient from app.jobs import ScanJobRunner from app.models import Repository, ScanJobStatus, ScanTrigger from app.store import MaintenanceModeError, ScanStore def _gitea_repository() -> Repository: return Repository( id="gitea:64", provider="gitea", provider_repository_id="64", owner="Jens", name="RepoPulse", full_name="Jens/RepoPulse", default_branch="main", latest_revision="0123456789abcdef0123456789abcdef01234567", ) def test_maintenance_mode_is_durable_authenticated_and_exposes_drain_state(tmp_path: Path, monkeypatch) -> None: database = tmp_path / "history.db" main_module.store = ScanStore(str(database)) main_module.job_runner = None main_module.store.upsert_repository(_gitea_repository()) queued = main_module.store.enqueue_job( "gitea:64", _gitea_repository().latest_revision, ScanTrigger.MANUAL, ) monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False) monkeypatch.delenv("REPOPULSE_MAINTENANCE_TOKEN", raising=False) client = TestClient(main_module.app) assert client.post("/api/v1/operator/maintenance/enter").status_code == 503 monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret") assert client.get("/api/v1/meta").json()["mutation_authentication"] == "perimeter_required" assert client.post("/api/v1/operator/maintenance/enter").status_code == 401 assert client.get("/api/v1/operator/maintenance").status_code == 401 headers = {"X-RepoPulse-Token": "operator-secret"} entered = client.post("/api/v1/operator/maintenance/enter", headers=headers) assert entered.status_code == 200 assert entered.json()["enabled"] is True assert entered.json()["queue_depth"] == 1 assert entered.json()["active_jobs"] == 0 assert entered.json()["active_drained"] is True assert entered.json()["drained"] is False changed_at = entered.json()["changed_at"] assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 423 assert main_module.store.maintenance_status()["changed_at"] == changed_at assert client.get("/api/v1/operator/maintenance", headers=headers).json()["enabled"] is True assert client.get("/api/v1/meta").json()["maintenance"]["enabled"] is True restarted = ScanStore(str(database)) assert restarted.maintenance_status()["enabled"] is True assert restarted.get_job(queued.id).status == ScanJobStatus.QUEUED assert client.post("/api/v1/operator/maintenance/exit").status_code == 401 exited = client.post("/api/v1/operator/maintenance/exit", headers=headers) assert exited.status_code == 200 assert exited.json()["enabled"] is False assert ScanStore(str(database)).maintenance_status()["enabled"] is False def test_paused_mode_rejects_api_mutations_but_keeps_read_endpoints_available(tmp_path: Path, monkeypatch) -> None: main_module.store = ScanStore(str(tmp_path / "history.db")) main_module.job_runner = None repository = _gitea_repository() main_module.store.upsert_repository(repository) queued = main_module.store.enqueue_job( repository.id, repository.latest_revision, ScanTrigger.MANUAL, ) monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False) monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret") client = TestClient(main_module.app) headers = {"X-RepoPulse-Token": "operator-secret"} assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 200 mutation_requests = [ ("/api/v1/scans", {}), ("/api/v1/repositories/sync", {}), ("/api/v1/repositories/gitea%3A64/scans", {"json": {"force": True}}), ("/api/v1/webhooks/gitea", {"content": b"not validated while paused"}), ("/api/v1/operator/database-recovery/acknowledge", {}), ] for path, kwargs in mutation_requests: response = client.post(path, **kwargs) assert response.status_code == 423, (path, response.status_code, response.text) assert client.get("/api/v1/repositories").status_code == 200 assert client.get("/api/v1/scan-jobs/" + queued.id).status_code == 200 assert main_module.store.queue_stats()["queued"] == 1 assert main_module.store.get_job(queued.id).status == ScanJobStatus.QUEUED def test_paused_worker_makes_no_scheduler_writes_or_job_claims(tmp_path: Path) -> None: store = ScanStore(str(tmp_path / "history.db")) repository = _gitea_repository() store.upsert_repository(repository) queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED) store.set_state("worker_heartbeat", "before-maintenance") store.set_state("last_gitea_sync", "prior-sync") store.set_maintenance_mode(True) class CountingGitea: configured = True def __init__(self) -> None: self.sync_calls = 0 def sync(self, _store): self.sync_calls += 1 raise AssertionError("scheduler must not sync in maintenance mode") gitea = CountingGitea() runner = ScanJobRunner(store, gitea=gitea) # type: ignore[arg-type] assert runner._run_iteration() is None assert gitea.sync_calls == 0 assert store.get_state("worker_heartbeat") == "before-maintenance" assert store.get_state("last_gitea_sync") == "prior-sync" assert store.claim_next_job() is None assert store.queue_stats()["queued"] == 1 assert store.get_job(queued.id).status == ScanJobStatus.QUEUED def test_already_claimed_job_can_finish_during_maintenance(tmp_path: Path) -> None: source = tmp_path / "source" source.mkdir() (source / "README.md").write_text("# Existing work\n", encoding="utf-8") (source / "app.py").write_text("print('ok')\n", encoding="utf-8") store = ScanStore(str(tmp_path / "history.db")) repository = Repository( id="local:existing-job", provider="local", provider_repository_id="existing-job", owner="local", name="Existing job", full_name="local/Existing job", default_branch="main", ) store.upsert_repository(repository, source_path=str(source)) store.enqueue_job(repository.id, None, ScanTrigger.MANUAL) claimed = store.claim_next_job() assert claimed is not None runner = ScanJobRunner(store, gitea=GiteaClient()) store.set_maintenance_mode(True) assert store.maintenance_status()["enabled"] is True assert store.queue_stats()["active"] == 1 runner._process(claimed) assert store.get_job(claimed.id).status == ScanJobStatus.COMPLETED assert store.queue_stats()["active"] == 0 assert store.maintenance_status()["enabled"] is True def test_invalid_persisted_maintenance_state_fails_closed(tmp_path: Path) -> None: store = ScanStore(str(tmp_path / "history.db")) store.set_state("maintenance_mode", "not-json") assert store.maintenance_status()["enabled"] is True with pytest.raises(MaintenanceModeError): store.enqueue_job("missing", "revision", ScanTrigger.MANUAL)