"""Tests for the RG-aware branch of ``GET /api/v1/cmdb/billing/daily``. PR #251 added a per-(scope_type) dispatch to ``list_billing_daily``: * Tenant-level scopes (Azure subscription, AWS account, GCP project, Aliyun account) → ``cmdb_cloud_billing_daily`` (the original path the daily cost cron writes). * Resource-group scopes (Azure RG, ``parent_scope_id`` set, ``scope_type='azure_resource_group'``) → ``cmdb_cost_facts`` filtered to ``integration_id`` + ``resource_group=``, aggregated per day. Pre-PR-251 the endpoint returned ``$0`` for every RG scope because the daily cron only writes at tenant level — visible bug: NexusCRM RG scope detail page showed ``$0/30d`` while the parent subscription had real spend. Coverage: * RG scope hits the cost_facts branch and gets back the right SUM shape (date, total_cost_usd, cost_by_service). * Facts in a DIFFERENT RG under the same integration are NOT included. * Facts in the matching RG but on a different integration are NOT included. * Empty RG (no facts) returns ``data=[]`` (not a server error). * Tenant-level scope still goes through ``cmdb_cloud_billing_daily`` (regression guard). """ from __future__ import annotations import uuid from datetime import UTC, datetime from datetime import date as _date from decimal import Decimal import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import ( CmdbCloudBillingDaily, CmdbCloudScope, CmdbCostFact, ) from app.integrations.models import Integration async def _make_integration(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="azure", name="Azure RG Test", slug=f"az-rg-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["billing"], ) db.add(row) await db.commit() return row async def _make_subscription_scope( db: AsyncSession, integration: Integration, *, external_id: str, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="azure_subscription", external_id=external_id, display_name=external_id, status="active", ) db.add(s) await db.commit() return s async def _make_rg_scope( db: AsyncSession, integration: Integration, *, rg_name: str, parent: CmdbCloudScope, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="azure_resource_group", external_id=rg_name, display_name=f"RG · {rg_name}", status="active", parent_scope_id=str(parent.id), ) db.add(s) await db.commit() return s async def _add_cost_fact( db: AsyncSession, *, integration: Integration, fact_date: _date, amount_usd: Decimal, resource_group: str, service: str = "Foundry Models", ) -> CmdbCostFact: fact = CmdbCostFact( fact_key=uuid.uuid4().hex, date=fact_date, integration_id=str(integration.id), resource_group=resource_group, service=service, amount_usd=amount_usd, amount_native=amount_usd, currency="USD", ) db.add(fact) await db.commit() return fact async def _add_daily( db: AsyncSession, *, integration: Integration, scope: CmdbCloudScope, fact_date: _date, amount_usd: Decimal, ) -> CmdbCloudBillingDaily: row = CmdbCloudBillingDaily( id=uuid.uuid4(), integration_id=str(integration.id), scope_id=str(scope.id), date=fact_date, total_cost_usd=amount_usd, currency="USD", cost_by_service={}, ) db.add(row) await db.commit() return row @pytest.mark.asyncio async def test_rg_scope_routes_to_cost_facts( db_session: AsyncSession, client: AsyncClient, admin_headers: dict[str, str], ): """RG-typed scope: ``/billing/daily?scope_id=`` returns rows aggregated from cost_facts.resource_group, NOT billing_daily.""" integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-rg-test", ) rg = await _make_rg_scope( db_session, integ, rg_name="MyAppRG", parent=sub, ) # API default ``end_dt`` is ``datetime.now(UTC).date()`` — keep the # test fixture on the same clock so a TZ skew at the test run hour # doesn't push the fact past the window. Mirrors the way the # production ingest crons stamp facts on UTC dates. today = datetime.now(UTC).date() # Two facts on different days under THIS RG, two service rows # for one of those days so we can verify cost_by_service folding. await _add_cost_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("12.50"), resource_group="MyAppRG", service="Foundry Models", ) await _add_cost_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("3.00"), resource_group="MyAppRG", service="Storage", ) yesterday = _date.fromordinal(today.toordinal() - 1) await _add_cost_fact( db_session, integration=integ, fact_date=yesterday, amount_usd=Decimal("8.00"), resource_group="MyAppRG", service="Foundry Models", ) # A fact in a DIFFERENT RG on the same integration — must NOT # appear in this query. await _add_cost_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("999.00"), resource_group="some-other-rg", service="Foundry Models", ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(rg.id)}, headers=admin_headers, ) assert resp.status_code == 200 rows = resp.json()["data"] # Two days returned (today + yesterday), sorted ascending. assert len(rows) == 2 today_row = next(r for r in rows if r["date"] == today.isoformat()) yest_row = next( r for r in rows if r["date"] == yesterday.isoformat() ) assert today_row["scope_id"] == str(rg.id) assert today_row["integration_id"] == str(integ.id) assert today_row["total_cost_usd"] == pytest.approx(15.50) # Service breakdown folded into cost_by_service dict. assert today_row["cost_by_service"]["Foundry Models"] == pytest.approx(12.50) assert today_row["cost_by_service"]["Storage"] == pytest.approx(3.00) assert yest_row["total_cost_usd"] == pytest.approx(8.00) # The $999 fact in some-other-rg did NOT leak in. assert sum(r["total_cost_usd"] for r in rows) == pytest.approx(23.50) @pytest.mark.asyncio async def test_rg_scope_empty_returns_empty_list( db_session: AsyncSession, client: AsyncClient, admin_headers: dict[str, str], ): """An RG with zero facts in the window returns [], not 404 / 500. This is the NexusCRM AK case in prod — the RG was minted but had no usage yet.""" integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-rg-empty", ) rg = await _make_rg_scope( db_session, integ, rg_name="EmptyRG", parent=sub, ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(rg.id)}, headers=admin_headers, ) assert resp.status_code == 200 assert resp.json()["data"] == [] async def _add_grant( db: AsyncSession, *, scope: CmdbCloudScope, person_id, status: str = "active", ): """Minimal CmdbCloudAccessGrant fixture for auth-inherit tests.""" from app.domains.cmdb.models import CmdbCloudAccessGrant grant = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(person_id), role_key="Reader", status=status, ) db.add(grant) await db.commit() return grant @pytest.mark.asyncio async def test_rg_branch_inherits_parent_subscription_grant_for_employee( db_session: AsyncSession, client: AsyncClient, employee_user, employee_headers: dict[str, str], ): """An employee who is on the parent subscription's grants (active status) can read the child RG's billing — the auth check walks one hop from RG → parent. Without this inheritance, every parent-sub member who deep-linked into a child RG would 403, which is a regression from the pre-PR-251 behaviour where the RG and the parent were a single scope row. Pinned by code-review pass 2 (P1 #2 / #3).""" user, _token = employee_user integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-with-emp-grant", ) await _add_grant(db_session, scope=sub, person_id=user.id) rg = await _make_rg_scope( db_session, integ, rg_name="ChildRGForEmp", parent=sub, ) # Seed one cost fact under the RG so the response isn't empty — # the test cares about 200-not-403 here, content is incidental. today = datetime.now(UTC).date() await _add_cost_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("5.00"), resource_group="ChildRGForEmp", ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(rg.id)}, headers=employee_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"][0]["total_cost_usd"] == pytest.approx(5.0) @pytest.mark.asyncio async def test_rg_branch_does_not_inherit_from_closed_parent( db_session: AsyncSession, client: AsyncClient, employee_user, employee_headers: dict[str, str], ): """A closed parent subscription (``status='closed'``) must NOT grant residual access to the surviving child RG. Soft-deleted / decommissioned parents would otherwise inherit privilege creep after the org rotates IT ownership of the tenant. Pinned by logic-review pass post-fix (P1 #1).""" from app.domains.cmdb.models import SCOPE_STATUS_CLOSED user, _token = employee_user integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-closed", ) sub.status = SCOPE_STATUS_CLOSED await db_session.commit() await _add_grant(db_session, scope=sub, person_id=user.id) rg = await _make_rg_scope( db_session, integ, rg_name="OrphanRG", parent=sub, ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(rg.id)}, headers=employee_headers, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_rg_branch_rejects_employee_with_no_grant_on_either_scope( db_session: AsyncSession, client: AsyncClient, employee_user, employee_headers: dict[str, str], ): """Negative test: an employee with grants on NEITHER the RG nor its parent must 403 on the RG billing endpoint.""" integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-without-emp-grant", ) rg = await _make_rg_scope( db_session, integ, rg_name="LockedRG", parent=sub, ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(rg.id)}, headers=employee_headers, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_rg_branch_rejects_mismatched_integration_id( db_session: AsyncSession, client: AsyncClient, admin_headers: dict[str, str], ): """RG scopes are integration-pinned. Passing ``?scope_id=& integration_id=`` must 400 instead of summing facts on the overridden integration with the RG's name — that would be a cross-tenant leak whenever RG names collide across clouds (``default``, ``rg-prod``, ``NexusCRM``). Pinned by code-review pass 1 (P0 finding).""" integ = await _make_integration(db_session) other_integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-mismatch", ) rg = await _make_rg_scope( db_session, integ, rg_name="MyAppRG", parent=sub, ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={ "scope_id": str(rg.id), "integration_id": str(other_integ.id), }, headers=admin_headers, ) assert resp.status_code == 400 payload = resp.json() assert payload["success"] is False assert any( err.get("code") == "RG_BILLING_INTEGRATION_MISMATCH" for err in payload.get("errors", []) ), payload # Sanity: same scope, MATCHING integration_id → 200 (proves the # 400 is targeted at the mismatch, not at the param itself). resp_ok = await client.get( "/api/v1/cmdb/billing/daily", params={ "scope_id": str(rg.id), "integration_id": str(integ.id), }, headers=admin_headers, ) assert resp_ok.status_code == 200 @pytest.mark.asyncio async def test_tenant_scope_still_uses_billing_daily( db_session: AsyncSession, client: AsyncClient, admin_headers: dict[str, str], ): """Regression guard: tenant-level (azure_subscription) scopes must still query ``cmdb_cloud_billing_daily``, not cost_facts. The two ledgers can drift (verified prod 2026-05-11: Azure facts $77k vs billing_daily $48k for the same 30-day window), so the tenant page MUST keep reading the daily ledger or finance reconciliation against vendor invoices breaks.""" integ = await _make_integration(db_session) sub = await _make_subscription_scope( db_session, integ, external_id="sub-tenant-test", ) # API default ``end_dt`` is ``datetime.now(UTC).date()`` — keep the # test fixture on the same clock so a TZ skew at the test run hour # doesn't push the fact past the window. Mirrors the way the # production ingest crons stamp facts on UTC dates. today = datetime.now(UTC).date() await _add_daily( db_session, integration=integ, scope=sub, fact_date=today, amount_usd=Decimal("42.42"), ) # Also seed a cost_fact under a child RG of this subscription — # it must NOT show up on the subscription's billing_daily query # (the subscription query reads billing_daily, period). await _add_cost_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("88.00"), resource_group="child-rg", service="Foundry Models", ) resp = await client.get( "/api/v1/cmdb/billing/daily", params={"scope_id": str(sub.id)}, headers=admin_headers, ) assert resp.status_code == 200 rows = resp.json()["data"] assert len(rows) == 1 assert rows[0]["total_cost_usd"] == pytest.approx(42.42) # The $88 fact under child-rg did NOT leak in. @pytest.mark.asyncio async def test_billing_daily_rejects_oversized_date_span( client: AsyncClient, admin_headers: dict[str, str], ): """Regression: a tenant-wide ``/billing/daily`` with an enormous date span must 422 (the table grows forever, so an unbounded scan would OOM the worker). Mirrors the CSV export's _resolve_daily_range cap.""" resp = await client.get( "/api/v1/cmdb/billing/daily", params={"start": "1970-01-01", "end": "2099-12-31"}, headers=admin_headers, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_billing_daily_rejects_inverted_range( client: AsyncClient, admin_headers: dict[str, str], ): """``start`` after ``end`` is a 422, not a silent empty result.""" resp = await client.get( "/api/v1/cmdb/billing/daily", params={"start": "2026-06-01", "end": "2026-05-01"}, headers=admin_headers, ) assert resp.status_code == 422, resp.text