- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""AI Workforce: employees, runs, tool permissions and approvals.
|
|
|
|
The invariants these tests defend:
|
|
|
|
* a customer never sees a model, provider or routing *mode* — only a named policy
|
|
* tool permission is deny-by-default, so a tool the platform has never heard of
|
|
is not silently allowed
|
|
* every run leaves a timeline, because that timeline is the audit trail
|
|
* approving is a human decision; nothing dangerous executes without it
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest_asyncio
|
|
from httpx import AsyncClient
|
|
|
|
from tests.conftest import auth_header, signup
|
|
from tests.legacy_tasks import legacy_task
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _platform_seed(seeded):
|
|
"""Every test here needs the platform-level seed rows.
|
|
|
|
Agent templates and routing policies are installed by ``app/seed.py`` and are
|
|
not owned by any organization, so a test that creates an employee would
|
|
otherwise find no template to start from.
|
|
"""
|
|
return seeded
|
|
|
|
|
|
async def _org(client: AsyncClient, token: str, name: str = "شرکت نیروی کار") -> str:
|
|
response = await client.post(
|
|
"/api/organizations", json={"name": name}, headers=auth_header(token)
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()["id"]
|
|
|
|
|
|
async def _employee(
|
|
client: AsyncClient, token: str, org_id: str, *, name: str = "برنامهنویس یک"
|
|
) -> dict:
|
|
"""Create an employee from the first available template."""
|
|
templates = await client.get(
|
|
f"/api/organizations/{org_id}/agent-templates", headers=auth_header(token)
|
|
)
|
|
assert templates.status_code == 200, templates.text
|
|
template = templates.json()[0]
|
|
|
|
response = await client.post(
|
|
f"/api/organizations/{org_id}/employees",
|
|
json={"name": name, "role_key": template["role_key"], "template_id": template["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Employees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_creating_an_employee_seeds_a_prompt_and_tool_permissions(client: AsyncClient):
|
|
"""A template must produce a usable employee, not an empty shell."""
|
|
token = (await signup(client, "wf-seed@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
detail = await client.get(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
|
)
|
|
assert detail.status_code == 200, detail.text
|
|
body = detail.json()
|
|
|
|
# Prompt version 1 exists and carries the template's default prompt.
|
|
assert len(body["prompt_versions"]) >= 1
|
|
assert body["prompt_versions"][0]["version"] == 1
|
|
assert body["prompt_versions"][0]["content"].strip()
|
|
|
|
# Tool permissions were seeded from the template.
|
|
assert isinstance(body["tool_permissions"], list)
|
|
assert all("tool_key" in p and "allowed" in p for p in body["tool_permissions"])
|
|
|
|
|
|
async def test_employee_payload_never_leaks_model_identity(client: AsyncClient):
|
|
"""The customer buys capacity, not a model. Nothing may name one."""
|
|
token = (await signup(client, "wf-secret@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
detail = await client.get(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
|
)
|
|
payload = json.dumps(detail.json()).lower()
|
|
for forbidden in ("provider", "api_key", "internal_name", "openai", "anthropic"):
|
|
assert forbidden not in payload, f"{forbidden} leaked in employee payload"
|
|
|
|
|
|
async def test_routing_choices_expose_a_name_but_not_the_mode(client: AsyncClient):
|
|
"""`mode` is an internal routing hint and must not be serialised."""
|
|
token = (await signup(client, "wf-routing@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
|
|
response = await client.get(
|
|
f"/api/organizations/{org_id}/routing-choices", headers=auth_header(token)
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
choices = response.json()
|
|
assert choices, "seed data should provide routing policies"
|
|
|
|
for choice in choices:
|
|
assert set(choice) <= {"id", "name", "description"}, choice
|
|
assert "mode" not in choice
|
|
|
|
|
|
async def test_prompt_version_is_appended_with_an_incrementing_number(client: AsyncClient):
|
|
token = (await signup(client, "wf-prompt@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
for content in ("نسخهٔ دوم پرامپت", "نسخهٔ سوم پرامپت"):
|
|
response = await client.post(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}/prompt-versions",
|
|
json={"content": content},
|
|
headers=auth_header(token),
|
|
)
|
|
assert response.status_code in (200, 201), response.text
|
|
|
|
detail = await client.get(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
|
)
|
|
versions = sorted(v["version"] for v in detail.json()["prompt_versions"])
|
|
assert versions == [1, 2, 3]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool permissions — deny by default
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_unknown_tool_is_denied_by_default(client: AsyncClient):
|
|
"""The platform must fail closed for a tool it has never heard of.
|
|
|
|
A permission row for a tool that does not exist would be a silent no-op that
|
|
hides a typo, so the request is rejected outright (422) rather than stored.
|
|
"""
|
|
token = (await signup(client, "wf-tools@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
# Explicitly grant a tool that is not in the catalog.
|
|
response = await client.put(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}/tools/evil.exfiltrate",
|
|
json={"allowed": False},
|
|
headers=auth_header(token),
|
|
)
|
|
assert response.status_code == 422, response.text
|
|
assert response.json()["error"]["code"] == "validation_error"
|
|
|
|
# And nothing was persisted: the employee's permission set stays clean.
|
|
detail = await client.get(
|
|
f"/api/organizations/{org_id}/employees/{employee['id']}",
|
|
headers=auth_header(token),
|
|
)
|
|
assert detail.status_code == 200, detail.text
|
|
granted = {p["tool_key"] for p in detail.json()["tool_permissions"] if p["allowed"]}
|
|
assert "evil.exfiltrate" not in granted, "an unknown tool must never be granted"
|
|
|
|
|
|
async def test_tool_permission_can_be_toggled(client: AsyncClient):
|
|
token = (await signup(client, "wf-toggle@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
base = f"/api/organizations/{org_id}/employees/{employee['id']}/tools"
|
|
|
|
granted = await client.put(
|
|
f"{base}/knowledge.search", json={"allowed": True}, headers=auth_header(token)
|
|
)
|
|
assert granted.status_code == 200, granted.text
|
|
assert granted.json()["allowed"] is True
|
|
|
|
revoked = await client.put(
|
|
f"{base}/knowledge.search", json={"allowed": False}, headers=auth_header(token)
|
|
)
|
|
assert revoked.status_code == 200, revoked.text
|
|
assert revoked.json()["allowed"] is False
|
|
|
|
|
|
async def test_tools_catalog_requires_no_secrets(client: AsyncClient):
|
|
"""The catalog describes capabilities; it must not carry endpoints or keys."""
|
|
token = (await signup(client, "wf-catalog@example.com"))["access_token"]
|
|
org_id = await _org(client, token)
|
|
|
|
response = await client.get(
|
|
f"/api/organizations/{org_id}/tools", headers=auth_header(token)
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
payload = json.dumps(response.json()).lower()
|
|
for forbidden in ("api_key", "secret", "token", "password"):
|
|
assert forbidden not in payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tasks and runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_starting_a_run_produces_a_timeline(client: AsyncClient, session_factory):
|
|
"""Every run must be reconstructable from its steps.
|
|
|
|
The task is a pre-v2 row with no pipeline state, so it runs through the legacy path.
|
|
A task made over HTTP now enters the engine and is refused here (decision D-D).
|
|
"""
|
|
signed = await signup(client, "wf-run@example.com")
|
|
token = signed["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
task_id = await legacy_task(
|
|
session_factory,
|
|
org_id,
|
|
signed["user"]["id"],
|
|
title="افزودن تست برای ماژول پرداخت",
|
|
assigned_employee_id=employee["id"],
|
|
)
|
|
|
|
run = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
assert run.status_code in (200, 201), run.text
|
|
run_body = run.json()
|
|
|
|
assert run_body["task_id"] == task_id
|
|
assert run_body["status"] in {"queued", "planning", "running", "completed"}
|
|
assert len(run_body["steps"]) >= 1, "a run must record at least one step"
|
|
assert [s["index"] for s in run_body["steps"]] == sorted(
|
|
s["index"] for s in run_body["steps"]
|
|
)
|
|
|
|
|
|
async def test_run_payload_never_leaks_model_identity(client: AsyncClient, session_factory):
|
|
"""The legacy run's payload names no model (a pre-v2 task, decision D-D)."""
|
|
signed = await signup(client, "wf-runsecret@example.com")
|
|
token = signed["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
task_id = await legacy_task(
|
|
session_factory,
|
|
org_id,
|
|
signed["user"]["id"],
|
|
title="کار برای بررسی نشت",
|
|
assigned_employee_id=employee["id"],
|
|
)
|
|
run = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
|
|
payload = json.dumps(run.json()).lower()
|
|
for forbidden in ("provider", "api_key", "internal_name", "model"):
|
|
assert forbidden not in payload, f"{forbidden} leaked in run payload"
|
|
|
|
|
|
async def test_task_detail_lists_its_runs(client: AsyncClient, session_factory):
|
|
"""A pre-v2 task (no pipeline state, decision D-D) keeps its runs on its detail."""
|
|
signed = await signup(client, "wf-taskdetail@example.com")
|
|
token = signed["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
task_id = await legacy_task(
|
|
session_factory,
|
|
org_id,
|
|
signed["user"]["id"],
|
|
title="کار با چند اجرا",
|
|
assigned_employee_id=employee["id"],
|
|
)
|
|
|
|
run = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
assert run.status_code == 201, run.text
|
|
|
|
detail = await client.get(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}", headers=auth_header(token)
|
|
)
|
|
assert detail.status_code == 200, detail.text
|
|
runs = detail.json()["runs"]
|
|
assert len(runs) == 1
|
|
assert runs[0]["id"] == run.json()["id"]
|
|
assert runs[0]["task_id"] == task_id
|
|
|
|
|
|
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient, session_factory):
|
|
"""A successful run closes its task; re-running it must be refused, not
|
|
silently create a second run against a task that is already done.
|
|
|
|
The task is a pre-v2 row (no pipeline state, decision D-D), so this is the legacy path.
|
|
"""
|
|
signed = await signup(client, "wf-closedtask@example.com")
|
|
token = signed["access_token"]
|
|
org_id = await _org(client, token)
|
|
employee = await _employee(client, token, org_id)
|
|
|
|
task_id = await legacy_task(
|
|
session_factory,
|
|
org_id,
|
|
signed["user"]["id"],
|
|
title="کار بستهشده",
|
|
assigned_employee_id=employee["id"],
|
|
)
|
|
|
|
first = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
assert first.status_code == 201, first.text
|
|
assert first.json()["status"] == "completed", first.text
|
|
|
|
second = await client.post(
|
|
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
|
json={"employee_id": employee["id"]},
|
|
headers=auth_header(token),
|
|
)
|
|
assert second.status_code == 409, second.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tenant isolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_employee_of_another_tenant_is_not_readable(client: AsyncClient):
|
|
token_a = (await signup(client, "wf-iso-a@example.com"))["access_token"]
|
|
token_b = (await signup(client, "wf-iso-b@example.com"))["access_token"]
|
|
org_a = await _org(client, token_a, "سازمان الف")
|
|
employee = await _employee(client, token_a, org_a)
|
|
|
|
# B is authenticated and knows the employee id, but has no membership in A.
|
|
response = await client.get(
|
|
f"/api/organizations/{org_a}/employees/{employee['id']}", headers=auth_header(token_b)
|
|
)
|
|
# 404, not 403: the API must not confirm that the employee exists.
|
|
assert response.status_code == 404, response.text
|
|
|
|
|
|
async def test_task_of_another_tenant_is_not_readable(client: AsyncClient):
|
|
token_a = (await signup(client, "wf-tiso-a@example.com"))["access_token"]
|
|
token_b = (await signup(client, "wf-tiso-b@example.com"))["access_token"]
|
|
org_a = await _org(client, token_a, "سازمان ب")
|
|
|
|
task = await client.post(
|
|
f"/api/organizations/{org_a}/tasks",
|
|
json={"title": "کار محرمانهٔ الف"},
|
|
headers=auth_header(token_a),
|
|
)
|
|
task_id = task.json()["id"]
|
|
|
|
response = await client.get(
|
|
f"/api/organizations/{org_a}/tasks/{task_id}", headers=auth_header(token_b)
|
|
)
|
|
assert response.status_code == 404, response.text
|
|
|
|
|
|
async def test_pending_approvals_are_org_scoped(client: AsyncClient):
|
|
"""A tenant must never see another tenant's approval queue."""
|
|
token_a = (await signup(client, "wf-appr-a@example.com"))["access_token"]
|
|
token_b = (await signup(client, "wf-appr-b@example.com"))["access_token"]
|
|
org_a = await _org(client, token_a, "سازمان پ")
|
|
org_b = await _org(client, token_b, "سازمان ت")
|
|
|
|
response = await client.get(
|
|
f"/api/organizations/{org_b}/approvals/pending", headers=auth_header(token_a)
|
|
)
|
|
# Not a member of B at all.
|
|
assert response.status_code == 404, response.text
|
|
|
|
own = await client.get(
|
|
f"/api/organizations/{org_a}/approvals/pending", headers=auth_header(token_a)
|
|
)
|
|
assert own.status_code == 200, own.text
|
|
assert own.json() == []
|