- Authoritative candidate capture with server-issued artifacts and raw-byte
immutability hashing (source_response_hash server-owned)
- Closed-period lifecycle: request-hash bound approvals, persisted closure
immutability violations, byte-for-byte catalog stability on reclosure
- Verification runs: persisted VerificationRun model + FK migration,
publish gate (block_publish), scheduled observability runs (02:00 UTC)
- FR-013 baseline inheritance: prior_release_id migration, plan_inheritance/
execute_inheritance classification and re-extraction, API endpoints
- Visual executor bound to release-deployment environment; caller mismatch
rejected; visual SSIM/reconciliation modules
- Query execution decomposed: envelope/model/executor split, no direct SQL
- AgentRun approvals extracted to submodule; evidence adapter; _utils
- Dashboard testing service decomposed into 30+ modules (all <400 LOC)
- Five Feature-037 agent tools with permission guards (tools_037.py)
- API readiness endpoint; Alembic env/migrations; test fixture repos
- Specs 036/037 contracts, openapi.yaml, schema.json, tasks/traceability
updated; semantic index rebuilt with 0 parse warnings
- Fix ADR-0003 parser ambiguity: remove [DEF🆔ADR] prose example
- Add axiom-mcp-agent-feedback.md: agent findings for MCP rework plan
- Tests: 298 service + 1464 API + 45 agent passing; ruff clean
247 lines
12 KiB
Python
247 lines
12 KiB
Python
# #region Test.Api.DashboardTesting.ApprovalGuards [C:3] [TYPE Module] [SEMANTICS testing,api,dashboard-testing,approval,feature-037]
|
|
# @defgroup Feature 037 approval lifecycle guard tests for dashboard-testing endpoints.
|
|
# @LAYER Test
|
|
# @RELATION VERIFIES -> [Api.DashboardTesting]
|
|
# @RELATION BINDS_TO -> [BaselineEngine.Candidates.Create]
|
|
# @TEST_EDGE: agent_run_mismatch -> 422 on wrong agent_run_id.
|
|
# @TEST_EDGE: release_payload_mutation -> 409 on mismatched release_version at consume.
|
|
# @TEST_EDGE: permission_revocation_before_consume -> 403 on APPROVE revocation.
|
|
# @TEST_EDGE: reason_required -> 409 on missing reason when reason_required=True.
|
|
# @TEST_EDGE: openapi_status_201 -> POST approval-gate returns 201.
|
|
from __future__ import annotations
|
|
|
|
from src.core.database import SessionLocal
|
|
from src.dependencies import get_current_user
|
|
from src.models.auth import Role, User
|
|
|
|
from .conftest import (
|
|
create_dashboard_testing_agent_run as _create_agent_run,
|
|
create_dashboard_testing_capture_artifact as _create_capture_artifact,
|
|
make_dashboard_testing_admin_user as _make_admin_user,
|
|
make_dashboard_testing_candidate_payload as _make_candidate_payload,
|
|
)
|
|
|
|
|
|
# #region Test.Api.DashboardTesting.Feature037ApprovalGuards [C:3] [TYPE Class] [SEMANTICS test,api,approval,guard,feature-037]
|
|
# @BRIEF Feature 037 approval lifecycle guards: agent_run_id validation, release payload mutation,
|
|
# RBAC revocation defense, required reason, and OpenAPI contract compliance.
|
|
class TestFeature037ApprovalGuards:
|
|
"""Feature 037 approval lifecycle guard tests — no SUT mocks, durable hardcoded assertions."""
|
|
|
|
# #region Test.Api.Feature037.TestAgentRunIdMismatch [C:2] [TYPE Function] [SEMANTICS test,api,approval,agent-run-mismatch]
|
|
# @BRIEF POST /approval-gate with agent_run_id that doesn't match candidate's run returns 422.
|
|
# @TEST_EDGE agent_run_mismatch -> 422 on wrong agent_run_id.
|
|
def test_agent_run_id_mismatch_rejected(self, dashboard_testing_client):
|
|
"""agent_run_id in body must match candidate's DraftArtifact.run_id."""
|
|
setup_session = SessionLocal()
|
|
try:
|
|
run = _create_agent_run(setup_session)
|
|
run_id = run.id
|
|
artifact_id, sha256 = _create_capture_artifact(setup_session, run_id)
|
|
setup_session.commit()
|
|
finally:
|
|
setup_session.close()
|
|
|
|
payload = _make_candidate_payload(run_id, capture_artifact_ref=artifact_id, source_response_hash=sha256)
|
|
resp = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
|
assert resp.status_code == 201
|
|
candidate_id = resp.json()["candidate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate",
|
|
json={
|
|
"agent_run_id": "wrong-run-id",
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
},
|
|
)
|
|
assert resp.status_code == 422, f"Expected 422, got {resp.status_code}: {resp.text}"
|
|
detail = resp.json().get("detail", "")
|
|
assert "agent_run_id" in detail.lower()
|
|
# #endregion Test.Api.Feature037.TestAgentRunIdMismatch
|
|
|
|
# #region Test.Api.Feature037.TestReleasePayloadMutationRejected [C:2] [TYPE Function] [SEMANTICS test,api,approval,release-mutation]
|
|
# @BRIEF Consuming with release_version different from bound value returns 409.
|
|
# @TEST_EDGE release_payload_mutation -> 409 on mismatched release_version at consume.
|
|
def test_release_payload_mutation_rejected(self, dashboard_testing_client):
|
|
"""Consume with mutated release_version is rejected via hash revalidation."""
|
|
setup_session = SessionLocal()
|
|
try:
|
|
run = _create_agent_run(setup_session)
|
|
run_id = run.id
|
|
artifact_id, sha256 = _create_capture_artifact(setup_session, run_id)
|
|
setup_session.commit()
|
|
finally:
|
|
setup_session.close()
|
|
|
|
payload = _make_candidate_payload(run_id, capture_artifact_ref=artifact_id, source_response_hash=sha256)
|
|
resp = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
|
assert resp.status_code == 201
|
|
candidate_id = resp.json()["candidate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate",
|
|
json={
|
|
"agent_run_id": run_id,
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
gate_id = resp.json()["gate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
|
json={"decision": "confirm"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/consume"
|
|
f"?release_version=v2.0.0&release_commit_hash=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
)
|
|
assert resp.status_code == 409, f"Expected 409, got {resp.status_code}: {resp.text}"
|
|
detail = resp.json().get("detail", "")
|
|
assert "release_version mismatch" in detail.lower()
|
|
# #endregion Test.Api.Feature037.TestReleasePayloadMutationRejected
|
|
|
|
# #region Test.Api.Feature037.TestPermissionRevocationBeforeConsume [C:2] [TYPE Function] [SEMANTICS test,api,approval,permission-revocation]
|
|
# @BRIEF A user whose APPROVE permission was revoked cannot consume a confirmed gate.
|
|
# @TEST_EDGE permission_revocation_before_consume -> 403 on APPROVE revocation.
|
|
def test_permission_revocation_before_consume(self, dashboard_testing_client):
|
|
"""RBAC revocation at consume time: non-APPROVE user gets 403 on consume."""
|
|
setup_session = SessionLocal()
|
|
try:
|
|
run = _create_agent_run(setup_session)
|
|
run_id = run.id
|
|
artifact_id, sha256 = _create_capture_artifact(setup_session, run_id)
|
|
setup_session.commit()
|
|
finally:
|
|
setup_session.close()
|
|
|
|
payload = _make_candidate_payload(run_id, capture_artifact_ref=artifact_id, source_response_hash=sha256)
|
|
resp = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
|
assert resp.status_code == 201
|
|
candidate_id = resp.json()["candidate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate",
|
|
json={
|
|
"agent_run_id": run_id,
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
gate_id = resp.json()["gate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
|
json={"decision": "confirm"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
viewer_role = Role(id="viewer-role", name="Viewer", is_admin=False)
|
|
viewer_user = User(id="viewer-user", username="viewer", email="viewer@test.com")
|
|
viewer_user.roles = [viewer_role]
|
|
dashboard_testing_client.app.dependency_overrides[get_current_user] = lambda: viewer_user
|
|
|
|
try:
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/consume"
|
|
f"?release_version=v1.0.0&release_commit_hash=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
)
|
|
assert resp.status_code == 403, (
|
|
f"Expected 403 for revoked permission, "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
finally:
|
|
dashboard_testing_client.app.dependency_overrides[get_current_user] = lambda: _make_admin_user()
|
|
# #endregion Test.Api.Feature037.TestPermissionRevocationBeforeConsume
|
|
|
|
# #region Test.Api.Feature037.TestOpenApi201Status [C:2] [TYPE Function] [SEMANTICS test,api,approval,openapi,201]
|
|
# @BRIEF POST /approval-gate returns 201 with required agent_run_id in body.
|
|
# @TEST_EDGE openapi_status_201 -> POST approval-gate returns 201.
|
|
def test_approval_gate_returns_201(self, dashboard_testing_client):
|
|
"""POST approval-gate returns 201 with OpenAPI-compliant body containing agent_run_id."""
|
|
setup_session = SessionLocal()
|
|
try:
|
|
run = _create_agent_run(setup_session)
|
|
run_id = run.id
|
|
artifact_id, sha256 = _create_capture_artifact(setup_session, run_id)
|
|
setup_session.commit()
|
|
finally:
|
|
setup_session.close()
|
|
|
|
payload = _make_candidate_payload(run_id, capture_artifact_ref=artifact_id, source_response_hash=sha256)
|
|
resp = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
|
assert resp.status_code == 201
|
|
candidate_id = resp.json()["candidate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate",
|
|
json={
|
|
"agent_run_id": run_id,
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
},
|
|
)
|
|
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
|
|
data = resp.json()
|
|
assert "gate_id" in data
|
|
assert data["status"] == "pending"
|
|
assert "candidate_id" in data
|
|
assert "operation" in data
|
|
# #endregion Test.Api.Feature037.TestOpenApi201Status
|
|
|
|
# #region Test.Api.Feature037.TestRequiredReasonEnforced [C:2] [TYPE Function] [SEMANTICS test,api,approval,reason-required]
|
|
# @BRIEF Decide on a gate with reason_required=True but no reason returns 409.
|
|
# @TEST_EDGE reason_required -> 409 on missing reason when reason_required=True.
|
|
def test_required_reason_enforced(self, dashboard_testing_client):
|
|
"""Decide on reason_required gate without reason returns 409."""
|
|
setup_session = SessionLocal()
|
|
try:
|
|
run = _create_agent_run(setup_session)
|
|
run_id = run.id
|
|
artifact_id, sha256 = _create_capture_artifact(setup_session, run_id)
|
|
setup_session.commit()
|
|
finally:
|
|
setup_session.close()
|
|
|
|
payload = _make_candidate_payload(run_id, capture_artifact_ref=artifact_id, source_response_hash=sha256)
|
|
resp = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
|
assert resp.status_code == 201
|
|
candidate_id = resp.json()["candidate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate",
|
|
json={
|
|
"agent_run_id": run_id,
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
|
"reason_required": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
gate_id = resp.json()["gate_id"]
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
|
json={"decision": "confirm"},
|
|
)
|
|
assert resp.status_code == 409, f"Expected 409, got {resp.status_code}: {resp.text}"
|
|
detail = resp.json().get("detail", "")
|
|
assert "reason" in detail.lower()
|
|
|
|
resp = dashboard_testing_client.post(
|
|
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
|
json={"decision": "confirm", "reason": "QA approved after review"},
|
|
)
|
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
|
|
assert resp.json()["status"] == "confirmed"
|
|
# #endregion Test.Api.Feature037.TestRequiredReasonEnforced
|
|
# #endregion Test.Api.DashboardTesting.Feature037ApprovalGuards
|
|
|
|
|
|
# #endregion Test.Api.DashboardTesting.ApprovalGuards
|