test_wp12_metering_showback_api.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """Closed Flask/OpenAPI/RBAC contract for P3-WP12 Showback endpoints."""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. import yaml
  5. USER_UID = "01900000-0000-7000-8000-000000068912"
  6. class _MeteringService:
  7. calls: list[dict] = []
  8. def record_for_principal(self, *, principal_id, body):
  9. type(self).calls.append({"record": principal_id, "body": body})
  10. return {"event_uid": body["event_uid"], "persisted_before_ack": True}
  11. def showback_for_principal(self, *, principal_id, window, kind):
  12. type(self).calls.append({"read": principal_id, "window": window, "kind": kind})
  13. return {"window": window, "source_micros": 1250000, "allocated_micros": 1250000, "difference_micros": 0}
  14. def publish_allocation_for_principal(self, *, principal_id, body):
  15. type(self).calls.append({"allocation": principal_id, "body": body})
  16. return {"rule_uid": body["rule_uid"], "rule_version": body["rule_version"], "persisted_before_ack": True}
  17. def evaluate_budget_for_principal(self, *, principal_id, body):
  18. type(self).calls.append({"budget": principal_id, "body": body})
  19. return {"budget_uid": body["budget_uid"], "alert_created": True, "provider": "disabled"}
  20. def allocation_replay_for_principal(self, *, principal_id, window, rule_uid, rule_version):
  21. type(self).calls.append({"allocation_replay": principal_id, "window": window, "rule_uid": rule_uid, "rule_version": rule_version})
  22. return {"rule_uid": rule_uid, "rule_version": rule_version, "source_micros": 1250000, "allocated_micros": 1250000, "difference_micros": 0}
  23. EVENT = {
  24. "schema_version": 1,
  25. "event_uid": "api-event-001",
  26. "event_kind": "query",
  27. "occurred_at": "2026-08-18T00:00:00Z",
  28. "window_start": "2026-08-18T00:00:00Z",
  29. "window_end": "2026-08-18T00:05:00Z",
  30. "quantity": "1.250000",
  31. "unit": "gb",
  32. "idempotency_key": "api-idempotency-001",
  33. "evidence": {"digest": "a" * 64, "reference": "local-fixture://wp12/v1"},
  34. "mapping": {"department": "engineering", "project": "local-engineering", "cost_center": "local-fixture"},
  35. }
  36. ALLOCATION = {
  37. "schema_version": 1,
  38. "rule_uid": "api-allocation-001",
  39. "rule_version": 1,
  40. "effective_start": "2026-08-01T00:00:00Z",
  41. "effective_end": "2026-09-01T00:00:00Z",
  42. "mapping": {"department": "engineering", "project": "local-engineering", "cost_center": "local-fixture"},
  43. "allocations": [{"target": "local-engineering", "weight_micros": 1000000}],
  44. }
  45. BUDGET = {
  46. "schema_version": 1,
  47. "budget_uid": "api-budget-001",
  48. "window": "2026-08",
  49. "mapping": {"department": "engineering", "project": "local-engineering", "cost_center": "local-fixture"},
  50. "limit_micros": 2000000,
  51. "threshold_micros": 1000000,
  52. }
  53. def _client(monkeypatch):
  54. from app import create_app
  55. from app.api.system import metering_showback_routes
  56. _MeteringService.calls = []
  57. monkeypatch.setattr(metering_showback_routes, "_service", lambda: _MeteringService())
  58. monkeypatch.setattr(
  59. "app.core.system.auth.load_identity_from_token",
  60. lambda token, secret: {"id": USER_UID, "roles": [token]} if token in {"viewer", "admin"} else None,
  61. )
  62. app = create_app()
  63. app.config.update(TESTING=True)
  64. return app.test_client()
  65. def test_wp12_routes_are_rbac_closed_no_store_and_server_derived(monkeypatch):
  66. client = _client(monkeypatch)
  67. assert client.post("/api/system/metering/events", json=EVENT, headers={"Authorization": "Bearer viewer"}).status_code == 403
  68. rejected = client.post("/api/system/metering/events", json=EVENT | {"tenant_ref": "spoof"}, headers={"Authorization": "Bearer admin"})
  69. assert rejected.status_code == 400 and rejected.headers["Cache-Control"] == "no-store"
  70. created = client.post("/api/system/metering/events", json=EVENT, headers={"Authorization": "Bearer admin"})
  71. assert created.status_code == 201 and created.headers["Cache-Control"] == "no-store"
  72. assert _MeteringService.calls[0]["record"] == USER_UID
  73. shown = client.get("/api/system/metering/showback?window=2026-08", headers={"Authorization": "Bearer viewer"})
  74. assert shown.status_code == 200 and shown.headers["Cache-Control"] == "no-store"
  75. replay = client.get("/api/system/metering/allocation-replay?window=2026-08&rule_uid=api-allocation-001&rule_version=1", headers={"Authorization": "Bearer viewer"})
  76. assert replay.status_code == 200 and replay.headers["Cache-Control"] == "no-store"
  77. assert client.post("/api/system/metering/chargeback", json={}, headers={"Authorization": "Bearer admin"}).status_code == 404
  78. def test_wp12_control_routes_are_closed_rbac_and_no_store(monkeypatch):
  79. client = _client(monkeypatch)
  80. assert client.post("/api/system/metering/allocations", json=ALLOCATION, headers={"Authorization": "Bearer viewer"}).status_code == 403
  81. rejected = client.post("/api/system/metering/allocations", json=ALLOCATION | {"tenant_ref": "spoof"}, headers={"Authorization": "Bearer admin"})
  82. assert rejected.status_code == 400 and rejected.headers["Cache-Control"] == "no-store"
  83. created = client.post("/api/system/metering/allocations", json=ALLOCATION, headers={"Authorization": "Bearer admin"})
  84. assert created.status_code == 201 and created.headers["Cache-Control"] == "no-store"
  85. budget = client.post("/api/system/metering/budgets", json=BUDGET, headers={"Authorization": "Bearer admin"})
  86. assert budget.status_code == 201 and budget.headers["Cache-Control"] == "no-store"
  87. assert _MeteringService.calls[0]["allocation"] == USER_UID
  88. assert _MeteringService.calls[1]["budget"] == USER_UID
  89. def test_wp12_openapi_is_closed_permissioned_and_no_store():
  90. spec = yaml.safe_load(Path("docs/architecture/OPENAPI.yaml").read_text())
  91. event = spec["paths"]["/api/system/metering/events"]["post"]
  92. showback = spec["paths"]["/api/system/metering/showback"]["get"]
  93. allocation = spec["paths"]["/api/system/metering/allocations"]["post"]
  94. budget = spec["paths"]["/api/system/metering/budgets"]["post"]
  95. replay = spec["paths"]["/api/system/metering/allocation-replay"]["get"]
  96. assert event["x-required-permission"] == "metering:manage"
  97. assert showback["x-required-permission"] == "metering:read"
  98. assert event["requestBody"]["content"]["application/json"]["schema"]["additionalProperties"] is False
  99. assert event["responses"]["201"]["headers"]["Cache-Control"]["schema"]["const"] == "no-store"
  100. assert event["requestBody"]["required"] is True
  101. assert event["requestBody"]["content"]["application/json"]["schema"]["properties"]["mapping"]["additionalProperties"] is False
  102. assert event["requestBody"]["content"]["application/json"]["schema"]["properties"]["evidence"]["required"] == ["digest", "reference"]
  103. assert allocation["x-required-permission"] == "metering:manage"
  104. assert allocation["requestBody"]["content"]["application/json"]["schema"]["additionalProperties"] is False
  105. assert allocation["requestBody"]["required"] is True
  106. assert allocation["requestBody"]["content"]["application/json"]["schema"]["properties"]["allocations"]["items"]["additionalProperties"] is False
  107. assert budget["responses"]["201"]["headers"]["Cache-Control"]["schema"]["const"] == "no-store"
  108. assert replay["x-required-permission"] == "metering:read"
  109. assert {item["name"] for item in showback["parameters"]} == {"window"}
  110. assert all(item["required"] is True for item in replay["parameters"])