نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,379 @@
|
||||
"""AI Workforce: employees, runs, tool permissions and approvals.
|
||||
|
||||
The invariants these tests defend:
|
||||
|
||||
* a customer never sees a model, provider or routing *mode* — only a named policy
|
||||
* tool permission is deny-by-default, so a tool the platform has never heard of
|
||||
is not silently allowed
|
||||
* every run leaves a timeline, because that timeline is the audit trail
|
||||
* approving is a human decision; nothing dangerous executes without it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _platform_seed(seeded):
|
||||
"""Every test here needs the platform-level seed rows.
|
||||
|
||||
Agent templates and routing policies are installed by ``app/seed.py`` and are
|
||||
not owned by any organization, so a test that creates an employee would
|
||||
otherwise find no template to start from.
|
||||
"""
|
||||
return seeded
|
||||
|
||||
|
||||
async def _org(client: AsyncClient, token: str, name: str = "شرکت نیروی کار") -> str:
|
||||
response = await client.post(
|
||||
"/api/organizations", json={"name": name}, headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
async def _employee(
|
||||
client: AsyncClient, token: str, org_id: str, *, name: str = "برنامهنویس یک"
|
||||
) -> dict:
|
||||
"""Create an employee from the first available template."""
|
||||
templates = await client.get(
|
||||
f"/api/organizations/{org_id}/agent-templates", headers=auth_header(token)
|
||||
)
|
||||
assert templates.status_code == 200, templates.text
|
||||
template = templates.json()[0]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/organizations/{org_id}/employees",
|
||||
json={"name": name, "role_key": template["role_key"], "template_id": template["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_creating_an_employee_seeds_a_prompt_and_tool_permissions(client: AsyncClient):
|
||||
"""A template must produce a usable employee, not an empty shell."""
|
||||
token = (await signup(client, "wf-seed@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
detail = await client.get(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
body = detail.json()
|
||||
|
||||
# Prompt version 1 exists and carries the template's default prompt.
|
||||
assert len(body["prompt_versions"]) >= 1
|
||||
assert body["prompt_versions"][0]["version"] == 1
|
||||
assert body["prompt_versions"][0]["content"].strip()
|
||||
|
||||
# Tool permissions were seeded from the template.
|
||||
assert isinstance(body["tool_permissions"], list)
|
||||
assert all("tool_key" in p and "allowed" in p for p in body["tool_permissions"])
|
||||
|
||||
|
||||
async def test_employee_payload_never_leaks_model_identity(client: AsyncClient):
|
||||
"""The customer buys capacity, not a model. Nothing may name one."""
|
||||
token = (await signup(client, "wf-secret@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
detail = await client.get(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
||||
)
|
||||
payload = json.dumps(detail.json()).lower()
|
||||
for forbidden in ("provider", "api_key", "internal_name", "openai", "anthropic"):
|
||||
assert forbidden not in payload, f"{forbidden} leaked in employee payload"
|
||||
|
||||
|
||||
async def test_routing_choices_expose_a_name_but_not_the_mode(client: AsyncClient):
|
||||
"""`mode` is an internal routing hint and must not be serialised."""
|
||||
token = (await signup(client, "wf-routing@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/organizations/{org_id}/routing-choices", headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
choices = response.json()
|
||||
assert choices, "seed data should provide routing policies"
|
||||
|
||||
for choice in choices:
|
||||
assert set(choice) <= {"id", "name", "description"}, choice
|
||||
assert "mode" not in choice
|
||||
|
||||
|
||||
async def test_prompt_version_is_appended_with_an_incrementing_number(client: AsyncClient):
|
||||
token = (await signup(client, "wf-prompt@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
for content in ("نسخهٔ دوم پرامپت", "نسخهٔ سوم پرامپت"):
|
||||
response = await client.post(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}/prompt-versions",
|
||||
json={"content": content},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code in (200, 201), response.text
|
||||
|
||||
detail = await client.get(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token)
|
||||
)
|
||||
versions = sorted(v["version"] for v in detail.json()["prompt_versions"])
|
||||
assert versions == [1, 2, 3]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool permissions — deny by default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unknown_tool_is_denied_by_default(client: AsyncClient):
|
||||
"""The platform must fail closed for a tool it has never heard of.
|
||||
|
||||
A permission row for a tool that does not exist would be a silent no-op that
|
||||
hides a typo, so the request is rejected outright (422) rather than stored.
|
||||
"""
|
||||
token = (await signup(client, "wf-tools@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
# Explicitly grant a tool that is not in the catalog.
|
||||
response = await client.put(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}/tools/evil.exfiltrate",
|
||||
json={"allowed": False},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
assert response.json()["error"]["code"] == "validation_error"
|
||||
|
||||
# And nothing was persisted: the employee's permission set stays clean.
|
||||
detail = await client.get(
|
||||
f"/api/organizations/{org_id}/employees/{employee['id']}",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
granted = {p["tool_key"] for p in detail.json()["tool_permissions"] if p["allowed"]}
|
||||
assert "evil.exfiltrate" not in granted, "an unknown tool must never be granted"
|
||||
|
||||
|
||||
async def test_tool_permission_can_be_toggled(client: AsyncClient):
|
||||
token = (await signup(client, "wf-toggle@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
base = f"/api/organizations/{org_id}/employees/{employee['id']}/tools"
|
||||
|
||||
granted = await client.put(
|
||||
f"{base}/knowledge.search", json={"allowed": True}, headers=auth_header(token)
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
assert granted.json()["allowed"] is True
|
||||
|
||||
revoked = await client.put(
|
||||
f"{base}/knowledge.search", json={"allowed": False}, headers=auth_header(token)
|
||||
)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
assert revoked.json()["allowed"] is False
|
||||
|
||||
|
||||
async def test_tools_catalog_requires_no_secrets(client: AsyncClient):
|
||||
"""The catalog describes capabilities; it must not carry endpoints or keys."""
|
||||
token = (await signup(client, "wf-catalog@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/organizations/{org_id}/tools", headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = json.dumps(response.json()).lower()
|
||||
for forbidden in ("api_key", "secret", "token", "password"):
|
||||
assert forbidden not in payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks and runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
|
||||
"""Every run must be reconstructable from its steps."""
|
||||
token = (await signup(client, "wf-run@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={
|
||||
"title": "افزودن تست برای ماژول پرداخت",
|
||||
"description": "پوشش تست واحد برای محاسبهٔ تخفیف",
|
||||
"assigned_employee_id": employee["id"],
|
||||
},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert task.status_code == 201, task.text
|
||||
task_id = task.json()["id"]
|
||||
|
||||
run = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
||||
json={"employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert run.status_code in (200, 201), run.text
|
||||
run_body = run.json()
|
||||
|
||||
assert run_body["task_id"] == task_id
|
||||
assert run_body["status"] in {"queued", "planning", "running", "completed"}
|
||||
assert len(run_body["steps"]) >= 1, "a run must record at least one step"
|
||||
assert [s["index"] for s in run_body["steps"]] == sorted(
|
||||
s["index"] for s in run_body["steps"]
|
||||
)
|
||||
|
||||
|
||||
async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
|
||||
token = (await signup(client, "wf-runsecret@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
run = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
||||
json={"employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
payload = json.dumps(run.json()).lower()
|
||||
for forbidden in ("provider", "api_key", "internal_name", "model"):
|
||||
assert forbidden not in payload, f"{forbidden} leaked in run payload"
|
||||
|
||||
|
||||
async def test_task_detail_lists_its_runs(client: AsyncClient):
|
||||
token = (await signup(client, "wf-taskdetail@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
|
||||
run = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
||||
json={"employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert run.status_code == 201, run.text
|
||||
|
||||
detail = await client.get(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}", headers=auth_header(token)
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
runs = detail.json()["runs"]
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["id"] == run.json()["id"]
|
||||
assert runs[0]["task_id"] == task_id
|
||||
|
||||
|
||||
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient):
|
||||
"""A successful run closes its task; re-running it must be refused, not
|
||||
silently create a second run against a task that is already done."""
|
||||
token = (await signup(client, "wf-closedtask@example.com"))["access_token"]
|
||||
org_id = await _org(client, token)
|
||||
employee = await _employee(client, token, org_id)
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار بستهشده", "assigned_employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
|
||||
first = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
||||
json={"employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
assert first.json()["status"] == "completed", first.text
|
||||
|
||||
second = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
|
||||
json={"employee_id": employee["id"]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert second.status_code == 409, second.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_employee_of_another_tenant_is_not_readable(client: AsyncClient):
|
||||
token_a = (await signup(client, "wf-iso-a@example.com"))["access_token"]
|
||||
token_b = (await signup(client, "wf-iso-b@example.com"))["access_token"]
|
||||
org_a = await _org(client, token_a, "سازمان الف")
|
||||
employee = await _employee(client, token_a, org_a)
|
||||
|
||||
# B is authenticated and knows the employee id, but has no membership in A.
|
||||
response = await client.get(
|
||||
f"/api/organizations/{org_a}/employees/{employee['id']}", headers=auth_header(token_b)
|
||||
)
|
||||
# 404, not 403: the API must not confirm that the employee exists.
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
async def test_task_of_another_tenant_is_not_readable(client: AsyncClient):
|
||||
token_a = (await signup(client, "wf-tiso-a@example.com"))["access_token"]
|
||||
token_b = (await signup(client, "wf-tiso-b@example.com"))["access_token"]
|
||||
org_a = await _org(client, token_a, "سازمان ب")
|
||||
|
||||
task = await client.post(
|
||||
f"/api/organizations/{org_a}/tasks",
|
||||
json={"title": "کار محرمانهٔ الف"},
|
||||
headers=auth_header(token_a),
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/organizations/{org_a}/tasks/{task_id}", headers=auth_header(token_b)
|
||||
)
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
async def test_pending_approvals_are_org_scoped(client: AsyncClient):
|
||||
"""A tenant must never see another tenant's approval queue."""
|
||||
token_a = (await signup(client, "wf-appr-a@example.com"))["access_token"]
|
||||
token_b = (await signup(client, "wf-appr-b@example.com"))["access_token"]
|
||||
org_a = await _org(client, token_a, "سازمان پ")
|
||||
org_b = await _org(client, token_b, "سازمان ت")
|
||||
|
||||
response = await client.get(
|
||||
f"/api/organizations/{org_b}/approvals/pending", headers=auth_header(token_a)
|
||||
)
|
||||
# Not a member of B at all.
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
own = await client.get(
|
||||
f"/api/organizations/{org_a}/approvals/pending", headers=auth_header(token_a)
|
||||
)
|
||||
assert own.status_code == 200, own.text
|
||||
assert own.json() == []
|
||||
Reference in New Issue
Block a user