"""HTTP integration tests for the resource-creator endpoints. Targets the gap that allowed PR #466's ``AuditService.write_audit`` (a non-existent static method, not the ``AuditService(db).log()`` instance pattern every other domain uses) to ship + remain broken through PR #500 / #502 / #507 — the service-layer tests covered the resolve / sweep logic but no integration test exercised the actual HTTP path the frontend hits. Result: every successful ``GET /cmdb/cost/resources/{int}/{rid}/creator`` 500'd after the audit-API quota was already burnt, with zero observability. This file pins the contract: * GET /creator → 200 on resolved-from-cache hits, audit row written * GET /creator?refresh=true → 200 on driver-success, audit row written, force=True passed through to the service * POST /creator/retry → 200, audit row written, attempt_count reset, driver called once * Non-finance / non-cloud-admin role → 403 * Missing integration → 404 (no driver call, no audit row) * Non-UUID integration_id → 422 ValidationError * Empty resource_id → 422 * Oversized resource_id (>512) → 422 * Sensitive-endpoint rate-limit (10/h) → 429 after exhaustion These wire-shape assertions catch the class of regression that "all 33 service tests pass" misses — a wrongly-named audit method + AttributeError surface at the route boundary. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest import pytest_asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.domains.cmdb import resource_creator_service from app.domains.cmdb.models import ( RESOURCE_CREATOR_MAX_ATTEMPTS, RESOURCE_CREATOR_SOURCE_CLOUDTRAIL, RESOURCE_CREATOR_STATUS_ERROR, RESOURCE_CREATOR_STATUS_RESOLVED, CmdbResourceCreator, ) from app.integrations.driver_base import ResourceCreatorInfo from app.integrations.models import Integration class _FakeCloudDriver: """Minimal stand-in. Mirrors the fake in ``test_resource_creator_service.py`` — duck-typed, not inherited from CloudDriver to avoid abstractmethod traps. """ def __init__(self, get_creator): self._get_creator = get_creator self.calls = 0 async def get_resource_creator(self, resource_id, **kwargs): self.calls += 1 return await self._get_creator(resource_id, **kwargs) async def close(self): pass def _install_fake_driver(monkeypatch, fake: _FakeCloudDriver) -> None: monkeypatch.setattr( resource_creator_service, "CloudDriver", _FakeCloudDriver, ) async def _build(integration_id, *, session=None): return fake monkeypatch.setattr( resource_creator_service, "build_driver_by_id", _build, ) @pytest_asyncio.fixture async def aws_integration(db_session: AsyncSession) -> Integration: integ = Integration( id=uuid.uuid4(), kind="cloud", provider="aws_cloud", name="Test AWS Router", slug="test-aws-rc-router", enabled=True, config_public={}, ) db_session.add(integ) await db_session.commit() await db_session.refresh(integ) return integ # ── GET endpoint ─────────────────────────────────────────────── @pytest.mark.asyncio async def test_get_creator_writes_audit_row_on_success( client, admin_user, db_session, aws_integration, monkeypatch, ): """**This is the regression test for the PR #466 audit bug.** Every prior PR claimed the GET endpoint wrote a ``cmdb.resource_creator.lookup`` audit row; in fact every call raised AttributeError on ``AuditService.write_audit`` (which doesn't exist) and 500'd. Now that the call is rewritten to ``AuditService(db).log(...)``, this test pins the audit-row landing so a future refactor that drifts the method name back breaks loudly. """ _, token = admin_user async def _fake_get(rid, **kw): return ResourceCreatorInfo( principal_id="arn:test", email="creator@test.com", source="cloudtrail", ) _install_fake_driver(monkeypatch, _FakeCloudDriver(_fake_get)) integ_id = str(aws_integration.id) resp = await client.get( f"/api/v1/cmdb/cost/resources/{integ_id}/i-router-1/creator", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True assert body["data"]["status"] == "resolved" assert body["data"]["creator"]["email"] == "creator@test.com" # The new ``serialize_resource_creator_row`` helper guarantees # this key — both HTTP + MCP twins now mechanically identical. assert "last_attempt_at" in body["data"] # Audit row exists with correct action / resource_id shape. audit_q = await db_session.execute( select(AuditLog).where( AuditLog.action == "cmdb.resource_creator.lookup", ), ) rows = audit_q.scalars().all() assert len(rows) >= 1 row = rows[-1] assert row.resource_type == "cmdb.resource_creator" assert row.resource_id == f"{integ_id}:i-router-1"[:255] assert row.actor_type == "user" assert row.status == "success" @pytest.mark.asyncio async def test_get_creator_403_for_employee( client, employee_user, aws_integration, ): """Finance/cloud-read RBAC must reject plain employee.""" _, token = employee_user integ_id = str(aws_integration.id) resp = await client.get( f"/api/v1/cmdb/cost/resources/{integ_id}/i-emp-1/creator", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 # ── POST /retry endpoint ─────────────────────────────────────── @pytest.mark.asyncio async def test_post_retry_writes_audit_and_resets_attempt_count( client, admin_user, db_session, aws_integration, monkeypatch, ): """Same audit-row regression test for POST /creator/retry — plus assert the attempt-count reset actually persisted to DB. """ _, token = admin_user integ_id = str(aws_integration.id) stuck = CmdbResourceCreator( integration_id=integ_id, resource_id="i-router-stuck", source=RESOURCE_CREATOR_SOURCE_CLOUDTRAIL, status=RESOURCE_CREATOR_STATUS_ERROR, attempt_count=RESOURCE_CREATOR_MAX_ATTEMPTS, last_attempt_at=datetime.now(UTC), last_error="StaleThrottling", ) db_session.add(stuck) await db_session.commit() async def _fake_get(rid, **kw): return ResourceCreatorInfo( principal_id="arn:retried", email="winner@test.com", source="cloudtrail", ) fake = _FakeCloudDriver(_fake_get) _install_fake_driver(monkeypatch, fake) resp = await client.post( f"/api/v1/cmdb/cost/resources/{integ_id}/i-router-stuck/creator/retry", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["data"]["status"] == "resolved" assert fake.calls == 1 # driver was actually invoked # DB row mutation persisted (this fails if the route 500'd # mid-commit on the now-broken audit-write, which it would # have done pre-fix). refreshed_q = await db_session.execute( select(CmdbResourceCreator).where( CmdbResourceCreator.integration_id == integ_id, CmdbResourceCreator.resource_id == "i-router-stuck", ), ) row = refreshed_q.scalar_one() assert row.status == RESOURCE_CREATOR_STATUS_RESOLVED assert row.attempt_count < RESOURCE_CREATOR_MAX_ATTEMPTS assert row.creator_email == "winner@test.com" # Audit row landed with retry action. audit_q = await db_session.execute( select(AuditLog).where( AuditLog.action == "cmdb.resource_creator.retry", ), ) rows = audit_q.scalars().all() assert len(rows) >= 1 @pytest.mark.asyncio async def test_post_retry_403_for_employee( client, employee_user, aws_integration, ): _, token = employee_user integ_id = str(aws_integration.id) resp = await client.post( f"/api/v1/cmdb/cost/resources/{integ_id}/i-emp/creator/retry", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_post_retry_404_for_unknown_integration( client, admin_user, db_session, monkeypatch, ): """Unknown integration must NOT call the driver and MUST NOT write an audit row. The route should fail-closed at LookupError → 404 well before any audit-API or audit_logs side effects. """ _, token = admin_user driver_calls = {"n": 0} async def _fake_get(rid, **kw): driver_calls["n"] += 1 return None _install_fake_driver(monkeypatch, _FakeCloudDriver(_fake_get)) # Pre-count audit rows so we can assert delta=0. pre = await db_session.execute( select(AuditLog).where( AuditLog.action == "cmdb.resource_creator.retry", ), ) pre_count = len(pre.scalars().all()) bogus = str(uuid.uuid4()) resp = await client.post( f"/api/v1/cmdb/cost/resources/{bogus}/i-x/creator/retry", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 assert driver_calls["n"] == 0 # short-circuited before driver # Audit rows unchanged. post = await db_session.execute( select(AuditLog).where( AuditLog.action == "cmdb.resource_creator.retry", ), ) assert len(post.scalars().all()) == pre_count @pytest.mark.asyncio async def test_post_retry_400_on_non_uuid_integration_id( client, admin_user, ): """Defence-in-depth UUID validation (was missing in PR #508 initial draft; added after security review caught the MCP/HTTP asymmetry). """ _, token = admin_user resp = await client.post( "/api/v1/cmdb/cost/resources/not-a-uuid/i/creator/retry", headers={"Authorization": f"Bearer {token}"}, ) # FastAPI / our handler maps ValidationError → 400 (or 422 via # the global exception handler — accept either to stay robust # to the project's exception mapping conventions). assert resp.status_code in (400, 422) @pytest.mark.asyncio async def test_post_retry_400_on_oversized_resource_id( client, admin_user, aws_integration, ): """`resource_id` column is String(512); 513+ chars must fail fast at the route level, not after a partial UPDATE. """ _, token = admin_user integ_id = str(aws_integration.id) oversized = "x" * 600 resp = await client.post( f"/api/v1/cmdb/cost/resources/{integ_id}/{oversized}/creator/retry", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code in (400, 422) @pytest.mark.asyncio async def test_get_creator_400_on_oversized_resource_id( client, admin_user, aws_integration, ): """Round 3: same length cap on GET (was missing pre-Round-3). Without this gate, an oversized resource_id would burn audit- API quota in the driver call before the asyncmy INSERT DataError on String(512) surfaces as 500. """ _, token = admin_user integ_id = str(aws_integration.id) oversized = "x" * 600 resp = await client.get( f"/api/v1/cmdb/cost/resources/{integ_id}/{oversized}/creator", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code in (400, 422) @pytest.mark.asyncio async def test_get_creator_cache_hit_skips_rate_limit( client, admin_user, db_session, aws_integration, monkeypatch, ): """The Round 3 surgical rate-limit gate: cache HIT (a row already exists for this integration+resource) is a pure SELECT and MUST NOT consume the per-user 10/h quota. Dashboard browsing is the common case and must not be throttled. Strategy: seed a resolved row → call GET 11 times (way past the 10/h cap) → assert all 11 still 200 because none of them triggered a driver call. """ from app.domains.cmdb.models import ( RESOURCE_CREATOR_SOURCE_CLOUDTRAIL, RESOURCE_CREATOR_STATUS_RESOLVED, ) _, token = admin_user integ_id = str(aws_integration.id) # Pre-seed: status='resolved' so cache-hit path triggers. row = CmdbResourceCreator( integration_id=integ_id, resource_id="i-cache-hit", source=RESOURCE_CREATOR_SOURCE_CLOUDTRAIL, status=RESOURCE_CREATOR_STATUS_RESOLVED, creator_principal_id="arn:test", creator_email="cached@test.com", attempt_count=1, resolved_at=datetime.now(UTC), last_attempt_at=datetime.now(UTC), ) db_session.add(row) await db_session.commit() # Driver MUST NOT be called on cache hits — install a fake # that fails loudly if invoked, so test fails if cache logic # regresses to "always fire driver". async def _fake_get(rid, **kw): raise AssertionError( f"driver should not be called on cache hit, got {rid}", ) _install_fake_driver(monkeypatch, _FakeCloudDriver(_fake_get)) # 11 calls — far exceeds the 10/h sensitive-rate-limit cap. # If cache hits were incorrectly gated, the 11th would 429. for _ in range(11): resp = await client.get( f"/api/v1/cmdb/cost/resources/{integ_id}/i-cache-hit/creator", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["status"] == "resolved"