"""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() == []