test_agent_governance_api.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. from __future__ import annotations
  2. USER_UID = "01900000-0000-7000-8000-000000009801"
  3. AGENT_UID = "01900000-0000-7000-8000-000000009802"
  4. REQUEST_UID = "01900000-0000-7000-8000-000000009803"
  5. class FakeAgentGovernanceService:
  6. def __init__(self):
  7. self.calls = []
  8. def list_agents(self, **filters):
  9. self.calls.append(("list_agents", filters))
  10. return [{"uid": AGENT_UID, "status": "active", "current_version": 2}]
  11. def register_agent(self, payload, actor_uid):
  12. self.calls.append(("register_agent", payload, actor_uid))
  13. return {"uid": AGENT_UID, "status": "draft", "current_version": 1}
  14. def create_tool_grant(self, uid, payload, actor_uid):
  15. self.calls.append(("create_tool_grant", uid, payload, actor_uid))
  16. return {"uid": "grant-1", "agent_uid": uid, **payload}
  17. def issue_credential(self, uid, payload, actor_uid):
  18. self.calls.append(("issue_credential", uid, payload, actor_uid))
  19. return {"token": "only-once", "expires_at": "2026-08-02T12:05:00+08:00"}
  20. def authorize_action(self, uid, token, payload):
  21. self.calls.append(("authorize_action", uid, token, payload))
  22. return {"uid": REQUEST_UID, "decision": "denied", "current_version": 1}
  23. def list_requests(self, **filters):
  24. return [{"uid": REQUEST_UID, "decision": "denied", **filters}]
  25. def reconcile_action(self, uid, expected_version, actor_uid):
  26. self.calls.append(("reconcile_action", uid, expected_version, actor_uid))
  27. return {"uid": uid, "decision": "authorized", "current_version": 2}
  28. def replay(self, uid):
  29. return {"request": {"uid": uid, "input_digest": "a" * 64}, "events": []}
  30. def dashboard(self):
  31. return {"agent_count": 1, "denied_count": 1}
  32. def _headers(role, **extra):
  33. return {"Authorization": f"Bearer {role}", **extra}
  34. def _client(monkeypatch):
  35. from app import create_app
  36. from app.api.knowledge_base import agent_governance_routes
  37. service = FakeAgentGovernanceService()
  38. monkeypatch.setattr(agent_governance_routes, "_service", lambda: service)
  39. monkeypatch.setattr(
  40. "app.core.system.auth.load_identity_from_token",
  41. lambda token, secret: (
  42. {"id": USER_UID, "username": token, "roles": [token]}
  43. if token in {"viewer", "editor", "admin"}
  44. else None
  45. ),
  46. )
  47. app = create_app()
  48. app.config.update(TESTING=True)
  49. return app.test_client(), service
  50. def test_viewer_reads_agent_inventory_dashboard_and_replay(monkeypatch):
  51. client, _service = _client(monkeypatch)
  52. listing = client.get(
  53. "/api/knowledge/agents?status=active", headers=_headers("viewer")
  54. )
  55. assert listing.status_code == 200
  56. assert listing.get_json()["data"][0]["uid"] == AGENT_UID
  57. dashboard = client.get("/api/knowledge/agents/dashboard", headers=_headers("viewer"))
  58. assert dashboard.get_json()["data"]["denied_count"] == 1
  59. replay = client.get(
  60. f"/api/knowledge/agents/actions/{REQUEST_UID}/replay",
  61. headers=_headers("viewer"),
  62. )
  63. assert replay.status_code == 200
  64. assert replay.headers["Cache-Control"] == "no-store"
  65. def test_editor_operates_grants_and_one_time_credentials_but_cannot_register(monkeypatch):
  66. client, service = _client(monkeypatch)
  67. forbidden = client.post(
  68. "/api/knowledge/agents", json={"code": "NOPE"}, headers=_headers("editor")
  69. )
  70. assert forbidden.status_code == 403
  71. grant = client.post(
  72. f"/api/knowledge/agents/{AGENT_UID}/grants",
  73. json={"tool_name": "knowledge.search"}, headers=_headers("editor"),
  74. )
  75. assert grant.status_code == 201
  76. credential = client.post(
  77. f"/api/knowledge/agents/{AGENT_UID}/credentials",
  78. json={"ttl_seconds": 300}, headers=_headers("editor"),
  79. )
  80. assert credential.status_code == 201
  81. assert credential.headers["Cache-Control"] == "no-store"
  82. assert credential.get_json()["data"]["token"] == "only-once"
  83. assert service.calls[-1][0] == "issue_credential"
  84. def test_admin_registers_and_reconciles_while_machine_credential_is_explicit(monkeypatch):
  85. client, service = _client(monkeypatch)
  86. created = client.post(
  87. "/api/knowledge/agents", json={"code": "READ_GOV"}, headers=_headers("admin")
  88. )
  89. assert created.status_code == 201
  90. assert created.headers["ETag"] == '"1"'
  91. decision = client.post(
  92. f"/api/knowledge/agents/{AGENT_UID}/actions/authorize",
  93. json={"prompt": "read"},
  94. headers=_headers("editor", **{"X-Agent-Credential": "machine-token"}),
  95. )
  96. assert decision.status_code == 201
  97. assert decision.get_json()["data"]["decision"] == "denied"
  98. assert service.calls[-1][2] == "machine-token"
  99. missing = client.post(
  100. f"/api/knowledge/agents/actions/{REQUEST_UID}/reconcile",
  101. headers=_headers("admin"),
  102. )
  103. assert missing.status_code == 428
  104. reconciled = client.post(
  105. f"/api/knowledge/agents/actions/{REQUEST_UID}/reconcile",
  106. headers=_headers("admin", **{"If-Match": '"1"'}),
  107. )
  108. assert reconciled.status_code == 200
  109. assert reconciled.headers["ETag"] == '"2"'
  110. def test_production_requires_a_dedicated_machine_credential_secret():
  111. import pytest
  112. from app import create_app
  113. from app.api.knowledge_base.agent_governance_routes import (
  114. AgentGovernanceUnavailable,
  115. _effective_credential_secret,
  116. _require_credential_secret,
  117. )
  118. app = create_app()
  119. app.config.update(TESTING=False, FLASK_ENV="production", AGENT_CREDENTIAL_SECRET="")
  120. with app.app_context():
  121. fallback, dedicated = _effective_credential_secret()
  122. assert len(fallback) == 64 and dedicated is False
  123. with pytest.raises(AgentGovernanceUnavailable):
  124. _require_credential_secret()
  125. app.config["AGENT_CREDENTIAL_SECRET"] = "production-agent-secret-with-at-least-32-bytes"
  126. _secret, dedicated = _effective_credential_secret()
  127. assert dedicated is True
  128. _require_credential_secret()