Files
RepoPulse-Public/backend/tests/test_maintenance.py
T

176 lines
7.1 KiB
Python

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)