نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
"""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
|
||||
|
||||
|
||||
@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):
|
||||
"""Every run must be reconstructable from its steps."""
|
||||
token = (await signup(client, "wf-run@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={
|
||||
"title": "افزودن تست برای ماژول پرداخت",
|
||||
"description": "پوشش تست واحد برای محاسبهٔ تخفیف",
|
||||
"assigned_employee_id": employee["id"],
|
||||
},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert task.status_code == 201, task.text
|
||||
task_id = task.json()["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):
|
||||
token = (await signup(client, "wf-runsecret@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["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):
|
||||
token = (await signup(client, "wf-taskdetail@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["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):
|
||||
"""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."""
|
||||
token = (await signup(client, "wf-closedtask@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار بستهشده", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["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() == []
|
||||
Reference in New Issue
Block a user