نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
View File
+248
View File
@@ -0,0 +1,248 @@
"""Shared test fixtures.
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
suite needs no Postgres, no Redis and no credentials.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Importing the ORM package registers every table on ``Base.metadata``. Bound to
# a private name on purpose: ``app`` is also the FastAPI instance imported below,
# and binding the package to the same name would shadow it.
import app.infrastructure.db.models as _orm_models # noqa: F401
from app.core.config import settings
from app.core.database import Base, get_session
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def _production_shaped_jwt_secret():
"""Sign test tokens with a 32+ byte key.
The development default is ``change-me`` (9 bytes), so PyJWT emits an
``InsecureKeyLengthWarning`` for every token signed or verified — hundreds of
lines per run, which buries genuine warnings. Production refuses a short
secret outright; that guard is covered by ``tests/test_config_guard.py``.
"""
settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes"
@pytest_asyncio.fixture
async def engine():
# StaticPool keeps one connection alive so the in-memory schema survives
# across the sessions a single request opens.
test_engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield test_engine
await test_engine.dispose()
@pytest_asyncio.fixture
async def session_factory(engine):
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest_asyncio.fixture
async def redirect_error_log(engine, monkeypatch):
"""Point the error recorder and the workers at the in-memory database.
Neither goes through the ``get_session`` dependency override that ``client``
installs — the recorder deliberately opens its own session, and the workers
are not HTTP requests at all. Without this redirect, any test that triggers a
failure path would write into the developer's real ``agy_web.db``.
Not autouse: only the modules that exercise failure paths need it, and the
``engine`` it depends on builds the whole schema.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
import app.infrastructure.error_log as error_log
from app.workers import base as worker_base
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
monkeypatch.setattr(error_log, "SessionFactory", factory)
monkeypatch.setattr(worker_base, "SessionFactory", factory)
return factory
@pytest_asyncio.fixture
async def client(engine, session_factory):
async def _override_session():
async with session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_session] = _override_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
async def signup(
client: AsyncClient, email: str, password: str = "password123", name: str = ""
) -> dict:
response = await client.post(
"/api/auth/signup", json={"email": email, "password": password, "name": name}
)
assert response.status_code == 201, response.text
return response.json()
def auth_header(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture
async def platform_owner_token(client: AsyncClient, session_factory) -> str:
"""A signed-up user promoted to platform owner.
The promotion is done directly in the database because there is deliberately
no API that can grant platform-owner status.
"""
# ``example.com`` is a documentation domain and passes email validation.
# Do NOT use ``.local``/``.test``: those are special-use TLDs and newer
# ``email-validator`` releases reject them, which fails the fixture.
email = "platform-owner@example.com"
body = await signup(client, email, name="مالک سامانه")
async with session_factory() as session:
row = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one()
row.is_platform_owner = True
await session.commit()
return body["access_token"]
#: The system agent templates ``app/seed.py`` installs. Mirrored here so the test
#: suite exercises the same employee-creation path a seeded install uses.
SYSTEM_TEMPLATES: list[tuple[str, str, str, list[str]]] = [
("مدیر سازمان", "manager", "کارها را تقسیم می‌کند.", ["knowledge.search"]),
(
"برنامه‌نویس",
"developer",
"کد می‌نویسد و تست می‌کند.",
["sandbox.run_command", "sandbox.write_file"],
),
]
#: The routing policies ``app/seed.py`` installs.
SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
("سریع و ارزان", "cheap", True, "برای کارهای روزمره."),
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
]
#: The mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
("mock-fast", "Mock Fast", ["text"]),
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "code"]),
]
#: The rate cards ``app/seed.py`` installs: (unit, customer_price, internal_cost).
SYSTEM_RATE_CARDS: list[tuple[str, float, float]] = [
("model_input_tokens", 0.0000004, 0.0000002),
("model_output_tokens", 0.0000016, 0.0000008),
("tool_call", 0.0005, 0.0001),
("premium_employee_run", 0.02, 0.008),
]
#: The sandbox templates ``app/seed.py`` installs: (name, image, description).
SYSTEM_SANDBOX_TEMPLATES: list[tuple[str, str, str]] = [
("python-3.13", "python:3.13-slim", "محیط پایتون برای اجرای تست و اسکریپت."),
("node-22", "node:22-slim", "محیط Node برای build و اجرای پروژه‌های فرانت‌اند."),
]
@pytest_asyncio.fixture
async def seeded(session_factory):
"""Install the platform-level seed rows a fresh install would have.
Agent templates, routing policies, the mock provider/models, the rate cards
and the sandbox templates are all *platform* data (no organization owns
them), so without this an employee cannot be created, a run cannot resolve a
model to route to, usage cannot be priced and no sandbox can be started —
none of which the real seeded install lacks.
"""
from app.infrastructure.db.models import (
AgentTemplateRow,
AiModelRow,
AiProviderRow,
RateCardRow,
RoutingPolicyRow,
SandboxTemplateRow,
)
async with session_factory() as session:
for name, role_key, description, tools in SYSTEM_TEMPLATES:
session.add(
AgentTemplateRow(
name=name,
role_key=role_key,
description=description,
default_prompt=f"تو {name} هستی. وظیفه‌ات: {description}",
default_tools_json=tools,
is_system=True,
)
)
for name, mode, is_default, description in SYSTEM_ROUTING_POLICIES:
session.add(
RoutingPolicyRow(
name=name, description=description, mode=mode,
is_default=is_default, is_active=True,
)
)
# A run cannot reach a model without an active provider + model, and
# ``pick_for_mode`` returns ``None`` when the catalogue is empty — which
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
provider_id = new_id()
session.add(
AiProviderRow(id=provider_id, name="mock", type="mock", base_url="", is_active=True)
)
for internal, display, capabilities in SYSTEM_MODELS:
session.add(
AiModelRow(
provider_id=provider_id,
internal_name=internal,
display_name_for_admin=display,
capabilities=capabilities,
input_price_per_1m=0.3,
output_price_per_1m=1.2,
is_active=True,
)
)
for unit, customer_price, internal_cost in SYSTEM_RATE_CARDS:
session.add(
RateCardRow(
name=unit.replace("_", " "), unit=unit,
customer_price=customer_price,
internal_cost_estimate=internal_cost, is_active=True,
)
)
for name, image, description in SYSTEM_SANDBOX_TEMPLATES:
session.add(
SandboxTemplateRow(
name=name, provider="mock", image=image, description=description,
default_cpu=1.0, default_memory_mb=1024, default_disk_mb=2048,
is_system=True,
)
)
await session.commit()
return True
+227
View File
@@ -0,0 +1,227 @@
"""AI Control Plane: access control and the no-leak guarantee.
Two properties matter more than any feature here:
1. A normal customer can never reach these endpoints.
2. A provider API key can never come back out of the API.
"""
from __future__ import annotations
from httpx import AsyncClient
from tests.conftest import auth_header, signup
SECRET_KEY = "sk-super-secret-value-do-not-leak-1234567890"
async def test_normal_user_cannot_list_providers(client: AsyncClient):
token = (await signup(client, "peon@example.com"))["access_token"]
response = await client.get("/api/admin/providers", headers=auth_header(token))
assert response.status_code == 403
assert response.json()["error"]["code"] == "permission_denied"
async def test_normal_user_cannot_create_provider(client: AsyncClient):
token = (await signup(client, "peon2@example.com"))["access_token"]
response = await client.post(
"/api/admin/providers",
json={"name": "evil", "type": "mock", "base_url": ""},
headers=auth_header(token),
)
assert response.status_code == 403
async def test_admin_endpoints_require_authentication(client: AsyncClient):
response = await client.get("/api/admin/providers")
assert response.status_code == 401
async def test_platform_owner_can_create_and_list_provider(
client: AsyncClient, platform_owner_token: str
):
created = await client.post(
"/api/admin/providers",
json={"name": "openrouter", "type": "openrouter", "base_url": "https://openrouter.ai/api/v1"},
headers=auth_header(platform_owner_token),
)
assert created.status_code == 201, created.text
assert created.json()["name"] == "openrouter"
listing = await client.get("/api/admin/providers", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
assert [p["name"] for p in listing.json()] == ["openrouter"]
async def test_duplicate_provider_name_is_rejected(client: AsyncClient, platform_owner_token: str):
payload = {"name": "dupe", "type": "mock", "base_url": ""}
first = await client.post(
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
)
assert first.status_code == 201
second = await client.post(
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
)
assert second.status_code == 409
async def test_credential_creation_returns_only_a_masked_key(
client: AsyncClient, platform_owner_token: str
):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p1", "type": "openai_compatible", "base_url": "https://x.test/v1"},
headers=auth_header(platform_owner_token),
)
).json()
response = await client.post(
f"/api/admin/providers/{provider['id']}/credentials",
json={"name": "primary", "api_key": SECRET_KEY},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 201, response.text
body = response.json()
assert SECRET_KEY not in str(body)
assert body["masked_key"].startswith("•")
assert body["masked_key"].endswith(SECRET_KEY[-4:])
async def test_listing_credentials_never_exposes_the_key(
client: AsyncClient, platform_owner_token: str
):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p2", "type": "openai_compatible", "base_url": "https://y.test/v1"},
headers=auth_header(platform_owner_token),
)
).json()
await client.post(
f"/api/admin/providers/{provider['id']}/credentials",
json={"name": "primary", "api_key": SECRET_KEY},
headers=auth_header(platform_owner_token),
)
response = await client.get(
f"/api/admin/providers/{provider['id']}/credentials",
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200
assert SECRET_KEY not in response.text
assert response.json()[0]["masked_key"] == "••••••"
async def test_model_creation_and_listing(client: AsyncClient, platform_owner_token: str):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p3", "type": "mock", "base_url": ""},
headers=auth_header(platform_owner_token),
)
).json()
created = await client.post(
"/api/admin/models",
json={
"provider_id": provider["id"],
"internal_name": "internal-coder-v1",
"display_name_for_admin": "Coder V1",
"capabilities": ["text", "code"],
"input_price_per_1m": 0.3,
"output_price_per_1m": 1.2,
},
headers=auth_header(platform_owner_token),
)
assert created.status_code == 201, created.text
listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
assert listing.json()[0]["internal_name"] == "internal-coder-v1"
async def test_model_requires_existing_provider(client: AsyncClient, platform_owner_token: str):
response = await client.post(
"/api/admin/models",
json={
"provider_id": "does-not-exist",
"internal_name": "x",
"display_name_for_admin": "X",
},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 404
async def test_routing_policy_creation_and_default_swap(
client: AsyncClient, platform_owner_token: str
):
first = await client.post(
"/api/admin/routing-policies",
json={"name": "cheap", "mode": "cheap", "is_default": True},
headers=auth_header(platform_owner_token),
)
assert first.status_code == 201
second = await client.post(
"/api/admin/routing-policies",
json={"name": "coding", "mode": "coding", "is_default": True},
headers=auth_header(platform_owner_token),
)
assert second.status_code == 201
listing = (
await client.get("/api/admin/routing-policies", headers=auth_header(platform_owner_token))
).json()
defaults = [p for p in listing if p["is_default"]]
assert len(defaults) == 1
assert defaults[0]["name"] == "coding"
async def test_rate_card_upsert_updates_in_place(client: AsyncClient, platform_owner_token: str):
payload = {
"unit": "model_input_tokens",
"name": "input",
"customer_price": 0.0000005,
"internal_cost_estimate": 0.0000002,
}
first = await client.put(
"/api/admin/rate-cards", json=payload, headers=auth_header(platform_owner_token)
)
assert first.status_code == 200, first.text
payload["customer_price"] = 0.0000009
second = await client.put(
"/api/admin/rate-cards", json=payload, headers=auth_header(platform_owner_token)
)
assert second.json()["customer_price"] == 0.0000009
listing = (
await client.get("/api/admin/rate-cards", headers=auth_header(platform_owner_token))
).json()
assert len([c for c in listing if c["unit"] == "model_input_tokens"]) == 1
async def test_platform_owner_sees_all_organizations(
client: AsyncClient, platform_owner_token: str
):
customer = (await signup(client, "tenant-x@example.com"))["access_token"]
await client.post(
"/api/organizations", json={"name": "شرکت مشتری"}, headers=auth_header(customer)
)
response = await client.get(
"/api/admin/organizations", headers=auth_header(platform_owner_token)
)
assert response.status_code == 200
assert [o["name"] for o in response.json()] == ["شرکت مشتری"]
assert response.json()[0]["member_count"] == 1
async def test_customer_cannot_see_other_organizations_through_admin(
client: AsyncClient,
):
customer = (await signup(client, "tenant-y@example.com"))["access_token"]
response = await client.get("/api/admin/organizations", headers=auth_header(customer))
assert response.status_code == 403
+98
View File
@@ -0,0 +1,98 @@
"""Authentication flow tests."""
from __future__ import annotations
from httpx import AsyncClient
from tests.conftest import auth_header, signup
async def test_health_is_public(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
async def test_signup_returns_token_and_user(client: AsyncClient):
body = await signup(client, "alice@example.com", name="آلیس")
assert body["access_token"]
assert body["user"]["email"] == "alice@example.com"
assert body["user"]["name"] == "آلیس"
assert body["user"]["is_platform_owner"] is False
assert "password" not in str(body).lower() or "password_hash" not in str(body)
async def test_signup_never_returns_password_hash(client: AsyncClient):
body = await signup(client, "hashcheck@example.com")
assert "password_hash" not in body["user"]
assert "password" not in body["user"]
async def test_signup_rejects_duplicate_email(client: AsyncClient):
await signup(client, "dup@example.com")
response = await client.post(
"/api/auth/signup",
json={"email": "dup@example.com", "password": "password123", "name": ""},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "conflict"
async def test_signup_rejects_short_password(client: AsyncClient):
response = await client.post(
"/api/auth/signup",
json={"email": "short@example.com", "password": "123", "name": ""},
)
assert response.status_code == 422
async def test_login_succeeds_with_correct_password(client: AsyncClient):
await signup(client, "bob@example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "bob@example.com", "password": "password123"}
)
assert response.status_code == 200
assert response.json()["access_token"]
async def test_login_fails_with_wrong_password(client: AsyncClient):
await signup(client, "carol@example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "carol@example.com", "password": "wrong-password"}
)
assert response.status_code == 401
async def test_login_is_case_insensitive_on_email(client: AsyncClient):
await signup(client, "Dave@Example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "dave@example.com", "password": "password123"}
)
assert response.status_code == 200
async def test_me_requires_authentication(client: AsyncClient):
response = await client.get("/api/auth/me")
assert response.status_code == 401
async def test_me_rejects_garbage_token(client: AsyncClient):
response = await client.get("/api/auth/me", headers=auth_header("not-a-real-token"))
assert response.status_code == 401
async def test_me_returns_profile_and_organizations(client: AsyncClient):
body = await signup(client, "erin@example.com", name="ارین")
token = body["access_token"]
response = await client.get("/api/auth/me", headers=auth_header(token))
assert response.status_code == 200
payload = response.json()
assert payload["user"]["email"] == "erin@example.com"
assert payload["organizations"] == []
async def test_logout_is_recorded_and_returns_204(client: AsyncClient):
body = await signup(client, "frank@example.com")
response = await client.post("/api/auth/logout", headers=auth_header(body["access_token"]))
assert response.status_code == 204
+77
View File
@@ -0,0 +1,77 @@
"""Configuration safety guards.
A production API booted with a placeholder ``JWT_SECRET`` lets anyone forge a
token for any tenant. These tests pin the fail-closed behaviour.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.core.config import DEVELOPMENT_CORS_ORIGINS, MIN_SECRET_LENGTH, Settings
_SAFE = {
"app_env": "production",
"jwt_secret": "s" * MIN_SECRET_LENGTH,
"encryption_key": "k" * 44,
"cors_origins": "https://app.example.com",
}
def _build(**overrides) -> Settings:
return Settings(**{**_SAFE, **overrides})
def test_development_tolerates_placeholder_secrets():
"""A fresh clone must run with zero configuration."""
settings = Settings(
app_env="development", jwt_secret="change-me", encryption_key=""
)
assert settings.is_production is False
def test_development_cors_allows_both_loopback_spellings():
"""``localhost`` and ``127.0.0.1`` are different browser origins.
Listing only one makes the dev UI fail with an opaque "Disallowed CORS
origin" — which is exactly what happened when the frontend was started with
``-H 127.0.0.1`` while the API allowed only ``localhost``.
"""
origins = Settings(app_env="development").cors_origin_list
assert "http://localhost:3000" in origins
assert "http://127.0.0.1:3000" in origins
def test_production_rejects_the_placeholder_secret():
with pytest.raises(ValidationError, match="placeholder"):
_build(jwt_secret="change-me")
@pytest.mark.parametrize("secret", ["", "short", "x" * (MIN_SECRET_LENGTH - 1)])
def test_production_rejects_a_short_secret(secret: str):
with pytest.raises(ValidationError, match="JWT_SECRET"):
_build(jwt_secret=secret)
def test_production_requires_an_encryption_key():
"""Without it, stored provider credentials cannot be encrypted at rest."""
with pytest.raises(ValidationError, match="ENCRYPTION_KEY"):
_build(encryption_key="")
def test_production_rejects_wildcard_cors():
with pytest.raises(ValidationError, match="CORS_ORIGINS"):
_build(cors_origins="*")
def test_production_rejects_the_development_cors_default():
"""A production deploy left on the dev allowlist has not been configured."""
with pytest.raises(ValidationError, match="development default"):
_build(cors_origins=DEVELOPMENT_CORS_ORIGINS)
def test_production_accepts_a_fully_configured_environment():
settings = _build()
assert settings.is_production is True
assert settings.cors_origin_list == ["https://app.example.com"]
+51
View File
@@ -0,0 +1,51 @@
"""The domain layer must stay framework-free.
This is enforced by actually importing the domain modules in a clean
interpreter and asserting that neither FastAPI nor SQLAlchemy got pulled in.
A comment in a README would not catch a regression; this does.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
API_ROOT = Path(__file__).resolve().parents[1]
_PROBE = """
import sys
import app.domain.identity.models
import app.domain.workforce.models
import app.domain.billing.models
import app.domain.sandbox.models
import app.domain.knowledge.models
import app.domain.audit.models
import app.domain.ai_control.models
import app.domain.projects.models
import app.domain.platform.rules
forbidden = [name for name in ("fastapi", "sqlalchemy", "redis", "httpx") if name in sys.modules]
assert not forbidden, f"domain layer leaked: {forbidden}"
print("clean")
"""
def test_domain_layer_has_no_framework_dependencies():
result = subprocess.run(
[sys.executable, "-c", _PROBE],
capture_output=True,
text=True,
cwd=str(API_ROOT),
)
assert result.returncode == 0, result.stderr
assert "clean" in result.stdout
def test_domain_rules_are_usable_without_a_database():
from app.domain.platform.rules import build_host, is_port_allocatable
assert build_host("my-app", "msa.artacloud.ir") == "my-app.msa.artacloud.ir"
assert is_port_allocatable(8100)
assert not is_port_allocatable(3000)
+269
View File
@@ -0,0 +1,269 @@
"""§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, on the mock adapters:
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 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 -------------------------------------------------------- #
signup_body = await signup(client, "smoke@example.com", name="کاربر دود")
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}"
+318
View File
@@ -0,0 +1,318 @@
"""The error console API — platform-owner only, grouped, and audited.
The console is the reason the error table exists at all: an error nobody can
read is not observability, it is a write-only table. These tests cover both
halves — that a regular customer can never reach it, and that the platform owner
can actually triage with it.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest_asyncio
from httpx import AsyncClient
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
from tests.conftest import auth_header, signup
CONSOLE = "/api/admin/errors"
@pytest_asyncio.fixture(autouse=True)
async def _isolated_error_log(redirect_error_log) -> AsyncIterator[None]:
yield
async def _seed_error(
*,
kind: str = "unhandled_exception",
message: str = "boom",
error_type: str = "RuntimeError",
severity: str = ErrorSeverity.ERROR,
source: str = ErrorSource.HTTP,
path: str = "/api/organizations/{id}/projects",
organization_id: str | None = None,
method: str = "GET",
status_code: int | None = 500,
) -> str | None:
"""Write one error row through the real recorder, so fingerprints are real."""
exc: BaseException | None = None
if error_type:
exc = type(error_type, (Exception,), {})(message)
event_id = await record_error(
kind=kind,
message=message,
exc=exc,
severity=severity,
source=source,
path=path,
method=method,
status_code=status_code,
organization_id=organization_id,
)
assert event_id is not None, "the recorder failed to write a row"
return event_id
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
async def test_a_regular_customer_cannot_reach_the_console(client: AsyncClient):
"""The console exposes every tenant's failures; it is platform-owner only."""
token = (await signup(client, "nosy@example.com"))["access_token"]
for path in (
CONSOLE,
f"{CONSOLE}/summary",
f"{CONSOLE}/groups",
f"{CONSOLE}/some-id",
):
response = await client.get(path, headers=auth_header(token))
assert response.status_code == 403, f"{path} leaked to a customer"
async def test_an_anonymous_caller_cannot_reach_the_console(client: AsyncClient):
response = await client.get(CONSOLE)
assert response.status_code == 401
async def test_resolving_requires_a_platform_owner(client: AsyncClient):
event_id = await _seed_error()
token = (await signup(client, "not-owner@example.com"))["access_token"]
response = await client.post(
f"{CONSOLE}/{event_id}/resolve", json={"note": "nope"}, headers=auth_header(token)
)
assert response.status_code == 403
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
async def test_errors_are_listed_newest_first(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="first failure")
await _seed_error(message="second failure")
response = await client.get(CONSOLE, headers=auth_header(platform_owner_token))
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 2
messages = [item["message"] for item in body["items"]]
assert messages == ["second failure", "first failure"]
async def test_the_list_carries_everything_needed_to_diagnose(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(
kind="task_run_failed",
message="provider returned garbage",
error_type="ValueError",
status_code=None,
method="",
path="",
)
item = (
await client.get(CONSOLE, headers=auth_header(platform_owner_token))
).json()["items"][0]
for field in (
"id", "kind", "message", "stack", "severity", "source", "error_type",
"fingerprint", "status_code", "method", "path", "organization_id",
"user_id", "request_id", "ip", "user_agent", "metadata_json",
"resolved_at", "resolved_note", "created_at",
):
assert field in item, f"the console row is missing {field}"
assert item["kind"] == "task_run_failed"
assert item["error_type"] == "ValueError"
async def test_filters_narrow_the_list(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="http thing", source=ErrorSource.HTTP)
await _seed_error(message="worker thing", source=ErrorSource.WORKER, method="", path="")
headers = auth_header(platform_owner_token)
by_source = await client.get(f"{CONSOLE}?source=worker", headers=headers)
assert [i["message"] for i in by_source.json()["items"]] == ["worker thing"]
by_severity = await client.get(f"{CONSOLE}?severity=error", headers=headers)
assert by_severity.json()["total"] == 2
by_search = await client.get(f"{CONSOLE}?search=worker", headers=headers)
assert [i["message"] for i in by_search.json()["items"]] == ["worker thing"]
async def test_the_page_size_is_capped(client: AsyncClient, platform_owner_token: str):
"""An uncapped limit would let one request pull every stack trace at once."""
response = await client.get(
f"{CONSOLE}?limit=100000", headers=auth_header(platform_owner_token)
)
assert response.status_code == 422
async def test_unknown_error_id_is_a_404(client: AsyncClient, platform_owner_token: str):
response = await client.get(f"{CONSOLE}/nope", headers=auth_header(platform_owner_token))
assert response.status_code == 404
assert response.json()["error"]["code"] == "not_found"
async def test_summary_counts_open_failures_by_severity(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(severity=ErrorSeverity.CRITICAL)
await _seed_error(severity=ErrorSeverity.ERROR)
await _seed_error(severity=ErrorSeverity.WARNING)
body = (
await client.get(f"{CONSOLE}/summary", headers=auth_header(platform_owner_token))
).json()
assert body["total_open"] == 3
assert body["open_by_severity"] == {"critical": 1, "error": 1, "warning": 1}
# ---------------------------------------------------------------------------
# Grouping — the view that makes the console usable
# ---------------------------------------------------------------------------
async def test_identical_failures_collapse_into_one_group(
client: AsyncClient, platform_owner_token: str
):
"""A bug that fired 4 000 times must be one line with a count, not 4 000."""
for entity in ("aaaa1111bbbb2222cccc3333dddd4444", "9999eeee8888ffff7777aaaa6666bbbb"):
await _seed_error(
message="task not found while starting a run",
path=f"/api/organizations/{entity}/projects",
)
await _seed_error(message="a completely different failure", path="/api/other")
groups = (
await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token))
).json()
assert len(groups) == 2
biggest = max(groups, key=lambda g: g["occurrences"])
assert biggest["occurrences"] == 2
assert biggest["open_count"] == 2
assert biggest["sample_message"] == "task not found while starting a run"
assert biggest["first_seen"] and biggest["last_seen"]
async def test_group_listing_is_reachable_before_the_id_route(
client: AsyncClient, platform_owner_token: str
):
""""groups" must not be swallowed by ``/errors/{error_id}``."""
response = await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token))
assert response.status_code == 200
assert isinstance(response.json(), list)
# ---------------------------------------------------------------------------
# Triage
# ---------------------------------------------------------------------------
async def test_resolving_a_group_closes_every_open_occurrence(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="the same bug", path="/api/x")
await _seed_error(message="the same bug", path="/api/x")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
resolved = await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "fixed in v2"}, headers=headers
)
assert resolved.status_code == 200, resolved.text
assert resolved.json()["resolved"] == 2
# Nothing is left open…
assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 0
# …and the note is on the records.
closed = (await client.get(f"{CONSOLE}?status=resolved", headers=headers)).json()["items"]
assert all(item["resolved_note"] == "fixed in v2" for item in closed)
assert all(item["resolved_at"] for item in closed)
async def test_resolving_a_group_is_audited(
client: AsyncClient, platform_owner_token: str, session_factory
):
""""Who decided this bug was fixed?" must have an answer."""
from sqlalchemy import select
from app.infrastructure.db.models import AuditEventRow
await _seed_error(message="auditable bug", path="/api/y")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "done"}, headers=headers
)
async with session_factory() as session:
entries = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.action == "error.resolve")
)
).scalars()
)
assert len(entries) == 1, "triaging an error must leave a trail"
assert entries[0].target_id == fingerprint
assert entries[0].actor_user_id
assert entries[0].metadata_json["resolved"] == 1
assert entries[0].metadata_json["note"] == "done"
async def test_reopening_a_group_brings_the_bug_back(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="flaky bug", path="/api/z")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "ok"}, headers=headers
)
reopened = await client.post(f"{CONSOLE}/groups/{fingerprint}/reopen", headers=headers)
assert reopened.json()["resolved"] == 1
assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 1
async def test_resolving_an_already_closed_group_is_a_404(
client: AsyncClient, platform_owner_token: str
):
"""Rather than silently reporting success on a no-op."""
await _seed_error(message="close me twice", path="/api/w")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers)
second = await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers
)
assert second.status_code == 404
async def test_the_open_filter_hides_resolved_groups(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="will be closed", path="/api/closed")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers)
assert (await client.get(f"{CONSOLE}/groups?status=open", headers=headers)).json() == []
resolved = (await client.get(f"{CONSOLE}/groups?status=resolved", headers=headers)).json()
assert len(resolved) == 1
assert resolved[0]["open_count"] == 0
+474
View File
@@ -0,0 +1,474 @@
"""The error pipeline: from a raised exception to a row in the database.
These tests drive the real FastAPI app with the real middleware and the real
exception handlers, against the real recorder — only the database connection is
redirected. A unit test of the handlers would have missed the middleware, and a
unit test of the recorder would have missed the handlers; the value here is that
the whole path is exercised end to end.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest_asyncio
from fastapi import APIRouter, Depends
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_session
from app.core.errors import ProviderUnavailableError
from app.infrastructure.db.models import ErrorEventRow
from app.interfaces.http.deps import OrgContext, get_org_context
from app.interfaces.http.request_context import REQUEST_ID_HEADER
from app.main import app
from tests.conftest import auth_header, signup
#: The password in this DSN must never reach the database.
LEAKY_DSN = "postgresql://agy:sup3rs3cret@db.internal:5432/agy"
TEST_PREFIX = "/api/__errortest__"
@pytest_asyncio.fixture
async def client(engine, session_factory):
"""The shared test client, except that app exceptions are not re-raised.
Starlette's ``ServerErrorMiddleware`` deliberately sends the 500 response
and *then* re-raises, so the ASGI server can log it. httpx mirrors that
re-raise by default, which would surface every intentional 500 as a test
error instead of a response. These tests care about what the caller
receives, so the re-raise is switched off.
"""
async def _override_session():
async with session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_session] = _override_session
async with AsyncClient(
transport=ASGITransport(app=app, raise_app_exceptions=False),
base_url="http://test",
) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest_asyncio.fixture(autouse=True)
async def _recorder_uses_the_test_database(redirect_error_log) -> AsyncIterator[None]:
"""Redirect the recorder and the workers to the in-memory database.
See ``conftest.redirect_error_log``. Autouse here because every test in this
module deliberately triggers a failure path.
"""
yield
@pytest_asyncio.fixture
async def failing_routes() -> AsyncIterator[None]:
"""Mount throwaway routes that fail on purpose.
Registered on the real app so the real middleware, handlers and dependencies
run. Torn down afterwards so no other test can see them.
"""
router = APIRouter()
@router.get(f"{TEST_PREFIX}/unhandled")
async def _unhandled() -> None:
raise RuntimeError(f"kaboom while dialling {LEAKY_DSN}")
@router.get(f"{TEST_PREFIX}/provider-down")
async def _provider_down() -> None:
raise ProviderUnavailableError("سرویس‌دهنده در دسترس نیست.")
@router.get(f"{TEST_PREFIX}/value-error")
async def _value_error() -> None:
raise ValueError("chunk size must be positive")
@router.get(f"{TEST_PREFIX}/org/{{org_id}}")
async def _org_scoped(context: OrgContext = Depends(get_org_context)) -> None:
raise RuntimeError(f"failed inside org {context.org_id}")
app.include_router(router)
yield
app.router.routes = [
route
for route in app.router.routes
if not getattr(route, "path", "").startswith(TEST_PREFIX)
]
async def _rows(session_factory) -> list[ErrorEventRow]:
async with session_factory() as session:
result = await session.execute(select(ErrorEventRow).order_by(ErrorEventRow.created_at))
return list(result.scalars())
async def _only_row(session_factory) -> ErrorEventRow:
rows = await _rows(session_factory)
assert len(rows) == 1, f"expected exactly one error row, found {len(rows)}"
return rows[0]
# ---------------------------------------------------------------------------
# Request ids
# ---------------------------------------------------------------------------
async def test_every_response_carries_a_request_id(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.headers.get(REQUEST_ID_HEADER)
async def test_caller_supplied_request_id_is_honoured(client: AsyncClient):
"""Lets an upstream proxy or BFF correlate its logs with ours."""
response = await client.get("/health", headers={REQUEST_ID_HEADER: "trace-from-upstream"})
assert response.headers[REQUEST_ID_HEADER] == "trace-from-upstream"
async def test_an_absurdly_long_caller_request_id_is_capped(client: AsyncClient):
response = await client.get("/health", headers={REQUEST_ID_HEADER: "x" * 500})
assert len(response.headers[REQUEST_ID_HEADER]) <= 64
# ---------------------------------------------------------------------------
# Unhandled exceptions
# ---------------------------------------------------------------------------
async def test_unhandled_exception_is_persisted_with_full_context(
client: AsyncClient, failing_routes: None, session_factory
):
response = await client.get(f"{TEST_PREFIX}/unhandled", headers={REQUEST_ID_HEADER: "req-1"})
assert response.status_code == 500
# The caller is told the request id so a bug report is actionable — in the
# body *and* in the header. Starlette's ServerErrorMiddleware turns an
# unhandled exception into a response outside every user middleware, so the
# header has to be set by the handler; a middleware-only approach silently
# loses it on exactly the responses that matter most.
assert response.json()["error"]["request_id"] == "req-1"
assert response.headers.get(REQUEST_ID_HEADER) == "req-1"
row = await _only_row(session_factory)
assert row.kind == "unhandled_exception"
assert row.severity == "critical"
assert row.source == "http"
assert row.error_type == "RuntimeError"
assert row.status_code == 500
assert row.method == "GET"
assert row.path == f"{TEST_PREFIX}/unhandled"
assert row.request_id == "req-1"
assert row.fingerprint
assert row.stack, "a traceback is the single most useful field for fixing a bug"
assert row.resolved_at is None
async def test_error_responses_carry_the_header_for_every_handler(
client: AsyncClient, failing_routes: None
):
"""Each handler path must set the header, not just the body."""
checks = [
(f"{TEST_PREFIX}/unhandled", 500),
(f"{TEST_PREFIX}/provider-down", 503),
(f"{TEST_PREFIX}/value-error", 422),
]
for path, expected in checks:
response = await client.get(path, headers={REQUEST_ID_HEADER: "hdr-check"})
assert response.status_code == expected, path
assert response.headers.get(REQUEST_ID_HEADER) == "hdr-check", path
assert response.json()["error"]["request_id"] == "hdr-check", path
# A Pydantic validation failure, which uses FastAPI's own exception type.
response = await client.post(
"/api/auth/login", json={"email": "nope"}, headers={REQUEST_ID_HEADER: "hdr-check"}
)
assert response.status_code == 422
assert response.headers.get(REQUEST_ID_HEADER) == "hdr-check"
async def test_a_routing_404_still_carries_the_header(client: AsyncClient):
"""Handled by Starlette's own middleware, so it exercises a different path."""
response = await client.get("/api/definitely-not-a-route")
assert response.status_code == 404
assert response.headers.get(REQUEST_ID_HEADER)
async def test_a_secret_in_the_exception_message_never_reaches_the_database(
client: AsyncClient, failing_routes: None, session_factory
):
"""The whole reason the redactor exists.
The exception interpolates a DSN containing a password; the stored message
must not contain it, and neither must the stored stack.
"""
await client.get(f"{TEST_PREFIX}/unhandled")
row = await _only_row(session_factory)
assert "sup3rs3cret" not in row.message
assert "sup3rs3cret" not in row.stack
assert "db.internal" in row.message # the useful part survives
async def test_org_and_user_are_attributed_through_the_real_dependency(
client: AsyncClient, failing_routes: None, session_factory
):
token = (await signup(client, "errlog-org@example.com"))["access_token"]
org = (
await client.post(
"/api/organizations", json={"name": "سازمان آزمایشی"}, headers=auth_header(token)
)
).json()
response = await client.get(
f"{TEST_PREFIX}/org/{org['id']}", headers=auth_header(token)
)
assert response.status_code == 500
row = await _only_row(session_factory)
assert row.organization_id == org["id"]
assert row.user_id == org["owner_user_id"]
async def test_a_failure_outside_any_tenant_still_records(
client: AsyncClient, failing_routes: None, session_factory
):
await client.get(f"{TEST_PREFIX}/unhandled")
row = await _only_row(session_factory)
assert row.organization_id is None
assert row.user_id is None
# ---------------------------------------------------------------------------
# Which failures get recorded
# ---------------------------------------------------------------------------
async def test_server_side_app_error_is_always_recorded(
client: AsyncClient, failing_routes: None, session_factory
):
"""A 503 is an operational fault, not caller error — the switch must not
suppress it."""
response = await client.get(f"{TEST_PREFIX}/provider-down")
assert response.status_code == 503
row = await _only_row(session_factory)
assert row.kind == "provider_unavailable"
assert row.severity == "error"
assert row.status_code == 503
async def test_client_errors_are_not_recorded_by_default(
client: AsyncClient, session_factory
):
"""A public API takes constant 404s from scanners; recording them would
bury real bugs."""
assert settings.error_log_client_errors is False
response = await client.get("/api/organizations/does-not-exist")
assert response.status_code in (401, 404)
assert await _rows(session_factory) == []
async def test_client_errors_are_recorded_when_enabled(
client: AsyncClient, session_factory, monkeypatch
):
monkeypatch.setattr(settings, "error_log_client_errors", True)
token = (await signup(client, "errlog-404@example.com"))["access_token"]
response = await client.get("/api/organizations/nope", headers=auth_header(token))
assert response.status_code == 404
row = await _only_row(session_factory)
assert row.kind == "not_found"
assert row.severity == "warning", "a 404 must never look like a server fault"
assert row.status_code == 404
async def test_a_bare_value_error_is_recorded_even_with_client_errors_off(
client: AsyncClient, failing_routes: None, session_factory
):
"""``ValueError`` escapes are a typing gap, not caller input: the codebase
has a typed ``ValidationError`` for that. They are always worth seeing."""
assert settings.error_log_client_errors is False
response = await client.get(f"{TEST_PREFIX}/value-error")
assert response.status_code == 422
row = await _only_row(session_factory)
assert row.kind == "value_error"
assert row.severity == "warning"
assert row.error_type == "ValueError"
async def test_validation_failures_use_the_standard_envelope(
client: AsyncClient, session_factory
):
"""The web client reads ``error.code``/``error.message``; FastAPI's default
``{"detail": [...]}`` is unreadable to it."""
response = await client.post("/api/auth/login", json={"email": "not-an-email"})
assert response.status_code == 422
body = response.json()
assert body["error"]["code"] == "validation_error"
assert body["error"]["message"]
assert body["error"]["request_id"]
assert isinstance(body["error"]["details"], list)
async def test_the_switch_can_turn_recording_off_entirely(
client: AsyncClient, failing_routes: None, session_factory, monkeypatch
):
monkeypatch.setattr(settings, "error_log_enabled", False)
response = await client.get(f"{TEST_PREFIX}/unhandled")
# The response is unchanged — only the persistence is skipped.
assert response.status_code == 500
assert response.json()["error"]["request_id"]
assert await _rows(session_factory) == []
# ---------------------------------------------------------------------------
# Resilience — recording must never make things worse
# ---------------------------------------------------------------------------
async def test_a_broken_recorder_does_not_break_the_response(
client: AsyncClient, failing_routes: None, monkeypatch
):
"""If the database is unreachable, the caller must still get a clean 500.
This is the failure mode that matters most: the errors you cannot record are
exactly the database errors, and they must not cascade into a hang or an
unhandled exception inside the exception handler.
"""
import app.infrastructure.error_log as error_log
class _Exploding:
def __call__(self, *args, **kwargs):
raise RuntimeError("database is unreachable")
monkeypatch.setattr(error_log, "SessionFactory", _Exploding())
response = await client.get(f"{TEST_PREFIX}/unhandled", headers={REQUEST_ID_HEADER: "req-x"})
assert response.status_code == 500
assert response.json()["error"]["code"] == "internal_error"
assert response.json()["error"]["request_id"] == "req-x"
async def test_metadata_that_is_not_serialisable_does_not_raise(
client: AsyncClient, failing_routes: None, session_factory
):
"""``_safe_metadata`` must degrade, not explode, on odd payloads."""
from app.infrastructure.error_log import _safe_metadata
class _Weird:
def __repr__(self) -> str:
return "<weird>"
result = _safe_metadata({"obj": _Weird(), "password": "hunter2"})
assert result["password"] == "[REDACTED]"
assert "weird" in str(result["obj"])
# ---------------------------------------------------------------------------
# Worker and startup failures
# ---------------------------------------------------------------------------
async def test_a_failed_worker_pass_is_persisted(seeded, session_factory):
"""A worker that only logged to stderr would lose the failure the moment the
container was replaced."""
import asyncio
from app.workers.base import run_periodically
async def exploding_pass():
raise RuntimeError("billing rollup blew up")
outcomes = await run_periodically(
exploding_pass,
interval_seconds=1,
stop=asyncio.Event(),
run_once=True,
worker_name="billing",
)
# The pass is reported as failed, so a cron entry exits non-zero instead of
# silently succeeding.
assert len(outcomes) == 1
assert outcomes[0].failed == 1
assert outcomes[0].name == "billing"
row = await _only_row(session_factory)
assert row.kind == "worker_pass_failed"
assert row.source == "worker"
assert row.severity == "error"
assert row.error_type == "RuntimeError"
assert row.metadata_json["worker"] == "billing"
assert row.stack
# A worker pass has no HTTP request behind it.
assert row.path == ""
assert row.status_code is None
async def test_a_failed_task_run_is_persisted_with_its_tenant(
seeded, client: AsyncClient, session_factory, monkeypatch
):
"""A task that cannot run is a tenant-visible problem, so the row must name
the organization — otherwise nobody can find it in the console."""
from app.application.services.workforce_service import WorkforceService
from app.workers.ai_runtime import AiRuntimeWorker
from tests.test_workers import _make_org_with_employee
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "errlog-worker@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کاری که شکست می‌خورد",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
async def _boom(*args, **kwargs):
raise RuntimeError("provider returned a malformed response")
monkeypatch.setattr(WorkforceService, "start_run", _boom)
outcome = await AiRuntimeWorker().drain()
assert outcome.failed == 1, outcome.notes
row = await _only_row(session_factory)
assert row.kind == "task_run_failed"
assert row.source == "worker"
assert row.organization_id == org_id
assert row.metadata_json["task_id"] == task_id
assert row.metadata_json["employee_id"] == employee_id
async def test_a_startup_failure_is_persisted_as_critical(
seeded, session_factory
):
"""Boot failures are the hardest to diagnose after the fact: the process is
gone and stderr is usually discarded."""
from app.main import _record_startup_failure
try:
raise RuntimeError("could not create the schema")
except RuntimeError as exc:
await _record_startup_failure(exc)
row = await _only_row(session_factory)
assert row.kind == "startup_failed"
assert row.source == "startup"
assert row.severity == "critical"
assert row.metadata_json["app_env"] == settings.app_env
+96
View File
@@ -0,0 +1,96 @@
"""§20 — list endpoints are paginated.
Every tenant-scoped list is unbounded in principle: an organization can create
thousands of tasks. Without a page size the API would happily serialize all of
them, so these tests pin the contract that a list is capped and that ``offset``
walks the result set.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import AsyncClient
from tests.conftest import auth_header, signup
# Fixtures resolve from conftest by name, so ``seeded`` needs no import here;
# importing it would shadow this parameter (ruff F811) and read as unused (F401).
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
async def test_tasks_list_is_paginated(client: AsyncClient):
token = (await signup(client, "page-tasks@example.com"))["access_token"]
headers = auth_header(token)
org_id = (
await client.post("/api/organizations", json={"name": "سازمان صفحه"}, headers=headers)
).json()["id"]
for index in range(5):
created = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": f"کار {index}"},
headers=headers,
)
assert created.status_code == 201, created.text
page_one = await client.get(
f"/api/organizations/{org_id}/tasks", params={"limit": 2}, headers=headers
)
assert page_one.status_code == 200, page_one.text
assert len(page_one.json()) == 2, "limit must cap the page size"
page_two = await client.get(
f"/api/organizations/{org_id}/tasks",
params={"limit": 2, "offset": 2},
headers=headers,
)
assert len(page_two.json()) == 2
# Pages must not overlap: offset walks the ordering rather than repeating it.
ids_one = {t["id"] for t in page_one.json()}
ids_two = {t["id"] for t in page_two.json()}
assert not (ids_one & ids_two), "consecutive pages returned the same row"
everything = await client.get(
f"/api/organizations/{org_id}/tasks", params={"limit": 500}, headers=headers
)
assert len(everything.json()) == 5
@pytest.mark.parametrize("bad", [{"limit": 0}, {"limit": 501}, {"offset": -1}])
async def test_out_of_range_pagination_is_rejected(client: AsyncClient, bad: dict):
token = (await signup(client, f"page-bad-{abs(hash(str(bad))) % 10**6}@example.com"))[
"access_token"
]
headers = auth_header(token)
org_id = (
await client.post(
"/api/organizations", json={"name": "سازمان اعتبار"}, headers=headers
)
).json()["id"]
response = await client.get(
f"/api/organizations/{org_id}/tasks", params=bad, headers=headers
)
assert response.status_code == 422, response.text
async def test_projects_and_employees_accept_pagination(client: AsyncClient):
"""The parameter is accepted consistently across the tenant-scoped lists."""
token = (await signup(client, "page-multi@example.com"))["access_token"]
headers = auth_header(token)
org_id = (
await client.post("/api/organizations", json={"name": "سازمان چندگانه"}, headers=headers)
).json()["id"]
for path in ("projects", "employees", "threads", "tasks"):
response = await client.get(
f"/api/organizations/{org_id}/{path}",
params={"limit": 10, "offset": 0},
headers=headers,
)
assert response.status_code == 200, f"{path}: {response.text}"
+119
View File
@@ -0,0 +1,119 @@
"""Platform safety rules: subdomain validation, proxy targets, port ranges."""
from __future__ import annotations
import pytest
from app.domain.platform.rules import (
RuleViolation,
assert_not_protected_port,
assert_port_allocatable,
build_host,
is_port_allocatable,
validate_managed_host,
validate_proxy_target,
validate_subdomain_label,
)
@pytest.mark.parametrize(
"label", ["my-app", "app1", "a" * 40, "abc"]
)
def test_valid_labels(label: str):
assert validate_subdomain_label(label) == label
@pytest.mark.parametrize(
"label",
[
"ab", # too short
"a" * 41, # too long
"-abc", # leading hyphen
"abc-", # trailing hyphen
"My-App", # uppercase is normalised, but this one has no other issue
"app_name", # underscore not allowed
"app.name", # dot not allowed
"app name", # whitespace
"app{}", # brace injection attempt
"app\nother", # newline injection attempt
"", # empty
],
)
def test_invalid_labels(label: str):
if label == "My-App":
# Documented behaviour: input is lower-cased, so this is accepted as "my-app".
assert validate_subdomain_label(label) == "my-app"
return
with pytest.raises(RuleViolation):
validate_subdomain_label(label)
def test_build_host_appends_root_domain():
assert build_host("shop", "msa.artacloud.ir") == "shop.msa.artacloud.ir"
def test_managed_host_rejects_foreign_domain():
with pytest.raises(RuleViolation):
validate_managed_host("evil.example.com", "msa.artacloud.ir")
def test_managed_host_rejects_nested_label():
with pytest.raises(RuleViolation):
validate_managed_host("a.b.msa.artacloud.ir", "msa.artacloud.ir")
def test_managed_host_rejects_injection_attempt():
with pytest.raises(RuleViolation):
validate_managed_host("app { reverse_proxy evil:80 }.msa.artacloud.ir", "msa.artacloud.ir")
def test_managed_host_accepts_valid_host():
assert (
validate_managed_host("my-app.msa.artacloud.ir", "msa.artacloud.ir")
== "my-app.msa.artacloud.ir"
)
@pytest.mark.parametrize(
"target",
["localhost:8080", "127.0.0.1:8080", "http://localhost:8080", "http://127.0.0.1:9000"],
)
def test_valid_proxy_targets(target: str):
host, port = validate_proxy_target(target)
assert host in {"localhost", "127.0.0.1"}
assert 1 <= port <= 65535
@pytest.mark.parametrize(
"target",
[
"example.com:80",
"169.254.169.254:80", # cloud metadata endpoint
"10.0.0.5:8080", # internal network
"localhost", # no port
"localhost:notaport",
"0.0.0.0:8080",
],
)
def test_ssrf_proxy_targets_rejected(target: str):
with pytest.raises(RuleViolation):
validate_proxy_target(target)
@pytest.mark.parametrize("port", [22, 53, 80, 443, 3000, 5432, 6379, 8008, 2019, 7999, 9000])
def test_reserved_and_out_of_range_ports_rejected(port: int):
assert not is_port_allocatable(port)
with pytest.raises(RuleViolation):
assert_port_allocatable(port)
@pytest.mark.parametrize("port", [8080, 8500, 8999])
def test_allocatable_ports(port: int):
assert is_port_allocatable(port)
assert assert_port_allocatable(port) == port
@pytest.mark.parametrize("port", [3000, 35555, 35556, 35557, 5432])
def test_protected_ports_cannot_be_proxied(port: int):
with pytest.raises(RuleViolation):
assert_not_protected_port(port)
+117
View File
@@ -0,0 +1,117 @@
"""Project Git repository endpoints.
These endpoints had **no test coverage** and were broken at runtime: the domain
entity and the infrastructure repository that manages ``Project`` rows were both
named ``ProjectRepository``, so the infrastructure class shadowed the entity and
``ensure_repository`` constructed it with ``project_id=`` — a ``TypeError`` that
turned every call into a 500.
The tests below exist to keep that from regressing, and to pin the security
properties the module promises: repositories are always private, and the Git
password never reaches a response body.
"""
from __future__ import annotations
import json
from httpx import AsyncClient
from tests.conftest import auth_header, signup
async def _org_and_project(client: AsyncClient, token: str) -> tuple[str, str]:
org = await client.post(
"/api/organizations", json={"name": "شرکت مخزن"}, headers=auth_header(token)
)
assert org.status_code == 201, org.text
org_id = org.json()["id"]
project = await client.post(
f"/api/organizations/{org_id}/projects",
json={"name": "پروژهٔ آزمایشی", "description": "برای تست مخزن"},
headers=auth_header(token),
)
assert project.status_code == 201, project.text
return org_id, project.json()["id"]
async def test_ensure_repository_creates_a_private_repo(client: AsyncClient):
"""The exact path that used to raise TypeError -> 500."""
token = (await signup(client, "repo-owner@example.com"))["access_token"]
org_id, project_id = await _org_and_project(client, token)
base = f"/api/organizations/{org_id}/projects/{project_id}"
response = await client.post(f"{base}/repo/ensure", headers=auth_header(token))
assert response.status_code in (200, 201), response.text
body = response.json()
assert body["owner"]
assert body["repo_name"]
assert body["clone_url"]
# A tenant repository must never be created public.
assert body.get("private", True) is True
async def test_repo_status_is_reported_after_ensure(client: AsyncClient):
token = (await signup(client, "repo-status@example.com"))["access_token"]
org_id, project_id = await _org_and_project(client, token)
base = f"/api/organizations/{org_id}/projects/{project_id}"
# Before ensure, the platform has no record of a repository.
before = await client.get(f"{base}/repo/status", headers=auth_header(token))
assert before.status_code == 200, before.text
assert before.json()["exists"] is False
await client.post(f"{base}/repo/ensure", headers=auth_header(token))
after = await client.get(f"{base}/repo/status", headers=auth_header(token))
assert after.status_code == 200, after.text
assert after.json()["exists"] is True
async def test_git_password_never_leaves_the_api(client: AsyncClient):
"""`ensure_credentials` returns plaintext to the service layer only.
The regression this guards against is a future refactor adding the
credential object straight to a response model.
"""
token = (await signup(client, "repo-secret@example.com"))["access_token"]
org_id, project_id = await _org_and_project(client, token)
base = f"/api/organizations/{org_id}/projects/{project_id}"
await client.post(f"{base}/repo/ensure", headers=auth_header(token))
for path in (f"{base}/repo", f"{base}/repo/status"):
response = await client.get(path, headers=auth_header(token))
assert response.status_code == 200, response.text
payload = json.dumps(response.json())
for forbidden in ("password", "encrypted_password", "token"):
assert forbidden not in payload.lower(), f"{forbidden} leaked via {path}"
async def test_repository_of_another_tenant_is_not_reachable(client: AsyncClient):
"""Tenant isolation must hold on the repo sub-resources too."""
token_a = (await signup(client, "repo-a@example.com"))["access_token"]
token_b = (await signup(client, "repo-b@example.com"))["access_token"]
org_a, project_a = await _org_and_project(client, token_a)
# B is authenticated but not a member of A's organization.
response = await client.post(
f"/api/organizations/{org_a}/projects/{project_a}/repo/ensure",
headers=auth_header(token_b),
)
# 404 rather than 403, so the API does not confirm the project exists.
assert response.status_code == 404, response.text
async def test_ensure_repository_is_idempotent(client: AsyncClient):
token = (await signup(client, "repo-idem@example.com"))["access_token"]
org_id, project_id = await _org_and_project(client, token)
base = f"/api/organizations/{org_id}/projects/{project_id}"
first = await client.post(f"{base}/repo/ensure", headers=auth_header(token))
second = await client.post(f"{base}/repo/ensure", headers=auth_header(token))
assert first.status_code in (200, 201), first.text
assert second.status_code in (200, 201), second.text
assert first.json()["repo_name"] == second.json()["repo_name"]
+221
View File
@@ -0,0 +1,221 @@
"""The redactor is the only thing standing between an exception payload and a
password in the database. These tests try to defeat it the way real code does:
secrets nested in dicts, buried in DSNs, wearing an unexpected key name.
"""
from __future__ import annotations
import pytest
from app.core.redact import (
REDACTED,
is_sensitive_key,
redact,
redact_headers,
redact_mapping,
redact_text,
)
# ---------------------------------------------------------------------------
# Structural layer — sensitive keys
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"key",
[
"password",
"PASSWORD",
"Passwd",
"api_key",
"apiKey",
"X-API-Key",
"authorization",
"Authorization",
"access_token",
"refreshToken",
"client_secret",
"private_key",
"gitea_admin_token",
"stripe_secret_key",
"db_password",
"encryption_key",
],
)
def test_sensitive_keys_are_recognised(key: str) -> None:
assert is_sensitive_key(key) is True
@pytest.mark.parametrize(
"key",
[
"token_count",
"input_tokens",
"output_tokens",
"max_tokens",
"total_tokens",
"name",
"email",
"organization_id",
"command",
"status_code",
],
)
def test_usage_counters_and_ordinary_keys_are_not_sensitive(key: str) -> None:
"""``input_tokens`` is billing data, not a credential.
Over-eager matching here would quietly destroy the usage numbers the error
log exists to help debug.
"""
assert is_sensitive_key(key) is False
def test_sensitive_key_value_is_replaced_whatever_it_looks_like() -> None:
# The value has no recognisable shape at all — only the key gives it away.
payload = {"password": "correct horse battery staple", "username": "demo"}
result = redact_mapping(payload)
assert result["password"] == REDACTED
assert result["username"] == "demo"
def test_redaction_reaches_into_nested_structures() -> None:
payload = {
"request": {
"headers": {"Authorization": "Bearer abcdefghijklmnop"},
"body": {"user": {"password": "hunter2", "email": "demo@example.com"}},
},
"providers": [{"api_key": "sk-abcdefghijklmnopqrstuvwxyz"}, {"name": "openai"}],
}
result = redact(payload)
assert result["request"]["headers"]["Authorization"] == REDACTED
assert result["request"]["body"]["user"]["password"] == REDACTED
assert result["request"]["body"]["user"]["email"] == "demo@example.com"
assert result["providers"][0]["api_key"] == REDACTED
assert result["providers"][1]["name"] == "openai"
# ---------------------------------------------------------------------------
# Lexical layer — recognisable values under harmless keys
# ---------------------------------------------------------------------------
def test_bearer_token_in_a_free_text_message_is_scrubbed() -> None:
text = "upstream rejected the credential Bearer eyJhbGciOiJIUzI1NiJ9.abcdefgh.ijklmnop"
scrubbed = redact_text(text)
assert "eyJhbGciOiJIUzI1NiJ9" not in scrubbed
assert "Bearer" in scrubbed # the scheme stays, it is not the secret
def test_authorization_header_form_is_scrubbed_once_not_twice() -> None:
"""``Authorization: Bearer <jwt>`` is a *sensitive key*, so the whole value
goes — and it must not leave a doubled ``[REDACTED] [REDACTED]`` behind."""
text = "request failed: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefgh.ijklmnop"
scrubbed = redact_text(text)
assert "eyJhbGciOiJIUzI1NiJ9" not in scrubbed
assert scrubbed.count(REDACTED) == 1
def test_jwt_anywhere_in_a_message_is_scrubbed() -> None:
token = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJzdWIiOiIxMjM0NTY3ODkwIn0"
".dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"
)
scrubbed = redact_text(f"token decode failed for {token} at step 3")
assert token not in scrubbed
assert "at step 3" in scrubbed
def test_url_credentials_are_stripped_but_host_is_kept() -> None:
scrubbed = redact_text("could not connect to postgresql://agy:s3cr3t@db.internal:5432/agy")
assert "s3cr3t" not in scrubbed
assert "db.internal" in scrubbed # the useful part survives
def test_provider_key_shapes_are_scrubbed() -> None:
for secret in (
"sk-abcdefghijklmnopqrstuvwxyz012345",
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
"AKIAIOSFODNN7EXAMPLE",
"xoxb-1234567890-abcdefghijkl",
):
assert secret not in redact_text(f"provider call failed: {secret}")
def test_pem_private_key_block_is_scrubbed() -> None:
pem = (
"-----BEGIN RSA PRIVATE KEY-----\n"
"MIIEowIBAAKCAQEA1234567890abcdefghijklmnop\n"
"-----END RSA PRIVATE KEY-----"
)
scrubbed = redact_text(f"failed to load key:\n{pem}")
assert "MIIEowIBAAKCAQEA" not in scrubbed
assert "REDACTED_PRIVATE_KEY" in scrubbed
def test_inline_key_value_pair_in_a_larger_string() -> None:
scrubbed = redact_text("webhook rejected: password=hunter2&retries=3")
assert "hunter2" not in scrubbed
assert "retries=3" in scrubbed
# ---------------------------------------------------------------------------
# Third net — configured secrets matched by value
# ---------------------------------------------------------------------------
def test_the_configured_jwt_secret_is_scrubbed_by_value() -> None:
"""The strongest guarantee: a real configured secret never reaches storage.
The test suite sets a 32+ byte ``JWT_SECRET``; whatever it is, it must not
survive redaction even when it appears with no key and no recognisable shape.
"""
from app.core.config import settings
secret = settings.jwt_secret
scrubbed = redact_text(f"signature check failed comparing against {secret} directly")
assert secret not in scrubbed
# ---------------------------------------------------------------------------
# Robustness — the redactor must never break a handler
# ---------------------------------------------------------------------------
def test_headers_are_scrubbed_and_lowercased() -> None:
result = redact_headers(
{"Authorization": "Bearer abcdefghijkl", "User-Agent": "curl/8.0", "Cookie": "sid=xyz"}
)
assert result["authorization"] == REDACTED
assert result["cookie"] == REDACTED
assert result["user-agent"] == "curl/8.0"
def test_bytes_become_a_length_not_a_body() -> None:
assert redact(b"\x00\x01\x02\x03") == "<4 bytes>"
def test_scalars_pass_through_untouched() -> None:
assert redact(42) == 42
assert redact(1.5) == 1.5
assert redact(True) is True
assert redact(None) is None
def test_empty_inputs_are_handled() -> None:
assert redact_mapping(None) == {}
assert redact_headers(None) == {}
assert redact_text("") == ""
def test_very_long_input_is_bounded() -> None:
"""A 5 MB blob must not be handed to the regex engine whole."""
scrubbed = redact_text("x" * 5_000_000)
assert len(scrubbed) < 100_000
assert "truncated" in scrubbed
def test_non_string_keys_do_not_raise() -> None:
result = redact_mapping({1: "a", (2, 3): "b", None: "c"})
assert result["1"] == "a"
assert result["None"] == "c"
+219
View File
@@ -0,0 +1,219 @@
"""§17 — the seed produces a stack a developer can actually log into.
This file exists because of two real bugs, both found by trying to *use* a
seeded install rather than by reading the code:
1. The seed created ``demo@agy.local`` by writing straight to the database,
bypassing request validation — while the login endpoint rejects ``.local`` as
a special-use TLD. The account existed and was unreachable.
2. Even the account that *could* log in belonged to no organization, so every
screen rendered empty and the demo looked broken.
Neither was caught by the rest of the suite, because every other test signs up a
fresh user at ``@example.com`` and creates its own organization. The seed was
simply never exercised end to end. These tests close that hole.
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import func, select
from tests.conftest import auth_header
@pytest_asyncio.fixture
async def seeded_install(monkeypatch, engine, session_factory):
"""Run the real ``app.seed`` against the in-memory test database.
``app.seed`` opens its own engine and session factory, so both module-level
names are swapped for the test ones. The seed code itself is untouched —
testing a copy of it would prove nothing.
"""
import app.seed as seed_module
monkeypatch.setattr(seed_module, "engine", engine)
monkeypatch.setattr(seed_module, "SessionFactory", session_factory)
await seed_module.seed()
return seed_module
async def test_seeded_demo_user_can_log_in(client: AsyncClient, seeded_install):
"""The credentials the seed prints must actually work."""
response = await client.post(
"/api/auth/login",
json={
"email": seeded_install.DEMO_EMAIL,
"password": seeded_install.DEMO_PASSWORD,
},
)
assert response.status_code == 200, (
f"the seeded demo account cannot log in: {response.text}"
)
assert response.json()["access_token"]
async def test_seeded_platform_owner_can_log_in(client: AsyncClient, seeded_install):
response = await client.post(
"/api/auth/login",
json={
"email": seeded_install.PLATFORM_OWNER_EMAIL,
"password": seeded_install.PLATFORM_OWNER_PASSWORD,
},
)
assert response.status_code == 200, response.text
me = await client.get(
"/api/auth/me", headers=auth_header(response.json()["access_token"])
)
assert me.status_code == 200, me.text
assert me.json()["user"]["is_platform_owner"] is True
async def test_seeded_accounts_belong_to_the_sample_organization(
client: AsyncClient, seeded_install
):
"""A login that lands on an empty dashboard is a broken demo.
Both seeded accounts must see the sample organization, otherwise every
screen behind the login renders nothing.
"""
for email, password in (
(seeded_install.DEMO_EMAIL, seeded_install.DEMO_PASSWORD),
(seeded_install.PLATFORM_OWNER_EMAIL, seeded_install.PLATFORM_OWNER_PASSWORD),
):
login = await client.post("/api/auth/login", json={"email": email, "password": password})
assert login.status_code == 200, login.text
me = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"])
)
organizations = me.json()["organizations"]
assert organizations, f"{email} belongs to no organization"
assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME
assert organizations[0]["role"] == "owner"
async def test_seeded_install_can_use_the_product(
client: AsyncClient, seeded_install
):
"""The seed's platform rows are enough to create a project and run a task.
A seed that satisfies the login test but leaves the catalogue empty would
still fail the moment a developer tried anything real.
"""
login = await client.post(
"/api/auth/login",
json={
"email": seeded_install.DEMO_EMAIL,
"password": seeded_install.DEMO_PASSWORD,
},
)
headers = auth_header(login.json()["access_token"])
org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"]
# The platform catalogue the seed installs must be visible to a tenant.
templates = await client.get(
f"/api/organizations/{org_id}/agent-templates", headers=headers
)
assert templates.status_code == 200, templates.text
assert templates.json(), "the seed must install agent templates"
sandbox_templates = await client.get(
f"/api/organizations/{org_id}/sandbox/templates", headers=headers
)
assert sandbox_templates.json(), "the seed must install sandbox templates"
project_id = (
await client.post(
f"/api/organizations/{org_id}/projects",
json={"name": "پروژهٔ seed"},
headers=headers,
)
).json()["id"]
template = templates.json()[0]
employee_id = (
await client.post(
f"/api/organizations/{org_id}/employees",
json={
"name": "کارمند seed",
"role_key": template["role_key"],
"template_id": template["id"],
},
headers=headers,
)
).json()["id"]
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار seed",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
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
assert run.json()["status"] == "completed", (
"a seeded install must be able to route to a model and finish a run"
)
async def test_seed_is_idempotent(client: AsyncClient, seeded_install, session_factory):
"""Re-running the seed must not duplicate anything.
The memberships and the wallet are now created by get-or-create rather than
as a side effect of creating the organization, so this guards the parts that
were easy to double up.
"""
import app.seed as seed_module
from app.infrastructure.db.models import (
AgentTemplateRow,
CreditWalletRow,
MembershipRow,
OrganizationRow,
UserRow,
)
async def counts() -> dict[str, int]:
async with session_factory() as session:
return {
"organizations": int(
(await session.execute(select(func.count()).select_from(OrganizationRow)))
.scalar_one()
),
"memberships": int(
(await session.execute(select(func.count()).select_from(MembershipRow)))
.scalar_one()
),
"wallets": int(
(await session.execute(select(func.count()).select_from(CreditWalletRow)))
.scalar_one()
),
"users": int(
(await session.execute(select(func.count()).select_from(UserRow)))
.scalar_one()
),
"templates": int(
(await session.execute(select(func.count()).select_from(AgentTemplateRow)))
.scalar_one()
),
}
before = await counts()
await seed_module.seed()
after = await counts()
assert after == before, f"re-seeding changed row counts: {before} -> {after}"
assert after["organizations"] == 1
assert after["memberships"] == 2, "one per seeded account, and no duplicates"
+136
View File
@@ -0,0 +1,136 @@
"""Multi-tenancy: the single most important guarantee in the product.
A member of organization A must never be able to read or mutate anything that
belongs to organization B — not by listing, not by guessing an id.
"""
from __future__ import annotations
from httpx import AsyncClient
from tests.conftest import auth_header, signup
async def _create_org(client: AsyncClient, token: str, name: str) -> dict:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def test_create_organization_makes_caller_the_owner(client: AsyncClient):
token = (await signup(client, "owner-a@example.com"))["access_token"]
org = await _create_org(client, token, "شرکت آلفا")
assert org["role"] == "owner"
assert org["slug"]
assert org["owner_user_id"]
async def test_organization_slug_is_unique_across_tenants(client: AsyncClient):
token_a = (await signup(client, "slug-a@example.com"))["access_token"]
token_b = (await signup(client, "slug-b@example.com"))["access_token"]
first = await _create_org(client, token_a, "My Company")
second = await _create_org(client, token_b, "My Company")
assert first["slug"] != second["slug"]
assert second["slug"].startswith(first["slug"])
async def test_list_only_returns_own_organizations(client: AsyncClient):
token_a = (await signup(client, "list-a@example.com"))["access_token"]
token_b = (await signup(client, "list-b@example.com"))["access_token"]
org_a = await _create_org(client, token_a, "شرکت الف")
await _create_org(client, token_b, "شرکت ب")
listing = await client.get("/api/organizations", headers=auth_header(token_a))
assert listing.status_code == 200
ids = [o["id"] for o in listing.json()]
assert ids == [org_a["id"]]
async def test_foreign_organization_is_not_readable(client: AsyncClient):
token_a = (await signup(client, "iso-a@example.com"))["access_token"]
token_b = (await signup(client, "iso-b@example.com"))["access_token"]
org_a = await _create_org(client, token_a, "سازمان محرمانه")
# B knows A's id but has no membership.
response = await client.get(f"/api/organizations/{org_a['id']}", headers=auth_header(token_b))
assert response.status_code == 404
assert response.json()["error"]["code"] == "not_found"
async def test_foreign_member_list_is_not_readable(client: AsyncClient):
token_a = (await signup(client, "mem-a@example.com"))["access_token"]
token_b = (await signup(client, "mem-b@example.com"))["access_token"]
org_a = await _create_org(client, token_a, "سازمان با اعضا")
response = await client.get(
f"/api/organizations/{org_a['id']}/members", headers=auth_header(token_b)
)
assert response.status_code == 404
async def test_foreign_invite_is_rejected(client: AsyncClient):
token_a = (await signup(client, "inv-a@example.com"))["access_token"]
token_b = (await signup(client, "inv-b@example.com"))["access_token"]
org_a = await _create_org(client, token_a, "سازمان دعوت")
response = await client.post(
f"/api/organizations/{org_a['id']}/invites",
json={"email": "attacker@example.com", "role": "admin"},
headers=auth_header(token_b),
)
assert response.status_code == 404
async def test_owner_can_list_members_and_see_themselves(client: AsyncClient):
token = (await signup(client, "solo@example.com", name="تک‌نفره"))["access_token"]
org = await _create_org(client, token, "تک‌نفره")
response = await client.get(
f"/api/organizations/{org['id']}/members", headers=auth_header(token)
)
assert response.status_code == 200
members = response.json()
assert len(members) == 1
assert members[0]["role"] == "owner"
assert members[0]["email"] == "solo@example.com"
async def test_invite_returns_token_only_to_the_inviter(client: AsyncClient):
token = (await signup(client, "inviter@example.com"))["access_token"]
org = await _create_org(client, token, "دعوت‌کننده")
response = await client.post(
f"/api/organizations/{org['id']}/invites",
json={"email": "newbie@example.com", "role": "member"},
headers=auth_header(token),
)
assert response.status_code == 201
body = response.json()
assert body["invite_token"]
assert body["email"] == "newbie@example.com"
async def test_cannot_invite_as_owner(client: AsyncClient):
token = (await signup(client, "noowner@example.com"))["access_token"]
org = await _create_org(client, token, "بدون مالک دوم")
response = await client.post(
f"/api/organizations/{org['id']}/invites",
json={"email": "wannabe@example.com", "role": "owner"},
headers=auth_header(token),
)
assert response.status_code == 422
async def test_audit_log_records_organization_creation(client: AsyncClient):
token = (await signup(client, "audited@example.com"))["access_token"]
org = await _create_org(client, token, "سازمان دارای audit")
response = await client.get(
f"/api/organizations/{org['id']}/audit", headers=auth_header(token)
)
assert response.status_code == 200
actions = [event["action"] for event in response.json()]
assert "organization.create" in actions
+336
View File
@@ -0,0 +1,336 @@
"""§23 — the standalone workers do real work, and do it idempotently.
The workers are not HTTP requests, so there is no ``get_session`` dependency to
override; they open their own sessions from ``app.workers.base.SessionFactory``.
The autouse fixture below swaps that one name so the workers run against the same
in-memory schema the API fixtures build — which is also the point of the
``app.application.container`` extraction: worker and API compose services
identically, so a worker test is meaningful and not a re-implementation.
What is asserted here is the part that is easy to get wrong and expensive to get
wrong: a task that nobody clicked "run" on still runs, a billing period is not
billed twice, and an abandoned sandbox session is stopped *and* audited.
"""
from __future__ import annotations
from datetime import timedelta
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.domain.base import utcnow
from app.workers import base as worker_base
from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker
from tests.conftest import auth_header, signup
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
@pytest_asyncio.fixture(autouse=True)
async def _workers_use_the_test_database(monkeypatch, session_factory):
"""Point every worker *and the error recorder* at the in-memory database.
The recorder opens its own session by design, so patching the worker session
factory alone would leave it writing failures into the developer's real
``agy_web.db``.
"""
import app.infrastructure.error_log as error_log
monkeypatch.setattr(worker_base, "SessionFactory", session_factory)
monkeypatch.setattr(error_log, "SessionFactory", session_factory)
return session_factory
async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]:
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant."""
token = (await signup(client, email))["access_token"]
headers = auth_header(token)
org_id = (
await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers)
).json()["id"]
project_id = (
await client.post(
f"/api/organizations/{org_id}/projects",
json={"name": "پروژهٔ کارگر"},
headers=headers,
)
).json()["id"]
template = (
await client.get(f"/api/organizations/{org_id}/agent-templates", headers=headers)
).json()[0]
employee_id = (
await client.post(
f"/api/organizations/{org_id}/employees",
json={
"name": "کارمند کارگر",
"role_key": template["role_key"],
"template_id": template["id"],
},
headers=headers,
)
).json()["id"]
return org_id, headers, project_id, employee_id
# -- ai-runtime -------------------------------------------------------------- #
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
"""A task created without pressing "run" must still be picked up."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار بی‌صاحب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
# Nothing has run it yet.
detail = await client.get(
f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers
)
assert detail.json()["status"] in {"new", "triaged"}
assert detail.json()["runs"] == []
outcome = await AiRuntimeWorker().drain()
assert outcome.processed == 1, outcome.summary()
assert outcome.failed == 0, outcome.notes
detail = await client.get(
f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers
)
assert detail.json()["status"] == "done"
runs = detail.json()["runs"]
assert len(runs) == 1
assert runs[0]["status"] == "completed"
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
"""A closed task must not be re-run — re-running would double-bill."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌باره",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
first = await AiRuntimeWorker().drain()
second = await AiRuntimeWorker().drain()
assert first.processed == 1
assert second.processed == 0, "a done task is not runnable"
assert second.touched == 0, second.summary()
# -- billing-worker ---------------------------------------------------------- #
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
"""Usage in the period must become exactly one draft invoice."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار قابل صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered"
end = utcnow()
outcome = await BillingWorker().roll_up(
period_start=end - timedelta(days=30), period_end=end
)
assert outcome.processed == 1, outcome.summary()
assert outcome.failed == 0, outcome.notes
invoices = await client.get(
f"/api/organizations/{org_id}/billing/invoices", headers=headers
)
assert invoices.status_code == 200, invoices.text
body = invoices.json()
assert len(body) == 1, body
assert body[0]["status"] == "draft"
assert body[0]["amount"] > 0, "the invoice must carry the ledger's total"
async def test_billing_roll_up_is_idempotent(client: AsyncClient):
"""Two passes over the same period must not bill twice."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌بار صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
await AiRuntimeWorker().drain()
end = utcnow()
start = end - timedelta(days=30)
worker = BillingWorker()
first = await worker.roll_up(period_start=start, period_end=end)
second = await worker.roll_up(period_start=start, period_end=end)
assert first.processed == 1
assert second.processed == 0, "the period was already invoiced"
assert second.skipped >= 1
invoices = (
await client.get(f"/api/organizations/{org_id}/billing/invoices", headers=headers)
).json()
assert len(invoices) == 1, "exactly one invoice for one period"
async def test_billing_roll_up_skips_an_organization_with_no_usage(client: AsyncClient):
"""No usage means no $0 draft littering the invoice table."""
org_id, headers, _, _ = await _make_org_with_employee(
client, "worker-billing-empty@example.com"
)
end = utcnow()
outcome = await BillingWorker().roll_up(
period_start=end - timedelta(days=30), period_end=end
)
assert outcome.processed == 0
assert outcome.skipped >= 1
invoices = (
await client.get(f"/api/organizations/{org_id}/billing/invoices", headers=headers)
).json()
assert invoices == []
@pytest.mark.parametrize(
"delta",
[timedelta(0), timedelta(minutes=-5), timedelta(minutes=30)],
)
async def test_billing_roll_up_refuses_a_nonsense_period(delta: timedelta):
"""A period that is empty, backwards or absurdly short is a bug, not a bill."""
end = utcnow()
with pytest.raises(ValueError):
await BillingWorker().roll_up(period_start=end - delta, period_end=end)
# -- sandbox-gateway --------------------------------------------------------- #
async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: AsyncClient):
"""An abandoned session must be stopped, and the stop must be auditable."""
org_id, headers, project_id, _ = await _make_org_with_employee(
client, "worker-gateway@example.com"
)
template_id = (
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
).json()[0]["id"]
workspace_id = (
await client.post(
f"/api/organizations/{org_id}/sandbox/workspaces",
json={"project_id": project_id, "template_id": template_id},
headers=headers,
)
).json()["id"]
session_id = (
await client.post(
f"/api/organizations/{org_id}/sandbox/sessions",
json={"workspace_id": workspace_id},
headers=headers,
)
).json()["id"]
# Backdate the session so it looks abandoned. The reaper will not touch a
# session that was active a moment ago, which is the behaviour we want.
from app.infrastructure.db.models import SandboxSessionRow
async with worker_base.SessionFactory() as session:
row = await session.get(SandboxSessionRow, session_id)
assert row is not None
row.last_active_at = utcnow() - timedelta(hours=6)
await session.commit()
outcome = await SandboxGatewayWorker().reap(idle_minutes=120)
assert outcome.processed == 1, outcome.summary()
assert outcome.failed == 0, outcome.notes
# The session row itself must now read ``stopped``.
async with worker_base.SessionFactory() as session:
row = await session.get(SandboxSessionRow, session_id)
assert row is not None
assert row.status == "stopped"
assert row.stopped_at is not None
audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers)
assert audit.status_code == 200, audit.text
reaps = [e for e in audit.json() if e["action"] == "sandbox.reap"]
assert reaps, "the platform stopping a sandbox must be audited"
assert reaps[0]["target_id"] == session_id
async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient):
"""A session that was just used must not be reaped."""
org_id, headers, project_id, _ = await _make_org_with_employee(
client, "worker-gateway-fresh@example.com"
)
template_id = (
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
).json()[0]["id"]
workspace_id = (
await client.post(
f"/api/organizations/{org_id}/sandbox/workspaces",
json={"project_id": project_id, "template_id": template_id},
headers=headers,
)
).json()["id"]
await client.post(
f"/api/organizations/{org_id}/sandbox/sessions",
json={"workspace_id": workspace_id},
headers=headers,
)
outcome = await SandboxGatewayWorker().reap(idle_minutes=120)
assert outcome.processed == 0, outcome.summary()
assert outcome.skipped == 1
def test_gateway_policy_report_names_no_secret():
"""The startup report must describe the envelope without leaking a key."""
outcome = SandboxGatewayWorker().policy_report()
assert outcome.notes, "the report must say something"
lowered = " ".join(outcome.notes).lower()
for forbidden in ("api_key", "token", "secret", "password", "daytona_api", "e2b_api"):
assert forbidden not in lowered, f"{forbidden} leaked into the policy report"
assert "deny_all" in lowered, "the default egress policy must be stated"
+379
View File
@@ -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() == []