"""E2E API tests for the certificate endpoints (RBAC + CRUD).""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import CERT_SOURCE_PROBED, CmdbCertificate PREFIX = "/api/v1/cmdb/certificates" async def _mk_cert(db, *, host, owner_id=None, days=60): # An explicit per-cert owner in a test stands for a manual override # (owner_is_manual=True) — under live resolution a bare snapshot owner # is ignored, so callers wanting the cert attributed to a person set # the override. row = CmdbCertificate( id=uuid.uuid4(), dedup_key=f"{CERT_SOURCE_PROBED}:{host}:443", source=CERT_SOURCE_PROBED, probe_host=host, common_name=host, not_after=datetime.now(UTC) + timedelta(days=days), status="valid", owner_person_id=owner_id, owner_is_manual=owner_id is not None, ) db.add(row) await db.commit() return row @pytest.mark.asyncio async def test_employee_sees_only_own_certs( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, ): emp, _ = employee_user # Owned via a manual override; the other cert has no resolvable owner. await _mk_cert(db_session, host="mine.example.com", owner_id=str(emp.id)) await _mk_cert(db_session, host="other.example.com", owner_id=None) resp = await client.get(PREFIX, headers=employee_headers) assert resp.status_code == 200 hosts = {c["probe_host"] for c in resp.json()["data"]} assert hosts == {"mine.example.com"} @pytest.mark.asyncio async def test_employee_sees_cert_via_dns_record_ownership( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, ): """Realistic path: the employee owns the DNS record for a host, so the probed cert on that host resolves to them with no manual override.""" from app.domains.cmdb.models import CmdbDnsRecord, CmdbDomain from app.integrations.models import Integration emp, _ = employee_user integ = Integration( id=uuid.uuid4(), kind="cloud", provider="aliyun", name="pytest-cert-scope", slug=f"scope-{uuid.uuid4().hex[:8]}", enabled=True, is_primary=False, capabilities=["dns_records"], ) db_session.add(integ) await db_session.flush() db_session.add(CmdbDomain( id=uuid.uuid4(), integration_id=integ.id, domain_name="scope.example.com", owner_person_id=None, )) db_session.add(CmdbDnsRecord( integration_id=integ.id, zone_name="scope.example.com", record_id="srr1", name="app", record_type="A", value="1.2.3.4", status="enable", owner_person_id=str(emp.id), )) await db_session.commit() await _mk_cert(db_session, host="app.scope.example.com", owner_id=None) resp = await client.get(PREFIX, headers=employee_headers) assert resp.status_code == 200 hosts = {c["probe_host"] for c in resp.json()["data"]} assert hosts == {"app.scope.example.com"} item = next( c for c in resp.json()["data"] if c["probe_host"] == "app.scope.example.com" ) assert item["owner_person_id"] == str(emp.id) assert item["owner_source"] == "dns_record" @pytest.mark.asyncio async def test_admin_sees_all_certs( client: AsyncClient, db_session: AsyncSession, admin_headers, ): await _mk_cert(db_session, host="a.example.com") await _mk_cert(db_session, host="b.example.com") resp = await client.get(PREFIX, headers=admin_headers) assert resp.status_code == 200 hosts = {c["probe_host"] for c in resp.json()["data"]} assert {"a.example.com", "b.example.com"} <= hosts @pytest.mark.asyncio async def test_stats_requires_admin( client: AsyncClient, db_session: AsyncSession, admin_headers, employee_headers, ): await _mk_cert(db_session, host="x.example.com", days=3) await _mk_cert(db_session, host="y.example.com", days=-1) # employee forbidden r_emp = await client.get(f"{PREFIX}/stats", headers=employee_headers) assert r_emp.status_code == 403 # admin ok r = await client.get(f"{PREFIX}/stats", headers=admin_headers) assert r.status_code == 200 data = r.json()["data"] assert data["expired"] >= 1 assert data["expiring_within_7d"] >= 1 assert data["total"] >= 2 @pytest.mark.asyncio async def test_create_requires_admin(client: AsyncClient, employee_headers): body = { "common_name": "manual.example.com", "not_after": (datetime.now(UTC) + timedelta(days=30)).isoformat(), } resp = await client.post(PREFIX, json=body, headers=employee_headers) assert resp.status_code == 403 @pytest.mark.asyncio async def test_admin_create_patch_delete( client: AsyncClient, admin_headers, employee_user, ): emp, _ = employee_user body = { "common_name": "manual.example.com", "not_after": (datetime.now(UTC) + timedelta(days=20)).isoformat(), "issuer": "Internal CA", "environment": "prod", } create = await client.post(PREFIX, json=body, headers=admin_headers) assert create.status_code == 201, create.text cert = create.json()["data"] cert_id = cert["id"] assert cert["source"] == "manual" assert cert["status"] == "expiring" # within 30d # PATCH owner + remark patch = await client.patch( f"{PREFIX}/{cert_id}", json={"owner_person_id": str(emp.id), "remark": "appliance"}, headers=admin_headers, ) assert patch.status_code == 200, patch.text assert patch.json()["data"]["owner_person_id"] == str(emp.id) assert patch.json()["data"]["remark"] == "appliance" # DELETE (soft) delete = await client.delete(f"{PREFIX}/{cert_id}", headers=admin_headers) assert delete.status_code == 200 # gone from list after = await client.get(PREFIX, headers=admin_headers) ids = {c["id"] for c in after.json()["data"]} assert cert_id not in ids @pytest.mark.asyncio async def test_get_detail_scope_enforced( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, ): other = await _mk_cert(db_session, host="secret.example.com", owner_id=None) resp = await client.get(f"{PREFIX}/{other.id}", headers=employee_headers) assert resp.status_code == 403 @pytest.mark.asyncio async def test_stale_probe_status_is_rederived_from_not_after( client: AsyncClient, db_session: AsyncSession, admin_headers, ): """A row whose probe stopped keeps a status contradicting its own expiry. ``status`` is written by ``derive_cert_status`` at probe time and never re-derived. Production 2026-08-18: ``tem.pingcap.net``, ``tem-test.pingcap.net`` and ``do3.pingcap.net`` all still read ``expiring`` while their certificate had expired **52 days earlier** — their last probe was in June while every other host was probed that morning. Readers must see the lifecycle the ``not_after`` implies, and be told the stored copy had gone stale. ``unreachable`` is deliberately NOT re-derived: it records the probe outcome ("could not verify"), a different question from "has it expired". """ now = datetime.now(UTC) stale = CmdbCertificate( id=uuid.uuid4(), dedup_key=f"{CERT_SOURCE_PROBED}:stale.example.com:443", source=CERT_SOURCE_PROBED, probe_host="stale.example.com", common_name="stale.example.com", not_after=now - timedelta(days=52), # What the June probe wrote, frozen ever since. status="expiring", last_probed_at=now - timedelta(days=53), owner_person_id=None, ) fresh = CmdbCertificate( id=uuid.uuid4(), dedup_key=f"{CERT_SOURCE_PROBED}:fresh.example.com:443", source=CERT_SOURCE_PROBED, probe_host="fresh.example.com", common_name="fresh.example.com", not_after=now + timedelta(days=200), status="valid", last_probed_at=now, ) unreachable = CmdbCertificate( id=uuid.uuid4(), dedup_key=f"{CERT_SOURCE_PROBED}:unreach.example.com:443", source=CERT_SOURCE_PROBED, probe_host="unreach.example.com", common_name="unreach.example.com", # In date, but the probe could not connect. not_after=now + timedelta(days=90), status="unreachable", last_error="[Errno 111] Connection refused", last_probed_at=now, ) db_session.add_all([stale, fresh, unreachable]) await db_session.commit() resp = await client.get(f"{PREFIX}?page_size=200", headers=admin_headers) assert resp.status_code == 200 by_host = {c["probe_host"]: c for c in resp.json()["data"]} # Stale row reads its real lifecycle, and says the stored copy was stale. assert by_host["stale.example.com"]["status"] == "expired" assert by_host["stale.example.com"]["status_stale"] is True # A freshly-probed row is untouched and not flagged. assert by_host["fresh.example.com"]["status"] == "valid" assert by_host["fresh.example.com"]["status_stale"] is False # Probe outcome survives — it is not a lifecycle answer. assert by_host["unreach.example.com"]["status"] == "unreachable" assert by_host["unreach.example.com"]["status_stale"] is False # The status FILTER must agree with what the rows display, or "show me # the expiring ones" returns rows labelled expired and hides the stale # ones that motivated the effective read. r_expired = await client.get( f"{PREFIX}?status=expired&page_size=200", headers=admin_headers, ) assert r_expired.status_code == 200 assert "stale.example.com" in { c["probe_host"] for c in r_expired.json()["data"] } r_expiring = await client.get( f"{PREFIX}?status=expiring&page_size=200", headers=admin_headers, ) assert r_expiring.status_code == 200 assert "stale.example.com" not in { c["probe_host"] for c in r_expiring.json()["data"] }