"""「成本口径」方法论说明页契约(成本口径改造 PR5)。 Pins: * 目录静态描述符形状:每条含 key / provider / 核验状态(闭集)/ 规则原文字段;核验定稿的云(AWS 主 Org / AWS SG / GCP)标 verified,阿里云 semi_verified 且带两个待确认点,Azure 未接入, 腾讯/金山/UCloud 不在范围。 * RBAC:can_read_cloud(auditor / management 可读规则文字), employee 403。 * 保密红线:``reconciliation``(当月两口径对数区)仅保密圈 (can_view_invoice_measure)返回;auditor / management 拿到的 响应里恒为 null —— 研发可见响应绝不同框两口径。 """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.cost_methodology import ( CLOUD_METHODOLOGIES, VERIFICATION_STATUSES, get_methodology_catalog, ) from app.domains.cmdb.models import CmdbCostFact from app.integrations.models import Integration # ── 静态目录形状 ────────────────────────────────────────────────── def test_catalog_shape_and_statuses() -> None: keys = [c["key"] for c in CLOUD_METHODOLOGIES] assert keys == [ "aws_org", "aws_sg", "gcp", "aliyun_intl", "azure", "other_clouds", ] for entry in CLOUD_METHODOLOGIES: assert entry["verification_status"] in VERIFICATION_STATUSES for field in ( "title", "data_source", "list_rule", "public_rule", "adjustment_handling", ): assert isinstance(entry[field], str) and entry[field], ( entry["key"], field, ) for field in ( "exclusions", "forbidden_columns", "known_boundaries", "pending_confirmations", ): assert isinstance(entry[field], list), (entry["key"], field) def test_catalog_verification_states_match_signoff() -> None: by_key = {c["key"]: c for c in CLOUD_METHODOLOGIES} assert by_key["aws_org"]["verification_status"] == "verified" assert by_key["aws_sg"]["verification_status"] == "verified" assert by_key["gcp"]["verification_status"] == "verified" # 阿里云半核验:抢占式记账 + 牌价纯度两个点待云管理确认。 aliyun = by_key["aliyun_intl"] assert aliyun["verification_status"] == "semi_verified" assert len(aliyun["pending_confirmations"]) == 2 # Azure 待接入(PR6 落规则后翻 rules_ready);其余云不在范围。 assert by_key["azure"]["verification_status"] in ( "pending", "rules_ready", ) assert by_key["other_clouds"]["verification_status"] == "out_of_scope" def test_catalog_getter_returns_copies() -> None: got = get_methodology_catalog() got[0]["title"] = "mutated" assert CLOUD_METHODOLOGIES[0]["title"] != "mutated" def test_catalog_carries_no_amounts() -> None: """保密红线的静态面:目录文字只含规则,绝不含实付金额字段。""" for entry in CLOUD_METHODOLOGIES: assert "invoice_total" not in entry assert "list_total" not in entry # ── HTTP 契约 ───────────────────────────────────────────────────── @pytest_asyncio.fixture async def methodology_integration(db_session: AsyncSession) -> Integration: integ = Integration( id=str(uuid.uuid4()), kind="cloud", provider="aws", name="AWS methodology test", slug=f"aws-meth-{uuid.uuid4().hex[:8]}", enabled=True, ) db_session.add(integ) await db_session.commit() # 当月两条 fact:usage 行带 list,credit 行只有 invoice —— 对数区 # 应给出 list/invoice 合计与 credit 桶分布。 month_first = datetime.now(UTC).date().replace(day=1) db_session.add(CmdbCostFact( fact_key=uuid.uuid4().hex, date=month_first, integration_id=str(integ.id), service="EC2", amount_usd=Decimal("60"), amount_native=Decimal("60"), amount_usd_list=Decimal("100"), amount_native_list=Decimal("100"), cost_bucket="usage", currency="USD", )) db_session.add(CmdbCostFact( fact_key=uuid.uuid4().hex, date=month_first, integration_id=str(integ.id), service="Credit", amount_usd=Decimal("-5"), amount_native=Decimal("-5"), cost_bucket="credit", currency="USD", )) await db_session.commit() return integ @pytest.mark.asyncio async def test_methodology_shape_and_recon_for_secrecy_circle( client, finance_headers, methodology_integration: Integration, ) -> None: r = await client.get( "/api/v1/cmdb/cost/methodology", headers=finance_headers, ) assert r.status_code == 200, r.text data = r.json()["data"] assert [c["key"] for c in data["clouds"]] == [ "aws_org", "aws_sg", "gcp", "aliyun_intl", "azure", "other_clouds", ] recon = data["reconciliation"] assert recon is not None assert recon["month"] == datetime.now(UTC).date().strftime("%Y-%m") aws = next(x for x in recon["rows"] if x["provider"] == "aws") assert aws["invoice_total"] == pytest.approx(55.0) # 60 − 5 assert aws["list_total"] == pytest.approx(100.0) assert aws["delta"] == pytest.approx(-45.0) # invoice − list assert aws["buckets"]["credit"] == pytest.approx(-5.0) @pytest.mark.asyncio async def test_methodology_rules_visible_but_no_recon_outside_circle( client, auditor_headers, methodology_integration: Integration, ) -> None: """auditor 过 can_read_cloud 门 → 规则文字可读;对数区被裁剪。""" r = await client.get( "/api/v1/cmdb/cost/methodology", headers=auditor_headers, ) assert r.status_code == 200, r.text data = r.json()["data"] assert len(data["clouds"]) == 6 assert data["reconciliation"] is None # 保密红线:非保密圈响应正文里不得出现任何 invoice 金额字段。 assert "invoice_total" not in r.text @pytest.mark.asyncio async def test_methodology_employee_403(client, employee_headers) -> None: r = await client.get( "/api/v1/cmdb/cost/methodology", headers=employee_headers, ) assert r.status_code == 403, r.text