File size: 13,911 Bytes
d0c8f19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4fbb05
 
 
d0c8f19
 
f8e7d59
 
 
 
 
 
 
d0c8f19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af423c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0c8f19
 
 
 
af423c3
d0c8f19
af423c3
 
d0c8f19
af423c3
d0c8f19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af423c3
d0c8f19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af423c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0c8f19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3623a88
d0c8f19
 
 
 
3623a88
d0c8f19
 
 
 
 
 
 
 
f8e7d59
3623a88
d0c8f19
 
 
 
 
 
 
 
f8e7d59
3623a88
d0c8f19
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""
Tests for POST /intents/{id}/execute and POST /admin/executions/{id}/resolve.

arf_enterprise is not installed in this test environment (by design -- it's
an optional, proprietary package; see routes_governance.py's
ENTERPRISE_EXECUTOR_AVAILABLE guard). The 501 "not available"/"not enabled"
paths are real, unconditional behavior and tested as such. For the
success/pending/error paths, the enterprise classes referenced in
routes_governance.py (EnterpriseExecutor, FakeCloudActuator,
PendingApprovalError, EnterpriseExecutionError, EnterpriseSafetyError) are
monkeypatched with lightweight stand-ins -- this tests arf-api's own glue
code (existence/tenant check, exception-to-HTTP-status mapping, response
shaping), not EnterpriseExecutor's internals, which are already covered by
the enterprise repo's own test suite.
"""
import pytest
from app.database.models_intents import TenantDB
import app.api.routes_governance as routes_governance
import app.api.routes_admin as routes_admin

TENANT_ID = "test-tenant"


@pytest.fixture(autouse=True)
def seed_tenant(db_session):
    tenant = db_session.query(TenantDB).filter_by(id=TENANT_ID).first()
    if not tenant:
        db_session.add(TenantDB(id=TENANT_ID, name="Test Tenant"))
        db_session.commit()


def _evaluate_intent(client):
    payload = {
        "intent_type": "provision_resource",
        "environment": "prod",
        "resource_type": "database",
        "region": "eastus",
        "size": "Standard",
        "estimated_cost": 1200,
        "policy_violations": [],
        "requester": "alice",
        "provenance": {},
        "configuration": {},
    }
    resp = client.post(
        "/api/v1/intents/evaluate", json=payload, headers={"X-Tenant-ID": TENANT_ID}
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    # data["intent_id"] (top level, set by evaluate_intent_endpoint right
    # before returning: result["intent_id"] = deterministic_id) is what
    # save_evaluated_intent actually persisted to IntentDB.deterministic_id.
    # data["healing_intent"]["intent_id"] is a separate, independently
    # generated id belonging to the HealingIntent object itself -- using it
    # here instead was the exact bug that made every test in this file 404.
    return data["intent_id"], data["healing_intent"]


class _FakePendingApprovalError(Exception):
    def __init__(self, message, level, approval_required, approval_id=None):
        super().__init__(message)
        self.level = level
        self.approval_required = approval_required
        self.approval_id = approval_id


class _FakeExecutionError(Exception):
    pass


class _FakeSafetyError(Exception):
    pass


class _FakeConfig:
    """Stands in for EnterpriseConfig, which is None in the import fallback
    whenever arf_enterprise isn't installed -- as it isn't in CI, since it's
    a private-repo package deliberately kept out of requirements.txt.

    Records what it was constructed with so a test can assert that
    ARF_TRUSTED_SIGNING_KEYS actually reaches the executor. Without that
    the trust store could silently go back to empty and every signed intent
    would be rejected as "Untrusted signing key" with nothing failing here.
    """
    last_trusted_signing_keys = None

    def __init__(self, trusted_signing_keys=None, **kwargs):
        self.trusted_signing_keys = trusted_signing_keys
        type(self).last_trusted_signing_keys = trusted_signing_keys


class _FakeExecutor:
    """Stands in for EnterpriseExecutor. Behavior is selected via a
    class-level `mode` set by each test before the request is made."""
    mode = "success"
    last_config = None

    def __init__(self, config=None, actuator=None, approval_store=None,
                 on_verified_outcome=None):
        self._on_verified_outcome = on_verified_outcome
        type(self).last_config = config

    async def execute(self, intent, human_approved=False, admin_approved=False):
        if self.mode == "success":
            if self._on_verified_outcome:
                self._on_verified_outcome(intent, True, {"observed": {"status": "running"}})
            return {"status": "success", "verified": {"status": "running"}, "compensating_action": None}
        if self.mode == "pending":
            raise _FakePendingApprovalError(
                "needs human approval", level="HumanInLoop", approval_required="human",
                approval_id="appr_test123",
            )
        if self.mode == "execution_error":
            raise _FakeExecutionError("ladder denied this intent")
        if self.mode == "safety_error":
            raise _FakeSafetyError("blast radius exceeded")
        raise RuntimeError(f"unhandled test mode: {self.mode}")


@pytest.fixture
def enterprise_execution_enabled(monkeypatch):
    monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
    monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", True)
    monkeypatch.setattr(routes_governance, "EnterpriseExecutor", _FakeExecutor)
    monkeypatch.setattr(routes_governance, "EnterpriseConfig", _FakeConfig)
    monkeypatch.setattr(routes_governance, "FakeCloudActuator", lambda: None)
    monkeypatch.setattr(routes_governance, "PendingApprovalError", _FakePendingApprovalError)
    monkeypatch.setattr(routes_governance, "EnterpriseExecutionError", _FakeExecutionError)
    monkeypatch.setattr(routes_governance, "EnterpriseSafetyError", _FakeSafetyError)
    _FakeExecutor.mode = "success"
    yield
    _FakeExecutor.mode = "success"


def test_execute_returns_501_when_enterprise_package_not_available(client):
    resp = client.post(
        "/api/v1/intents/does-not-matter/execute",
        json={"healing_intent": {}},
    )
    assert resp.status_code == 501
    assert "not installed" in resp.json()["detail"]


def test_execute_returns_501_when_not_enabled(client, monkeypatch):
    monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
    monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", False)
    resp = client.post(
        "/api/v1/intents/does-not-matter/execute",
        json={"healing_intent": {}},
    )
    assert resp.status_code == 501
    assert "not enabled" in resp.json()["detail"]


def test_execute_returns_404_for_unknown_intent(client, enterprise_execution_enabled):
    resp = client.post(
        "/api/v1/intents/does-not-exist-at-all/execute",
        json={"healing_intent": {}},
    )
    assert resp.status_code == 404


def test_execute_success_path(client, enterprise_execution_enabled):
    deterministic_id, healing_intent = _evaluate_intent(client)
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent, "human_approved": True},
    )
    assert resp.status_code == 200, resp.text
    assert resp.json()["status"] == "success"


def test_trusted_signing_keys_reach_the_executor_split_not_raw(
    client, enterprise_execution_enabled, monkeypatch
):
    """ARF_TRUSTED_SIGNING_KEYS holds N comma-separated fingerprints.
    Passing the raw string through as a one-element list would register the
    literal "abc,def" as a single key -- trusting neither real one -- and
    would look identical from the outside, since both spellings produce a
    non-empty list and a 200 here."""
    monkeypatch.setenv("ARF_TRUSTED_SIGNING_KEYS", " abc123 , def456 ,, ")
    deterministic_id, healing_intent = _evaluate_intent(client)
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent, "human_approved": True},
    )
    assert resp.status_code == 200, resp.text
    assert _FakeConfig.last_trusted_signing_keys == ["abc123", "def456"]
    assert _FakeExecutor.last_config is not None


def test_unset_trusted_signing_keys_trusts_nothing_rather_than_the_empty_string(
    client, enterprise_execution_enabled, monkeypatch
):
    """Fail closed. `"".split(",")` is `[""]`, so the naive parse would
    register the empty string as a trusted fingerprint -- trusting a key
    nobody holds is harmless, but it makes "trusts nothing" and
    "misconfigured" indistinguishable in the logs."""
    monkeypatch.delenv("ARF_TRUSTED_SIGNING_KEYS", raising=False)
    deterministic_id, healing_intent = _evaluate_intent(client)
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent, "human_approved": True},
    )
    assert resp.status_code == 200, resp.text
    assert _FakeConfig.last_trusted_signing_keys == []


def test_execute_pending_approval_returns_202_with_approval_id(client, enterprise_execution_enabled):
    deterministic_id, healing_intent = _evaluate_intent(client)
    _FakeExecutor.mode = "pending"
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent},
    )
    assert resp.status_code == 202
    body = resp.json()
    assert body["approval_id"] == "appr_test123"
    assert body["level"] == "HumanInLoop"


def test_execute_execution_error_returns_422(client, enterprise_execution_enabled):
    deterministic_id, healing_intent = _evaluate_intent(client)
    _FakeExecutor.mode = "execution_error"
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent, "human_approved": True},
    )
    assert resp.status_code == 422
    assert "ladder denied" in resp.json()["detail"]


def test_execute_safety_error_returns_422(client, enterprise_execution_enabled):
    deterministic_id, healing_intent = _evaluate_intent(client)
    _FakeExecutor.mode = "safety_error"
    resp = client.post(
        f"/api/v1/intents/{deterministic_id}/execute",
        json={"healing_intent": healing_intent, "human_approved": True},
    )
    assert resp.status_code == 422
    assert "blast radius" in resp.json()["detail"]


def test_execute_belongs_to_different_tenant_returns_404(client, enterprise_execution_enabled, db_session):
    """A deterministic_id that exists but under a different tenant must not
    be executable by this caller -- same tenant-scoping guarantee
    record_outcome already provides for /intents/outcome."""
    other_tenant = "other-tenant"
    if not db_session.query(TenantDB).filter_by(id=other_tenant).first():
        db_session.add(TenantDB(id=other_tenant, name="Other Tenant"))
        db_session.commit()

    from app.database.models_intents import IntentDB
    import datetime
    db_session.add(IntentDB(
        deterministic_id="belongs-to-other-tenant",
        tenant_id=other_tenant,
        intent_type="provision_resource",
        payload={},
        oss_payload={},
        environment="prod",
        evaluated_at=datetime.datetime.utcnow(),
        risk_score="0.1",
    ))
    db_session.commit()

    resp = client.post(
        "/api/v1/intents/belongs-to-other-tenant/execute",
        json={"healing_intent": {}},
    )
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# Admin resolve/list endpoints
# ---------------------------------------------------------------------------

TEST_ADMIN_KEY = "test-admin-key-for-governance-execute-tests"


class _FakeApprovalRecord:
    def __init__(self, id, decision_id, intent_id, level, approval_required, requested_at):
        self.id = id
        self.decision_id = decision_id
        self.intent_id = intent_id
        self.level = level
        self.approval_required = approval_required
        self.requested_at = requested_at


class _FakeApprovalStore:
    def __init__(self):
        import datetime
        self._pending = {
            "appr_1": _FakeApprovalRecord(
                "appr_1", "dec_1", "intent-1", "HumanInLoop", "human", datetime.datetime.utcnow()
            )
        }
        self.resolved = []

    def list_pending(self, limit=100, offset=0):
        return list(self._pending.values())[:limit]

    def resolve(self, approval_id, approved, resolved_by, note=None):
        if approval_id not in self._pending:
            return False
        del self._pending[approval_id]
        self.resolved.append((approval_id, approved, resolved_by, note))
        return True


@pytest.fixture
def admin_with_approval_store(monkeypatch, client):
    monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
    fake_store = _FakeApprovalStore()
    client.app.state.approval_store = fake_store
    yield fake_store
    client.app.state.approval_store = None


def test_list_pending_executions_returns_501_without_store(client, monkeypatch):
    monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
    client.app.state.approval_store = None
    resp = client.get("/api/v1/admin/executions/pending", headers={"X-Admin-Key": TEST_ADMIN_KEY})
    assert resp.status_code == 501


def test_list_pending_executions(client, admin_with_approval_store):
    resp = client.get("/api/v1/admin/executions/pending", headers={"X-Admin-Key": TEST_ADMIN_KEY})
    assert resp.status_code == 200
    body = resp.json()
    assert body["total"] == 1
    assert body["pending"][0]["approval_id"] == "appr_1"


def test_resolve_execution_approval(client, admin_with_approval_store):
    resp = client.post(
        "/api/v1/admin/executions/appr_1/resolve",
        headers={"X-Admin-Key": TEST_ADMIN_KEY},
        json={"approved": True, "note": "looks fine"},
    )
    assert resp.status_code == 200
    assert admin_with_approval_store.resolved == [("appr_1", True, "admin", "looks fine")]


def test_resolve_unknown_approval_returns_404(client, admin_with_approval_store):
    resp = client.post(
        "/api/v1/admin/executions/does-not-exist/resolve",
        headers={"X-Admin-Key": TEST_ADMIN_KEY},
        json={"approved": True},
    )
    assert resp.status_code == 404