The execution boundary for v2. Adds the local docker/podman sandbox adapter and the hardened host subprocess runner every other delivery adapter will use. - adapters/process.py: the one place the platform shells out. argv-only (no shell), start_new_session + killpg on timeout, concurrent stdout/stderr drains with a byte cap, explicit minimal env (never os.environ), and a missing binary reported as spawn_failed rather than an exception. - adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort. --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits, --read-only with tmpfs scratch, one bind mount (the work tree, never the git dir), labels, no published ports. Path-safe file access; secrets refused in `docker exec -e`. Extras for later phases: start_demo_container (loopback publish only), list_managed_containers, remove_container. - registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry survives create_workspace -> start_session. - sandbox_gateway worker: reap_orphans() removes labelled containers no live session points at, audited as sandbox.reap; run_pass() does both passes. - sandbox router: every mutating route now requires the platform owner (PLAN-v2 §7.7); read-only status routes stay open to org members. - sandbox-panel: canManageInfra prop (fails closed) hides the create form, the session panel and the action buttons from non-owners. Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean. Three existing tests now drive the sandbox as the platform owner, since that is who may touch it. Report: docs/plan/reports/agent-3-phase-1.md
280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""§18.3 — the minimal end-to-end smoke path.
|
|
|
|
One test walks the whole product in order, through the real HTTP API and the
|
|
real application services, against the agy-shaped catalogue (the seeded fixture
|
|
installs the agy provider and its models) with the fake model provider from
|
|
conftest standing in for the Google round-trip:
|
|
|
|
signup -> organization -> project -> repo -> preview domain -> sandbox
|
|
-> employee -> manager message -> task -> run -> usage -> audit
|
|
|
|
The point is not coverage of edge cases (the focused suites do that) but to
|
|
prove the seams line up: that an object created in one step is actually
|
|
accepted by the next one. A green unit suite with a broken seam between two
|
|
sections is exactly the failure this test exists to catch.
|
|
|
|
It also re-asserts the two product invariants that must hold at every step:
|
|
a customer never sees a raw provider/model, and another tenant's objects are
|
|
invisible (404, never 403).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest_asyncio
|
|
from httpx import AsyncClient
|
|
|
|
from app.domain.identity.models import PlatformRole
|
|
from tests.conftest import auth_header, signup
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _platform_seed(seeded):
|
|
"""The platform rows a fresh install has: templates, policies, models, rates."""
|
|
return seeded
|
|
|
|
|
|
async def test_end_to_end_smoke_path(client: AsyncClient):
|
|
# -- 1. signup -------------------------------------------------------- #
|
|
# The platform owner, not a plain manager: step 6 drives the sandbox
|
|
# directly, and v2 makes container/command access owner-only
|
|
# (PLAN-v2 §7.7). The org-scoped prefix still scopes the tenant and the
|
|
# audit trail, so this is the same path a customer walks minus the
|
|
# infrastructure step.
|
|
signup_body = await signup(
|
|
client, "smoke@example.com", name="کاربر دود", platform_role=PlatformRole.OWNER
|
|
)
|
|
token = signup_body["access_token"]
|
|
headers = auth_header(token)
|
|
|
|
me = await client.get("/api/auth/me", headers=headers)
|
|
assert me.status_code == 200, me.text
|
|
assert me.json()["user"]["email"] == "smoke@example.com"
|
|
|
|
# -- 2. create organization ------------------------------------------- #
|
|
org = await client.post(
|
|
"/api/organizations", json={"name": "سازمان دود"}, headers=headers
|
|
)
|
|
assert org.status_code == 201, org.text
|
|
org_id = org.json()["id"]
|
|
assert org.json()["role"] == "owner"
|
|
|
|
# -- 3. create project ------------------------------------------------ #
|
|
project = await client.post(
|
|
f"/api/organizations/{org_id}/projects",
|
|
json={"name": "پروژهٔ دود", "description": "مسیر دود انتها به انتها"},
|
|
headers=headers,
|
|
)
|
|
assert project.status_code == 201, project.text
|
|
project_id = project.json()["id"]
|
|
assert project.json()["slug"], "a project must get a slug for its preview host"
|
|
|
|
# -- 4. ensure repo --------------------------------------------------- #
|
|
repo = await client.post(
|
|
f"/api/organizations/{org_id}/projects/{project_id}/repo/ensure",
|
|
headers=headers,
|
|
)
|
|
assert repo.status_code in (200, 201), repo.text
|
|
assert repo.json()["private"] is True, "a customer repo must be private"
|
|
|
|
status_body = await client.get(
|
|
f"/api/organizations/{org_id}/projects/{project_id}/repo/status",
|
|
headers=headers,
|
|
)
|
|
assert status_body.status_code == 200, status_body.text
|
|
assert status_body.json()["exists"] is True
|
|
|
|
# -- 5. create preview domain ----------------------------------------- #
|
|
domain = await client.post(
|
|
f"/api/organizations/{org_id}/projects/{project_id}/preview/domains",
|
|
json={"subdomain": "smoke-app"},
|
|
headers=headers,
|
|
)
|
|
assert domain.status_code == 201, domain.text
|
|
domain_body = domain.json()
|
|
assert domain_body["host"] == "smoke-app.msa.artacloud.ir", domain_body
|
|
assert 8080 <= domain_body["port"] <= 8999, "port must come from the managed range"
|
|
assert domain_body["protected"] is False
|
|
|
|
# -- 6. sandbox workspace + one metered command ------------------------ #
|
|
templates = await client.get(
|
|
f"/api/organizations/{org_id}/sandbox/templates", headers=headers
|
|
)
|
|
assert templates.status_code == 200, templates.text
|
|
assert templates.json(), "a seeded install must expose sandbox templates"
|
|
template_id = templates.json()[0]["id"]
|
|
|
|
workspace = await client.post(
|
|
f"/api/organizations/{org_id}/sandbox/workspaces",
|
|
json={"project_id": project_id, "template_id": template_id},
|
|
headers=headers,
|
|
)
|
|
assert workspace.status_code in (200, 201), workspace.text
|
|
assert workspace.json()["status"] in {"ready", "starting", "created"}
|
|
|
|
session = await client.post(
|
|
f"/api/organizations/{org_id}/sandbox/sessions",
|
|
json={"workspace_id": workspace.json()["id"]},
|
|
headers=headers,
|
|
)
|
|
assert session.status_code in (200, 201), session.text
|
|
session_id = session.json()["id"]
|
|
|
|
command = await client.post(
|
|
f"/api/organizations/{org_id}/sandbox/sessions/{session_id}/commands",
|
|
# A caller that tries to raise its own limits must not be able to: the
|
|
# request schema accepts only `command`, so the extra keys are ignored
|
|
# and the platform's policy values apply.
|
|
json={
|
|
"command": "python -m pytest -q",
|
|
"timeout_seconds": 999_999,
|
|
"max_output_bytes": 10**9,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert command.status_code in (200, 201), command.text
|
|
command_body = command.json()
|
|
assert command_body["command"] == "python -m pytest -q"
|
|
for platform_owned in ("timeout_seconds", "max_output_bytes"):
|
|
assert platform_owned not in command_body, (
|
|
f"the caller must not be able to set {platform_owned}"
|
|
)
|
|
|
|
# -- 7. create employee ------------------------------------------------ #
|
|
agent_templates = await client.get(
|
|
f"/api/organizations/{org_id}/agent-templates", headers=headers
|
|
)
|
|
assert agent_templates.status_code == 200, agent_templates.text
|
|
agent_template = agent_templates.json()[0]
|
|
|
|
employee = await client.post(
|
|
f"/api/organizations/{org_id}/employees",
|
|
json={
|
|
"name": "کارمند دود",
|
|
"role_key": agent_template["role_key"],
|
|
"template_id": agent_template["id"],
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert employee.status_code == 201, employee.text
|
|
employee_id = employee.json()["id"]
|
|
|
|
# The employee record is customer-facing: it must not name a model.
|
|
assert "model" not in str(employee.json()).lower()
|
|
|
|
# -- 8. send a manager message ----------------------------------------- #
|
|
thread = await client.post(
|
|
f"/api/organizations/{org_id}/threads",
|
|
json={"title": "گفتوگو با مدیر", "project_id": project_id},
|
|
headers=headers,
|
|
)
|
|
assert thread.status_code == 201, thread.text
|
|
thread_id = thread.json()["id"]
|
|
|
|
message = await client.post(
|
|
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
|
|
json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id},
|
|
headers=headers,
|
|
)
|
|
assert message.status_code in (200, 201), message.text
|
|
message_body = message.json()
|
|
assert message_body["user_message"]["role"] == "user"
|
|
assistant = message_body["assistant_message"]
|
|
assert assistant["content"], "the manager must actually answer"
|
|
# The reply is customer-facing text: it must not name a model or provider.
|
|
assert "provider" not in assistant["content"].lower()
|
|
assert "model" not in assistant["content"].lower()
|
|
|
|
# -- 9. create task ---------------------------------------------------- #
|
|
task = await client.post(
|
|
f"/api/organizations/{org_id}/tasks",
|
|
json={
|
|
"title": "افزودن تست برای ماژول پرداخت",
|
|
"description": "پوشش تست واحد برای محاسبهٔ تخفیف",
|
|
"project_id": project_id,
|
|
"thread_id": thread_id,
|
|
"assigned_employee_id": employee_id,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert task.status_code == 201, task.text
|
|
task_id = task.json()["id"]
|
|
|
|
# -- 10. run task ------------------------------------------------------ #
|
|
run = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee_id},
|
|
headers=headers,
|
|
)
|
|
assert run.status_code in (200, 201), run.text
|
|
run_body = run.json()
|
|
assert run_body["status"] == "completed", run_body
|
|
assert run_body["steps"], "a run must leave a timeline"
|
|
assert run_body["artifacts"], "a completed run must produce an artifact"
|
|
assert [s["index"] for s in run_body["steps"]] == sorted(
|
|
s["index"] for s in run_body["steps"]
|
|
), "timeline steps must be ordered"
|
|
|
|
# The run payload is customer-facing: no raw model identity anywhere.
|
|
lowered = str(run_body).lower()
|
|
for forbidden in ("provider", "api_key", "internal_name", "model"):
|
|
assert forbidden not in lowered, f"{forbidden} leaked into the run payload"
|
|
|
|
# -- 11. see usage summary --------------------------------------------- #
|
|
summary = await client.get(
|
|
f"/api/organizations/{org_id}/billing/summary", headers=headers
|
|
)
|
|
assert summary.status_code == 200, summary.text
|
|
usage = summary.json()
|
|
assert usage["total_spend"] > 0, "a completed run must be metered"
|
|
assert usage["by_unit"], "usage must be attributable to a billing unit"
|
|
assert usage["balance"] is not None
|
|
|
|
events = await client.get(
|
|
f"/api/organizations/{org_id}/billing/usage-events", headers=headers
|
|
)
|
|
assert events.status_code == 200, events.text
|
|
assert any(e["run_id"] == run_body["id"] for e in events.json()), (
|
|
"the run's usage must be traceable back to the run"
|
|
)
|
|
|
|
# -- 12. audit trail --------------------------------------------------- #
|
|
audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers)
|
|
assert audit.status_code == 200, audit.text
|
|
actions = {e["action"] for e in audit.json()}
|
|
for expected in ("organization.create", "project.create", "domain.create"):
|
|
assert expected in actions, f"{expected} was not audited"
|
|
|
|
|
|
async def test_smoke_path_is_invisible_to_another_tenant(client: AsyncClient):
|
|
"""The same path, viewed by an outsider: every object must 404, not 403."""
|
|
owner_token = (await signup(client, "smoke-owner@example.com"))["access_token"]
|
|
owner_headers = auth_header(owner_token)
|
|
org_id = (
|
|
await client.post(
|
|
"/api/organizations", json={"name": "سازمان مالک"}, headers=owner_headers
|
|
)
|
|
).json()["id"]
|
|
project_id = (
|
|
await client.post(
|
|
f"/api/organizations/{org_id}/projects",
|
|
json={"name": "پروژهٔ مالک"},
|
|
headers=owner_headers,
|
|
)
|
|
).json()["id"]
|
|
|
|
outsider_token = (await signup(client, "smoke-outsider@example.com"))["access_token"]
|
|
outsider_headers = auth_header(outsider_token)
|
|
|
|
# The outsider is authenticated and knows both ids.
|
|
probes = [
|
|
f"/api/organizations/{org_id}",
|
|
f"/api/organizations/{org_id}/projects/{project_id}",
|
|
f"/api/organizations/{org_id}/employees",
|
|
f"/api/organizations/{org_id}/billing/summary",
|
|
f"/api/organizations/{org_id}/audit",
|
|
f"/api/organizations/{org_id}/sandbox/templates",
|
|
]
|
|
for path in probes:
|
|
response = await client.get(path, headers=outsider_headers)
|
|
assert response.status_code == 404, f"{path} returned {response.status_code}"
|