157 lines
5.9 KiB
Python
157 lines
5.9 KiB
Python
from contextlib import contextmanager
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from aios_autonomous_release_candidate.service import CandidateApplication, CandidateHTTPServer
|
|
|
|
|
|
@contextmanager
|
|
def running_server(tmp_path: Path):
|
|
application = CandidateApplication(tmp_path / "state.sqlite3")
|
|
server = CandidateHTTPServer(("127.0.0.1", 0), application)
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield application, f"http://127.0.0.1:{server.server_port}"
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
def get_json(url: str):
|
|
with urllib.request.urlopen(url, timeout=5) as response:
|
|
return response.status, json.load(response)
|
|
|
|
|
|
def post_json(url: str, payload: dict):
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
return response.status, json.load(response)
|
|
|
|
|
|
def test_health_and_readiness_are_loopback_safe(tmp_path: Path) -> None:
|
|
with running_server(tmp_path) as (_, base):
|
|
status, health = get_json(f"{base}/health")
|
|
assert status == 200
|
|
assert health["production_side_effects"] is False
|
|
status, ready = get_json(f"{base}/ready")
|
|
assert status == 200
|
|
assert ready == {"status": "ready", "secrets_included": False}
|
|
|
|
|
|
def test_json_content_type_is_required(tmp_path: Path) -> None:
|
|
with running_server(tmp_path) as (_, base):
|
|
request = urllib.request.Request(f"{base}/v1/tasks", data=b"{}", method="POST")
|
|
try:
|
|
urllib.request.urlopen(request, timeout=5)
|
|
except urllib.error.HTTPError as error:
|
|
assert error.code == 415
|
|
assert json.load(error)["error"] == "content_type_required"
|
|
|
|
|
|
def test_internal_exception_text_is_not_disclosed(tmp_path: Path) -> None:
|
|
with running_server(tmp_path) as (application, base):
|
|
def fail(_payload):
|
|
raise RuntimeError("sensitive internal diagnostic")
|
|
|
|
application.api.create_task = fail
|
|
request = urllib.request.Request(
|
|
f"{base}/v1/tasks",
|
|
data=b"{}",
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
urllib.request.urlopen(request, timeout=5)
|
|
except urllib.error.HTTPError as error:
|
|
assert error.code == 500
|
|
body = error.read().decode("utf-8")
|
|
assert "candidate_request_failed" in body
|
|
assert "sensitive internal diagnostic" not in body
|
|
|
|
|
|
def test_task_lifecycle_evidence_and_release_gate_endpoints(tmp_path: Path) -> None:
|
|
task_id = "api-lifecycle-task"
|
|
task = {
|
|
"task_id": task_id,
|
|
"intent": "exercise candidate API",
|
|
"binding": {
|
|
"repository": "aios/aios",
|
|
"branch": "patch/api-lifecycle",
|
|
"base_oid": "a" * 40,
|
|
"allowed_paths": ["services/test"],
|
|
},
|
|
"risk": "medium",
|
|
"steps": [
|
|
{
|
|
"step_id": "test-step",
|
|
"worker_role": "tester",
|
|
"required_capabilities": ["test.execute"],
|
|
}
|
|
],
|
|
"evidence_requirements": ["STEP_RESULT"],
|
|
"production_side_effects_allowed": False,
|
|
}
|
|
with running_server(tmp_path) as (_, base):
|
|
status, created = post_json(f"{base}/v1/tasks", task)
|
|
assert status == 201
|
|
assert created["production_side_effects"] is False
|
|
status, steps = get_json(f"{base}/v1/tasks/{task_id}/steps")
|
|
assert status == 200
|
|
assert steps["steps"]["test-step"]["state"] == "READY"
|
|
binding = {
|
|
"step_id": "test-step",
|
|
"worker_id": "worker-1",
|
|
"session_id": "session-1",
|
|
}
|
|
_, claimed = post_json(f"{base}/v1/tasks/{task_id}/claim", binding)
|
|
assert claimed["state"] == "RUNNING"
|
|
_, heartbeat = post_json(f"{base}/v1/tasks/{task_id}/heartbeat", binding)
|
|
assert heartbeat["state"] == "RUNNING"
|
|
_, result = post_json(
|
|
f"{base}/v1/tasks/{task_id}/result",
|
|
{"step_id": "test-step", "result_digest": hashlib.sha256(b"ok").hexdigest()},
|
|
)
|
|
assert result["state"] == "COMPLETED"
|
|
_, evidence = get_json(f"{base}/v1/tasks/{task_id}/evidence")
|
|
assert evidence["verified"] is True
|
|
assert any(item["event_type"] == "STEP_RESULT" for item in evidence["events"])
|
|
_, closure = post_json(f"{base}/v1/tasks/{task_id}/evidence/close", {})
|
|
assert len(closure["evidence_closure"]) == 64
|
|
|
|
release_plan = {
|
|
"task_id": task_id,
|
|
"repository": "aios/aios",
|
|
"branch": "integration/api-lifecycle",
|
|
"version": "SHADOW-0.1.0",
|
|
"target_oid": "b" * 40,
|
|
"tree_oid": "c" * 40,
|
|
"artifact_sha256": "d" * 64,
|
|
"manifest_sha256": "e" * 64,
|
|
"package_closure_sha256": "f" * 64,
|
|
"evidence_closure_oid": "1" * 40,
|
|
"approval_plan_id": "external-owner-release",
|
|
"rollback_approval_plan_id": "external-owner-rollback",
|
|
"rollback_artifact_sha256": "2" * 64,
|
|
"expected_tag_oid": "3" * 40,
|
|
"expected_target_oid": "b" * 40,
|
|
"production_config_sha256": "4" * 64,
|
|
}
|
|
_, release = post_json(f"{base}/v1/release/plan", release_plan)
|
|
assert release["release_state"] == "BUILT"
|
|
_, verified = post_json(
|
|
f"{base}/v1/release/verify", {"artifact_ok": True, "evidence_ok": True}
|
|
)
|
|
assert verified["release_state"] == "WAITING_OWNER"
|
|
assert verified["production_release"] == "BLOCKED_EXTERNAL_OWNER_SIGNATURE"
|