"""Prometheus /metrics endpoint contract tests.""" from __future__ import annotations import uuid import pytest from app.scheduled_jobs.models import ScheduledJob @pytest.mark.asyncio async def test_metrics_open_when_no_token_in_development(client, monkeypatch): """Empty PLATFORM_METRICS_TOKEN + APP_ENV='development' → open endpoint.""" from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "") monkeypatch.setattr(settings, "APP_ENV", "development") resp = await client.get("/metrics") assert resp.status_code == 200 text = resp.text assert "helix_scheduled_job_runs_total" in text assert "# TYPE helix_scheduled_job_runs_total counter" in text # Round-62: ``status='running'`` excluded from the counter (transient # rows would make it non-monotonic and break rate()). The transient # signal moves to the sibling gauge. assert "helix_scheduled_job_runs_active" in text assert "# TYPE helix_scheduled_job_runs_active gauge" in text assert "helix_scheduled_job_slo_status" in text assert "helix_offboarding_in_flight" in text assert "helix_compliance_incidents_open" in text @pytest.mark.asyncio async def test_metrics_refused_when_no_token_in_non_development( client, monkeypatch, ): """Empty PLATFORM_METRICS_TOKEN + APP_ENV != 'development' → 503. Round-62 hardening: leaving the token empty in prod would expose cron topology to anyone hitting the public ALB. The endpoint refuses rather than rendering, so the contract holds without raising at startup (which would brick rollouts when the .env hasn't been pre-staged with a fresh secret). """ from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "") monkeypatch.setattr(settings, "APP_ENV", "production") resp = await client.get("/metrics") assert resp.status_code == 503 # The error body uses the standard envelope — surface the code so # operators grepping prod logs see WHY the scrape 503'd. body = resp.json() assert body["errors"][0]["code"] == "metrics.not_configured" @pytest.mark.asyncio async def test_metrics_unauthorized_when_token_set_and_missing_header( client, monkeypatch, ): from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "the-secret") resp = await client.get("/metrics") assert resp.status_code == 401 @pytest.mark.asyncio async def test_metrics_authorized_when_correct_bearer_token( client, monkeypatch, ): from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "the-secret") resp = await client.get( "/metrics", headers={"Authorization": "Bearer the-secret"}, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_metrics_renders_known_handlers_after_seeding_job( client, db_session, monkeypatch, ): """A ScheduledJob row appears in last_status / failure_count series.""" from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "") monkeypatch.setattr(settings, "APP_ENV", "development") db_session.add(ScheduledJob( id=uuid.uuid4(), name="hr.process_advance", display_name="离职流程自动推进", handler_key="hr.process_advance", trigger_type="interval", interval_seconds=900, enabled=True, last_status="ok", failure_count=2, )) await db_session.flush() resp = await client.get("/metrics") assert resp.status_code == 200 text = resp.text assert 'helix_scheduled_job_last_status{handler_key="hr.process_advance"}' in text assert ( 'helix_scheduled_job_failure_count{handler_key="hr.process_advance"} 2' in text ) # SLO line for that handler must carry its declared tier (P0). assert ( 'helix_scheduled_job_slo_status{handler_key="hr.process_advance",tier="P0"}' in text ) @pytest.mark.asyncio async def test_metrics_content_type_is_prometheus_compliant( client, monkeypatch, ): from app.core.config import settings monkeypatch.setattr(settings, "PLATFORM_METRICS_TOKEN", "") monkeypatch.setattr(settings, "APP_ENV", "development") resp = await client.get("/metrics") assert resp.status_code == 200 assert "text/plain" in resp.headers.get("content-type", "") # Format version per Prometheus exposition spec. assert "version=0.0.4" in resp.headers.get("content-type", "")