feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+32 -4
View File
@@ -6,6 +6,7 @@ import pytest
from sqlalchemy.exc import IntegrityError
import app.application.container as container
import app.application.container_org as container_org
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.deploy_target import (
DeployTargetSpec,
@@ -109,7 +110,16 @@ async def test_app_profile_repository_is_tenant_scoped(session_factory):
@pytest.mark.asyncio
async def test_default_model_grants_return_active_models_with_tiers(session_factory):
async def test_granted_models_carry_tiers_and_need_an_owner_grant(session_factory):
"""The real grant service (Phase 3) replaced the "all active models" stub.
The foundation version asserted the placeholder wiring (every active model
usable by every organization). The behaviour the plan changes on purpose is
exactly that: an organization now sees a model only after the owner granted
it. The assertions below are strictly stronger — they still pin the tier
labels, and additionally pin the two switches (the model must be active
*and* granted) plus the per-organization scope.
"""
async with session_factory() as session:
provider = AiProviderRow(id=new_id(), name="agy-test")
session.add(provider)
@@ -135,10 +145,28 @@ async def test_default_model_grants_return_active_models_with_tiers(session_fact
grants = container.build_model_grants(session)
assert isinstance(grants, ModelGrantPort)
granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
org_id, _ = await _org_with_user(session, "granted")
other_id, _ = await _org_with_user(session, "untouched")
# Nothing is usable until the owner grants it.
assert await grants.granted_models(organization_id=org_id) == []
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
# ``container.py`` is locked and re-exports only ``build_model_grants``;
# the typed builder lives in the workstream's own container.
service = container_org.build_model_grant_service(session)
await service.grant_model(organization_id=org_id, ai_model_id="m-strong")
await service.grant_model(organization_id=org_id, ai_model_id="m-fast")
granted = {
g.ai_model_id: g.tier
for g in await grants.granted_models(organization_id=org_id)
}
assert granted == {"m-strong": "strong", "m-fast": "fast"}
assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
assert await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
# Inactive in the catalogue: never granted, whatever the grant table says.
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-off")
# Another organization is unaffected by this one's grants.
assert await grants.granted_models(organization_id=other_id) == []
def test_tier_for_never_uses_names():
+532
View File
@@ -0,0 +1,532 @@
"""MCP gateway: scoped tokens, tool calls, and what they may never do.
The invariants under test:
* a token from organization A can never read organization B — not by asking, not
by passing an ``organization_id``, not by guessing an id;
* a scope is a floor: a token minted ``tasks:read`` cannot create a task, and a
token minted ``memory:write`` cannot write a *rule* (only ``fact`` and
``preference``);
* a revoked or expired token is indistinguishable from an unknown one;
* the rate limit refuses the 61st call in a minute;
* every call leaves an audit row carrying the token prefix and the tool;
* no secret appears in any tool output — the reflection redacts, and a token
never is part of a payload.
The tests drive the gateway directly (``McpGateway.call``), which is the layer
the SDK server is a thin adapter around: the transport is the SDK's problem, the
rules are ours.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.services.mcp_service import (
PREFIX_DISPLAY_CHARS,
TOKEN_PREFIX,
McpService,
TokenRateLimiter,
)
from app.core.config import settings
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
UserRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
)
await session.commit()
return org.id, user.id
async def _gateway(session_factory, *, limit: int | None = None) -> McpGateway:
return McpGateway(
session_factory=session_factory,
rate_limiter=TokenRateLimiter(limit_per_minute=limit),
)
async def _token(
session_factory, gateway: McpGateway, org_id: str, *, scopes: list[str],
user_id: str | None = None,
) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id, name="agent", scopes=scopes,
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def _call(gateway: McpGateway, token: str, tool: str, **arguments):
return await gateway.call(tool=tool, arguments=arguments, token=token)
# ---------------------------------------------------------------------------
# Token lifecycle
# ---------------------------------------------------------------------------
async def test_a_token_is_shown_once_and_stored_only_as_a_hash(session_factory):
org_id, user_id = await _org(session_factory, "issue")
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id,
name="agent-1",
scopes=["tasks:read", "tasks:write"],
created_by_user_id=user_id,
ttl_days=30,
)
await session.commit()
raw = issued.token
rows = (await session.execute(select(McpTokenRow))).scalars().all()
assert len(rows) == 1
# Only the hash and the display prefix exist; the token itself is gone.
assert all(raw not in (row.token_hash or "") for row in rows)
assert all(row.prefix == raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS] for row in rows)
assert issued.record.scopes == ["tasks:read", "tasks:write"]
async def test_unknown_revoked_and_expired_are_all_the_same_answer(session_factory):
org_id, _ = await _org(session_factory, "verify")
gateway = await _gateway(session_factory)
raw = await _token(session_factory, gateway, org_id, scopes=["tasks:read"])
async with session_factory() as session:
service = McpService(session)
identity = await service.verify(raw)
assert identity is not None and identity.organization_id == org_id
# A token for another org id is not this org's business.
assert (await service.verify("smo_" + "x" * 24)) is None
row = (await session.execute(select(McpTokenRow))).scalar_one()
row.revoked_at = utcnow()
await session.commit()
assert await service.verify(raw) is None
row.revoked_at = None
from datetime import UTC, datetime, timedelta
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await session.commit()
assert await service.verify(raw) is None
async def test_unknown_scopes_are_refused_at_mint_time(session_factory):
org_id, _ = await _org(session_factory, "scopes")
async with session_factory() as session:
with pytest.raises(ValidationError):
await McpService(session).issue(
organization_id=org_id, name="x", scopes=["root:everything"]
)
with pytest.raises(ValidationError):
await McpService(session).issue(organization_id=org_id, name="x", scopes=[])
# ---------------------------------------------------------------------------
# Tenant isolation and scope enforcement
# ---------------------------------------------------------------------------
async def test_one_organizations_token_cannot_read_anothers_app(session_factory, seeded):
org_a, _ = await _org(session_factory, "mcp-a")
org_b, user_b = await _org(session_factory, "mcp-b")
gateway = await _gateway(session_factory)
token_a = await _token(
session_factory, gateway, org_a, scopes=["app:read", "tasks:read"]
)
# org A has no project yet, so the honest answer is a refusal, not org B's
# data. The important part is what it is *not*: never org B's app.
result = await _call(gateway, token_a, "app_get_profile")
assert result.ok is False
# Even with the id handed to it, the tool refuses: identity comes from the token.
result = await _call(gateway, token_a, "tasks_list")
assert result.ok is True
assert result.payload == {"tasks": []}
# A token minted for B does not exist for A either.
other = await _token(session_factory, gateway, org_b, scopes=["app:read"], user_id=user_b)
assert (await _call(gateway, other, "app_get_profile")).ok is False
async def test_scope_enforcement_is_per_tool(session_factory, seeded):
org_id, user_id = await _org(session_factory, "scope-org")
gateway = await _gateway(session_factory)
reader = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
# Reading is allowed.
listed = await _call(gateway, reader, "tasks_list")
assert listed.ok is True
# Writing is not: the scope is missing, and the refusal names the reason.
created = await _call(gateway, reader, "tasks_create", title="کار تازه")
assert created.ok is False
assert created.code == "scope_denied"
assert "دسترسی" in (created.error or "")
# A refused write leaves nothing behind.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
rows = (await session.execute(select(TaskRow))).scalars().all()
assert rows == []
async def test_memory_write_through_a_token_is_only_fact_or_preference(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "mem-write")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
ok = await _call(
gateway, token, "memory_write", kind="fact", content="پورت اپ ۸۰۰۰ است."
)
assert ok.ok is True
preference = await _call(
gateway, token, "memory_write", kind="preference", content="ترجیح: فارسی بنویس."
)
assert preference.ok is True
# A rule is not an agent's to set, and a lesson is the engine's to learn.
refused_rule = await _call(
gateway, token, "memory_write", kind="rule", content="همیشه تست بنویس."
)
assert refused_rule.ok is False
refused_lesson = await _call(
gateway, token, "memory_write", kind="lesson", content="درس تازه."
)
assert refused_lesson.ok is False
# What it wrote is searchable, and what it may not write is not there.
found = await _call(gateway, token, "memory_search", query="پورت")
assert found.ok is True
contents = [m["content"] for m in found.payload["memories"]]
assert any("۸۰۰۰" in content for content in contents)
assert not any("همیشه تست بنویس" in content for content in contents)
async def test_a_secret_written_through_a_token_never_comes_back(session_factory, seeded):
org_id, user_id = await _org(session_factory, "mem-secret")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
await _call(
gateway, token, "memory_write", kind="fact",
content="کلید CI استفاده‌شده ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است.",
)
found = await _call(gateway, token, "memory_search", query="کلید")
assert found.ok is True
text = found.to_json()
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in text
async def test_knowledge_and_pipeline_tools(session_factory, seeded):
org_id, user_id = await _org(session_factory, "knowledge-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "delivery:read"],
user_id=user_id,
)
# No sources yet: an empty result, not an error.
empty = await _call(gateway, token, "knowledge_search", query="هرچی")
assert empty.ok is True
assert empty.payload["results"] == []
# Delivery is another workstream's table; the tool exists and says so.
status = await _call(gateway, token, "pipeline_status")
assert status.ok is True
assert status.payload["available"] is False
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:write", "tasks:read"],
user_id=user_id,
)
created = await _call(
gateway, token, "tasks_create", title="پیاده‌سازی ورود", description="صفحهٔ ورود"
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
fetched = await _call(gateway, token, "tasks_get", task_id=task_id)
assert fetched.ok is True
assert fetched.payload["task"]["title"] == "پیاده‌سازی ورود"
# The write is attributed to the administrator who minted the token: a
# delegated key never erases who holds it.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
row = await session.get(TaskRow, task_id)
assert row is not None
assert row.created_by_user_id == user_id
# …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other")
other_token = await _token(
session_factory, gateway, other_org, scopes=["tasks:read", "tasks:write"]
)
foreign = await _call(gateway, other_token, "tasks_get", task_id=task_id)
assert foreign.ok is False
async def test_a_bad_token_or_a_disabled_switch_is_refused_everywhere(session_factory, seeded):
org_id, user_id = await _org(session_factory, "off-switch")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
assert (await _call(gateway, "smo_wrong", "tasks_list")).ok is False
assert (await _call(gateway, "", "tasks_list")).ok is False
assert (await _call(gateway, token, "no_such_tool")).ok is False
original = settings.mcp_gateway_enabled
settings.mcp_gateway_enabled = False
try:
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "mcp_disabled"
finally:
settings.mcp_gateway_enabled = original
# The per-organization switch is the owner's, not the token's.
async with session_factory() as session:
from app.application.container_org import build_model_grant_service
await build_model_grant_service(session).set_mcp_enabled(
organization_id=org_id, enabled=False, actor_user_id=user_id
)
await session.commit()
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "org_disabled"
async def test_the_rate_limit_refuses_the_sixty_first_call(session_factory, seeded):
org_id, user_id = await _org(session_factory, "rate")
gateway = await _gateway(session_factory, limit=5)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
codes = [
(await _call(gateway, token, "tasks_list")).ok for _ in range(7)
]
assert codes == [True] * 5 + [False, False]
limited = await _call(gateway, token, "tasks_list")
assert limited.code == "rate_limited"
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
async def test_every_call_leaves_an_audit_row_with_the_prefix(session_factory, seeded):
org_id, user_id = await _org(session_factory, "audit")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
await _call(gateway, token, "tasks_list")
await _call(gateway, token, "tasks_create", title="رد شده")
async with session_factory() as session:
rows = (
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.organization_id == org_id,
AuditEventRow.action == "mcp.tool_call",
)
)
)
.scalars()
.all()
)
assert len(rows) == 2
by_tool = {row.target_id: row for row in rows}
assert set(by_tool) == {"tasks_list", "tasks_create"}
# The prefix identifies the token; the token itself never appears.
prefix = token[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert all(row.metadata_json["prefix"] == prefix for row in rows)
assert all(token not in str(row.metadata_json) for row in rows)
assert by_tool["tasks_list"].metadata_json["ok"] is True
assert by_tool["tasks_create"].metadata_json["ok"] is False
assert by_tool["tasks_create"].metadata_json["error"]
# ---------------------------------------------------------------------------
# HTTP surface: tokens and the owner's switches
# ---------------------------------------------------------------------------
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
org = await client.post(
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
)
assert org.status_code == 201, org.text
return {"token": body["access_token"], "org_id": org.json()["id"]}
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
invite = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(owner_token),
)
assert invite.status_code == 201, invite.text
account = await signup(client, email, name=role)
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(account["access_token"]),
)
assert accepted.status_code == 200, accepted.text
return account["access_token"]
async def test_only_an_admin_mints_tokens_and_only_the_owner_switches_the_gateway(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
):
org = await _owner_with_org(client, "mcp-org")
viewer = await _member(client, org["token"], org["org_id"], "v@example.com", "viewer")
admin = await _member(client, org["token"], org["org_id"], "a@example.com", "admin")
# A viewer reads the list but cannot mint.
listing = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(viewer)
)
assert listing.status_code == 200, listing.text
assert listing.json() == []
refused = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "nope", "scopes": ["tasks:read"]},
headers=auth_header(viewer),
)
assert refused.status_code == 403, refused.text
# The owner's switches are the owner's.
not_owner = await client.get("/api/admin/mcp", headers=auth_header(manager_token))
assert not_owner.status_code == 403
anonymous = await client.get("/api/admin/mcp")
assert anonymous.status_code == 401
# An admin mints one, sees the raw token once, and it never returns.
created = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "agent-1", "scopes": ["tasks:read", "tasks:write"]},
headers=auth_header(admin),
)
assert created.status_code == 201, created.text
body = created.json()
assert body["token"].startswith("smo_")
assert body["prefix"] == body["token"][: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert body["scopes"] == ["tasks:read", "tasks:write"]
assert body["status"] == "active"
again = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(admin)
)
assert again.status_code == 200, again.text
assert "token" not in again.json()[0]
assert again.json()[0]["prefix"] == body["prefix"]
# An unknown scope is refused rather than silently dropped.
bad_scope = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "bad", "scopes": ["root:all"]},
headers=auth_header(admin),
)
assert bad_scope.status_code == 422, bad_scope.text
# Revoke, then revoking again is a 404 — the same idempotence rule as grants.
revoked = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert revoked.status_code == 200, revoked.text
twice = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert twice.status_code == 404
# The owner sees the gateway state and may switch it and one organization.
state = await client.get("/api/admin/mcp", headers=auth_header(platform_owner_token))
assert state.status_code == 200, state.text
assert state.json()["gateway_enabled"] is True
assert any(
org_row["organization_id"] == org["org_id"]
for org_row in state.json()["organizations"]
)
switched = await client.put(
"/api/admin/mcp",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert switched.status_code == 200, switched.text
assert switched.json()["gateway_enabled"] is False
org_switch = await client.put(
f"/api/admin/mcp/organizations/{org['org_id']}",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert org_switch.status_code == 200, org_switch.text
assert org_switch.json()["mcp_enabled"] is False
settings_view = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens/settings",
headers=auth_header(admin),
)
assert settings_view.json()["mcp_enabled"] is False
assert settings_view.json()["gateway_enabled"] is False
assert "tasks:read" in settings_view.json()["scopes"]
+647
View File
@@ -0,0 +1,647 @@
"""Org memory: what an organization learns about itself.
The invariants under test:
* teaching the same thing twice teaches nothing (dedupe by hash, corrections by
key);
* memory is capped and evicts the oldest *unpinned lesson* first — never a rule;
* one task may write at most three memories;
* the context block respects its budget, leads with pinned rules, and filters by
role (only code-reading roles see the code map; only the manager sees
preferences);
* a reflection that fails never raises into the engine;
* nothing secret is ever stored, and no organization ever sees another's memory.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_memory, build_memory_service
from app.application.ports.memory import TaskOutcome
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.domain.memory.models import (
CONTENT_MAX_CHARS,
KEY_MAX_CHARS,
MAX_WRITES_PER_TASK,
MemoryKind,
MemorySource,
content_hash,
lesson_expiry,
normalise,
tokenise,
validate_content,
validate_key,
visible_kinds,
)
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
TaskRow,
UsageEventRow,
UserRow,
)
from app.infrastructure.db.models_org import OrgMemoryRow
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _task(session_factory, org_id: str, task_id: str) -> str:
"""A real task row: ``evidence_task_id`` is a real reference, not a string."""
async with session_factory() as session:
session.add(
TaskRow(id=task_id, organization_id=org_id, title=f"کار {task_id}")
)
await session.commit()
return task_id
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
"""An organization with one member, written directly to the database."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
)
await session.commit()
return org.id, user.id
class _ShapedProvider:
"""A provider that answers the reflection prompt in the template it asks for."""
def __init__(self, answer: str) -> None:
self.answer = answer
self.calls = 0
async def complete(self, **_kwargs):
from app.application.ports import ModelResponse
self.calls += 1
return ModelResponse(
content=self.answer,
model_internal_name="fake",
input_tokens=40,
output_tokens=12,
)
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=True, detail="shaped fake")
class _BrokenProvider:
"""A provider that fails exactly like a network outage would."""
async def complete(self, **_kwargs):
raise RuntimeError("provider is unreachable: token=super-secret-token")
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=False, detail="down")
@asynccontextmanager
async def _service(session_factory, provider):
"""A memory service on its own session, committed when the block exits."""
session = session_factory()
try:
service = build_memory_service(session, provider=provider)
yield service
await session.commit()
finally:
await session.close()
# ---------------------------------------------------------------------------
# Domain rules
# ---------------------------------------------------------------------------
def test_normalisation_and_dedupe_hash():
assert normalise(" the API runs \n on 8000 ") == "the API runs on 8000"
assert content_hash("The API runs on 8000") == content_hash(" the api runs on 8000 ")
assert content_hash("the API runs on 8000") != content_hash("the API runs on 8001")
def test_limits_are_enforced_in_the_domain():
assert len(validate_key("k" * KEY_MAX_CHARS)) == KEY_MAX_CHARS
with pytest.raises(ValidationError):
validate_key("k" * (KEY_MAX_CHARS + 1))
assert validate_content(" x ") == "x"
with pytest.raises(ValidationError):
validate_content("x" * (CONTENT_MAX_CHARS + 1))
def test_role_filtering_of_kinds():
# A reviewer reads the code map; support does not.
assert MemoryKind.CODE_MAP in visible_kinds("reviewer")
assert MemoryKind.CODE_MAP not in visible_kinds("support")
assert MemoryKind.CODE_MAP in visible_kinds("developer")
# Preferences belong to the manager alone.
assert MemoryKind.PREFERENCE in visible_kinds("manager")
assert MemoryKind.PREFERENCE not in visible_kinds("developer")
# Rules and lessons are everyone's.
for role in ("manager", "developer", "support", "researcher"):
assert visible_kinds(role) >= {MemoryKind.RULE, MemoryKind.LESSON}
def test_lessons_expire_unless_pinned_or_confirmed():
assert lesson_expiry(pinned=False, confirmed=False) is not None
assert lesson_expiry(pinned=True, confirmed=False) is None
assert lesson_expiry(pinned=False, confirmed=True) is None
def test_tokenise_drops_stop_words():
words = tokenise("the API and the manager را")
assert "api" in words and "manager" in words
assert "the" not in words and "را" not in words
# ---------------------------------------------------------------------------
# record: dedupe, correction, cap, task ceiling, redaction
# ---------------------------------------------------------------------------
async def test_recording_the_same_thing_twice_updates_one_memory(session_factory):
org_id, _ = await _org(session_factory, "dedupe")
async with _service(session_factory, _ShapedProvider("")) as service:
first = await service.record(
organization_id=org_id, kind="rule", content="همیشه تست بنویس",
source="user",
)
second = await service.record(
organization_id=org_id, kind="rule", content=" همیشه تست بنویس ",
source="user",
)
assert first == second
async with session_factory() as session:
rows = (
(await session.execute(select(OrgMemoryRow))).scalars().all()
)
assert len(rows) == 1
async def test_a_key_corrects_instead_of_duplicating(session_factory):
org_id, _ = await _org(session_factory, "keyed")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="fact", content="پورت ۸۰۰۰ است",
source="user", key="app_port",
)
memory_id = await service.record(
organization_id=org_id, kind="fact", content="پورت ۸۰۰۱ است",
source="user", key="app_port",
)
memory = await service.get(org_id, memory_id)
assert memory.content == "پورت ۸۰۰۱ است"
memories = await service.list(org_id)
assert len(memories) == 1
async def test_the_cap_evicts_the_oldest_unpinned_lesson_first(session_factory, monkeypatch):
monkeypatch.setattr(settings, "memory_org_char_cap", 200)
org_id, _ = await _org(session_factory, "capped")
await _task(session_factory, org_id, "t-pinned")
async with _service(session_factory, _ShapedProvider("")) as service:
# A pinned lesson survives; the rest are evicted oldest-first.
pinned = await service.record(
organization_id=org_id, kind="lesson", content="پ" * 40,
source="reflection", evidence_task_id="t-pinned",
)
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
for index in range(6):
await _task(session_factory, org_id, f"t-{index}")
await service.record(
organization_id=org_id, kind="lesson", content=f"درس {index}" + "ی" * 30,
source="reflection", evidence_task_id=f"t-{index}",
)
memories = await service.list(org_id)
# Budget spent, and whatever fell out is the oldest, never the pinned one.
assert (await service.stats(org_id))["chars"] <= 200
ids = {m.id for m in memories}
assert pinned in ids
# Only lessons are evictable, so a rule or a preference is never the
# victim — and the budget is honoured.
assert all(m.kind is MemoryKind.LESSON for m in memories)
async def test_one_task_may_write_at_most_three_memories(session_factory):
org_id, _ = await _org(session_factory, "three-writes")
await _task(session_factory, org_id, "task-1")
async with _service(session_factory, _ShapedProvider("")) as service:
written = [
await service.record(
organization_id=org_id, kind="lesson", content=f"درس شمارهٔ {index}",
source="reflection", evidence_task_id="task-1",
)
for index in range(MAX_WRITES_PER_TASK + 2)
]
assert written[:MAX_WRITES_PER_TASK]
# The 4th write is refused — ``None``, not an exception.
assert written[MAX_WRITES_PER_TASK] is None
assert written[MAX_WRITES_PER_TASK + 1] is None
rows = await service.list(org_id)
assert len(rows) == MAX_WRITES_PER_TASK
async def test_nothing_secret_is_ever_stored(session_factory):
org_id, _ = await _org(session_factory, "redacted")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="rule",
content="کلید API کل من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است",
source="user",
)
memories = await service.list(org_id)
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in memories[0].content
# ---------------------------------------------------------------------------
# build_context
# ---------------------------------------------------------------------------
async def test_context_leads_with_pinned_rules_and_respects_the_budget(session_factory):
org_id, _ = await _org(session_factory, "context")
async with _service(session_factory, _ShapedProvider("")) as service:
pinned_rule = await service.record(
organization_id=org_id, kind="rule", content="قاعدهٔ سنجاق‌شده", source="user"
)
await service.set_pinned(organization_id=org_id, memory_id=pinned_rule, pinned=True)
await service.record(
organization_id=org_id, kind="preference", content="ترجیح معمولی", source="user"
)
for index in range(20):
await service.record(
organization_id=org_id, kind="lesson", content=f"درس قدیمی {index}" + " ز" * 20,
source="user",
)
block = await service.build_context(
organization_id=org_id, role="manager", query="درس", budget_chars=300
)
assert block
assert len(block) <= 300
assert "قاعدهٔ سنجاق‌شده" in block.splitlines()[1]
assert block.index("قاعدهٔ سنجاق‌شده") < block.index("ترجیح معمولی")
# A budget so small that nothing fits returns nothing, not a broken block.
assert await service.build_context(
organization_id=org_id, role="manager", query="درس", budget_chars=4
) == ""
async def test_context_filters_kinds_by_role(session_factory):
org_id, _ = await _org(session_factory, "roles")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="code_map",
content="نقشهٔ کد: مدل‌ها در db/models.py", source="system",
)
await service.record(
organization_id=org_id, kind="preference", content="ترجیح: فارسی بنویس",
source="user",
)
developer = await service.build_context(
organization_id=org_id, role="developer", query="db", budget_chars=2000
)
manager = await service.build_context(
organization_id=org_id, role="manager", query="db", budget_chars=2000
)
support = await service.build_context(
organization_id=org_id, role="support", query="db", budget_chars=2000
)
assert "نقشهٔ کد" in developer and "ترجیح" not in developer
assert "ترجیح" in manager and "نقشهٔ کد" not in manager
assert support == ""
async def test_context_counts_a_hit(session_factory):
org_id, _ = await _org(session_factory, "hits")
async with _service(session_factory, _ShapedProvider("")) as service:
memory_id = await service.record(
organization_id=org_id, kind="rule", content="هر تست قبل از کد", source="user"
)
await service.build_context(
organization_id=org_id, role="developer", query="تست", budget_chars=500
)
memory = await service.get(org_id, memory_id)
assert memory.hits == 1
assert memory.last_used_at is not None
# ---------------------------------------------------------------------------
# Reflection
# ---------------------------------------------------------------------------
async def test_reflection_stores_lessons_and_prices_the_call(session_factory, seeded):
org_id, _ = await _org(session_factory, "reflect")
await _task(session_factory, org_id, "task-77")
provider = _ShapedProvider(
"درس ۱: تست‌ها را قبل از ادغام اجرا کن.\n"
"درس ۲: دروازهٔ lint دو بار شکست.\n"
"نقشهٔ کد: app/db/models.py منبع حقیقت است.\n"
"یک خط اضافی که نادیده گرفته می‌شود."
)
async with _service(session_factory, provider) as service:
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(
task_id="task-77", title="ساخت صفحهٔ ورود", status="completed",
summary="تمام شد", changed_files=["a.py"], cycles=2, gate_failures=["lint"],
),
)
memories = await service.list(org_id)
kinds = [m.kind for m in memories]
assert MemoryKind.LESSON in kinds
assert MemoryKind.CODE_MAP in kinds
assert len(memories) == 3
assert all(m.source is MemorySource.REFLECTION for m in memories)
assert all(m.confidence == 0.5 for m in memories)
assert all(m.evidence_task_id == "task-77" for m in memories)
# The reflection is billed like any other model call.
async with session_factory() as session:
usage = (
(await session.execute(select(UsageEventRow))).scalars().all()
)
assert usage
assert all(row.metadata_json.get("memory_reflection") for row in usage)
async def test_a_failing_reflection_never_raises_and_records_the_error(
session_factory, redirect_error_log, seeded
):
org_id, _ = await _org(session_factory, "reflect-fail")
async with _service(session_factory, _BrokenProvider()) as service:
# No exception escapes, and the org keeps its memory untouched.
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(task_id="task-err", title="t", status="failed"),
)
assert await service.list(org_id) == []
from app.infrastructure.db.models import ErrorEventRow
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert any(row.kind == "memory.reflection_failed" for row in rows)
# A provider's failure text is not trusted verbatim: either it is redacted,
# or it never reached the row at all.
for row in rows:
if "super-secret-token" in (row.message or ""):
assert "REDACTED" in row.message
async def test_reflection_without_a_model_is_recorded_not_raised(
session_factory, redirect_error_log
):
"""No seeded catalogue → ``pick_for_mode`` returns None → something is recorded."""
org_id, _ = await _org(session_factory, "no-model")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(task_id="task-x", title="t", status="completed"),
)
assert await service.list(org_id) == []
from app.infrastructure.db.models import ErrorEventRow
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert any(row.kind == "memory.reflection_no_model" for row in rows)
async def test_expired_lessons_are_removed_by_the_expiry_job(session_factory):
org_id, _ = await _org(session_factory, "expiry")
async with _service(session_factory, _ShapedProvider("")) as service:
expired = await service.record(
organization_id=org_id, kind="lesson", content="درس منقضی", source="reflection"
)
pinned = await service.record(
organization_id=org_id, kind="lesson", content="درس سنجاق‌شده", source="reflection"
)
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
async with session_factory() as session:
row = await session.get(OrgMemoryRow, expired)
row.expires_at = utcnow().replace(year=2020) # long past
await session.commit()
assert await service.expire_memories() == 1
remaining = {m.id for m in await service.list(org_id)}
assert expired not in remaining
assert pinned in remaining
# ---------------------------------------------------------------------------
# Tenant isolation
# ---------------------------------------------------------------------------
async def test_one_organization_never_sees_anothers_memory(session_factory):
org_a, _ = await _org(session_factory, "tenant-a")
org_b, _ = await _org(session_factory, "tenant-b")
async with _service(session_factory, _ShapedProvider("")) as service:
memory_id = await service.record(
organization_id=org_a, kind="rule", content="قاعدهٔ سازمان الف", source="user"
)
assert memory_id not in {m.id for m in await service.list(org_b)}
with pytest.raises(NotFoundError):
await service.get(org_b, memory_id)
block = await service.build_context(
organization_id=org_b, role="manager", query="قاعده", budget_chars=500
)
assert block == ""
# ---------------------------------------------------------------------------
# HTTP surface
# ---------------------------------------------------------------------------
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
org = await client.post(
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
)
assert org.status_code == 201, org.text
return {"token": body["access_token"], "org_id": org.json()["id"]}
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
invite = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(owner_token),
)
assert invite.status_code == 201, invite.text
account = await signup(client, email, name=role)
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(account["access_token"]),
)
assert accepted.status_code == 200, accepted.text
return account["access_token"]
async def test_memory_http_permissions(client: AsyncClient):
org = await _owner_with_org(client, "memory-org")
viewer = await _member(client, org["token"], org["org_id"], "viewer@example.com", "viewer")
member = await _member(client, org["token"], org["org_id"], "member@example.com", "member")
admin = await _member(client, org["token"], org["org_id"], "admin@example.com", "admin")
# A viewer reads but cannot teach.
empty = await client.get(
f"/api/organizations/{org['org_id']}/memory", headers=auth_header(viewer)
)
assert empty.status_code == 200, empty.text
assert empty.json()["items"] == []
refused = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ ممنوعه"},
headers=auth_header(viewer),
)
assert refused.status_code == 403, refused.text
# A member teaches a rule; a lesson is refused (teaching is human-only).
taught = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ تازه", "key": "style"},
headers=auth_header(member),
)
assert taught.status_code == 201, taught.text
memory_id = taught.json()["id"]
assert taught.json()["kind_label"] == "قاعده"
lesson = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "lesson", "content": "درس دست‌نویس"},
headers=auth_header(member),
)
assert lesson.status_code == 403, lesson.text
# Editing is admin+.
edited = await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "قاعدهٔ ویرایش‌شده"},
headers=auth_header(admin),
)
assert edited.status_code == 200, edited.text
assert edited.json()["content"] == "قاعدهٔ ویرایش‌شده"
member_edit = await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "تلاش عضو"},
headers=auth_header(member),
)
assert member_edit.status_code == 403, member_edit.text
# Pin, then confirm (only a lesson may be confirmed).
pinned = await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
json={"pinned": True},
headers=auth_header(admin),
)
assert pinned.json()["pinned"] is True
not_a_lesson = await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/confirm",
headers=auth_header(admin),
)
assert not_a_lesson.status_code == 422, not_a_lesson.text
# Remembering without a session is not a thing.
anonymous = await client.get(f"/api/organizations/{org['org_id']}/memory")
assert anonymous.status_code == 401
async def test_memory_audit_and_isolation(client: AsyncClient, session_factory):
org = await _owner_with_org(client, "audited-memory")
other = await _owner_with_org(client, "other-memory")
admin = await _member(client, org["token"], org["org_id"], "a2@example.com", "admin")
created = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ حسابرسی"},
headers=auth_header(admin),
)
assert created.status_code == 201, created.text
memory_id = created.json()["id"]
await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "قاعدهٔ حسابرسی، ویرایش‌شده"},
headers=auth_header(admin),
)
await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
json={"pinned": True},
headers=auth_header(admin),
)
await client.delete(
f"/api/organizations/{org['org_id']}/memory/{memory_id}", headers=auth_header(admin)
)
async with session_factory() as session:
actions = [
row.action
for row in (
await session.execute(
select(AuditEventRow).where(
AuditEventRow.organization_id == org["org_id"]
)
)
).scalars()
]
assert {"memory.create", "memory.update", "memory.pin", "memory.delete"} <= set(actions)
# Another organization's memory is invisible here, never another tenant's
# data: the soft-deleted row exists, and the list is still empty.
stranger = await client.get(
f"/api/organizations/{other['org_id']}/memory",
headers=auth_header(other["token"]),
)
assert stranger.status_code == 200, stranger.text
assert stranger.json()["items"] == []
async def test_the_real_port_is_wired_in_the_container(session_factory):
from app.application.ports.memory import MemoryPort, NullMemory
org_id, _ = await _org(session_factory, "wired")
async with session_factory() as session:
memory = build_memory(session)
assert isinstance(memory, MemoryPort)
assert not isinstance(memory, NullMemory)
recorded = await memory.record(
organization_id=org_id, kind="rule", content="قاعدهٔ سیم‌کشی‌شده", source="user"
)
assert recorded
block = await memory.build_context(
organization_id=org_id, role="manager", query="سیم", budget_chars=200
)
assert "قاعدهٔ سیم‌کشی‌شده" in block
+395
View File
@@ -0,0 +1,395 @@
"""Owner model grants, the default set and org policy.
The invariant under test: **an organization may only use the models the owner
granted it.** Two switches have to agree (model active + grant active), which is
what makes revocation real, and a grant belongs to exactly one organization.
Nothing here depends on the engine's code: the checks go through the HTTP
endpoints *and* through ``ModelGrantPort`` as the engine would call it, so a
break in either path fails the suite.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_model_grant_service, build_model_grants
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
from tests.conftest import auth_header
GRANTS = "/api/admin/model-grants"
DEFAULTS = "/api/admin/default-model-grants"
POLICIES = "/api/admin/org-policies"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _model_ids(session_factory) -> list[str]:
async with session_factory() as session:
rows = (
await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name))
).scalars().all()
return [row.id for row in rows]
async def _audit_actions(session_factory, organization_id: str | None = None) -> list[str]:
async with session_factory() as session:
stmt = select(AuditEventRow.action)
if organization_id is not None:
stmt = stmt.where(AuditEventRow.organization_id == organization_id)
return list((await session.execute(stmt)).scalars().all())
# ---------------------------------------------------------------------------
# Authorization
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("method", "path"),
[
("get", f"{GRANTS}/org-1"),
("get", f"{GRANTS}/org-1/active"),
("put", f"{GRANTS}/org-1/models/model-1"),
("delete", f"{GRANTS}/org-1/models/model-1"),
("get", DEFAULTS),
("put", f"{DEFAULTS}/model-1"),
("delete", f"{DEFAULTS}/model-1"),
("get", f"{POLICIES}/org-1"),
("put", f"{POLICIES}/org-1"),
],
)
async def test_non_owner_is_forbidden_everywhere(
client: AsyncClient, manager_token: str, method: str, path: str
):
call = getattr(client, method)
kwargs: dict = {"headers": auth_header(manager_token)}
if method == "put":
kwargs["json"] = {"daily_credit_cap": 5, "is_active": True, "max_employees": 3}
response = await call(path, **kwargs)
assert response.status_code == 403, response.text
async def test_anonymous_is_rejected(client: AsyncClient):
assert (await client.get(f"{GRANTS}/org-1")).status_code == 401
# ---------------------------------------------------------------------------
# Grant matrix and revocation
# ---------------------------------------------------------------------------
async def test_wizard_copies_the_default_set(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
set_default = await client.put(
f"{DEFAULTS}/{model_id}",
json={"is_active": True, "daily_credit_cap": 12.5},
headers=auth_header(platform_owner_token),
)
assert set_default.status_code == 200, set_default.text
assert set_default.json()["granted"] is True
org_id = await _create_org(client, manager_token, "سازمان الف", "alpha-org")
matrix = await client.get(f"{GRANTS}/{org_id}", headers=auth_header(platform_owner_token))
assert matrix.status_code == 200, matrix.text
rows = {m["ai_model_id"]: m for m in matrix.json()["models"]}
assert rows[model_id]["granted"] is True
assert rows[model_id]["daily_credit_cap"] == 12.5
# Every other model is listed but not granted.
assert all(m["granted"] is False for m_id, m in rows.items() if m_id != model_id)
# The same answer through the engine's port.
async with session_factory() as session:
service = build_model_grants(session)
granted = await service.granted_models(organization_id=org_id)
assert [g.ai_model_id for g in granted] == [model_id]
assert granted[0].tier in {"fast", "balanced", "strong"}
assert granted[0].daily_credit_cap == 12.5
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
async def test_grant_edit_and_revoke_are_immediate_and_audited(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_id = await _create_org(client, manager_token, "سازمان ب", "beta-org")
owner = auth_header(platform_owner_token)
grant = await client.put(
f"{GRANTS}/{org_id}/models/{model_id}",
json={"daily_credit_cap": 3},
headers=owner,
)
assert grant.status_code == 200, grant.text
assert grant.json()["is_active"] is True
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
# Raising the cap keeps it granted.
raised = await client.put(
f"{GRANTS}/{org_id}/models/{model_id}",
json={"daily_credit_cap": 40},
headers=owner,
)
assert raised.json()["daily_credit_cap"] == 40
revoked = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
assert revoked.status_code == 200, revoked.text
async with session_factory() as session:
service = build_model_grant_service(session)
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_id) == []
# Revocation deactivates rather than deletes, so the history survives and
# re-granting is idempotent instead of racing a unique constraint.
row = (
await session.execute(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == org_id,
OrgModelGrantRow.ai_model_id == model_id,
)
)
).scalar_one()
assert row.is_active is False
active = await client.get(f"{GRANTS}/{org_id}/active", headers=owner)
assert active.json() == []
actions = await _audit_actions(session_factory, org_id)
assert "model_grant.set" in actions
assert "model_grant.revoke" in actions
# Revoking something that is not granted is a 404, not a silent success.
again = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
assert again.status_code == 404
async def test_inactive_model_is_never_granted(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_id = await _create_org(client, manager_token, "سازمان ج", "gamma-org")
owner = auth_header(platform_owner_token)
granted = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
assert granted.status_code == 200, granted.text
# The owner retires the model from the catalogue.
async with session_factory() as session:
row = await session.get(AiModelRow, model_id)
assert row is not None
row.is_active = False
await session.commit()
async with session_factory() as session:
service = build_model_grant_service(session)
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_id) == []
# Granting a retired model is refused rather than quietly stored.
refused = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
assert refused.status_code == 404
listing = await client.get(f"{GRANTS}/{org_id}", headers=owner)
assert all(m["ai_model_id"] != model_id for m in listing.json()["models"])
async def test_grants_are_isolated_per_organization(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_a = await _create_org(client, manager_token, "شرکت آلفا", "alpha-two")
org_b = await _create_org(client, manager_token, "شرکت بتا", "beta-two")
owner = auth_header(platform_owner_token)
granted = await client.put(
f"{GRANTS}/{org_a}/models/{model_id}", json={"daily_credit_cap": 7}, headers=owner
)
assert granted.status_code == 200, granted.text
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.is_granted(organization_id=org_a, ai_model_id=model_id)
assert not await service.is_granted(organization_id=org_b, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_b) == []
matrix_b = await client.get(f"{GRANTS}/{org_b}", headers=owner)
assert all(m["granted"] is False for m in matrix_b.json()["models"])
# …and org B's grant is never visible in org A's active list either.
active_a = await client.get(f"{GRANTS}/{org_a}/active", headers=owner)
assert [g["ai_model_id"] for g in active_a.json()] == [model_id]
async def test_default_set_can_be_removed_and_deactivated(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
owner = auth_header(platform_owner_token)
assert (await client.put(f"{DEFAULTS}/{model_id}", json={}, headers=owner)).status_code == 200
# Switched off: still in the set for the admin UI, but not copied to new orgs.
off = await client.put(
f"{DEFAULTS}/{model_id}", json={"is_active": False}, headers=owner
)
assert off.status_code == 200, off.text
org_off = await _create_org(client, manager_token, "سازمان خاموش", "off-org")
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.granted_models(organization_id=org_off) == []
removed = await client.delete(f"{DEFAULTS}/{model_id}", headers=owner)
assert removed.status_code == 200, removed.text
assert removed.json()["in_default_set"] is False
async with session_factory() as session:
assert (
await session.execute(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == model_id
)
)
).scalar_one_or_none() is None
# Already-created organizations keep the grant they were given.
async with session_factory() as session:
assert (
await session.execute(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == org_off
)
)
).scalar_one_or_none() is None
actions = await _audit_actions(session_factory)
assert "default_model_grant.set" in actions
assert "default_model_grant.remove" in actions
# ---------------------------------------------------------------------------
# Org policy
# ---------------------------------------------------------------------------
async def test_policy_defaults_caps_and_audit(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
owner = auth_header(platform_owner_token)
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
assert fresh.status_code == 200, fresh.text
body = fresh.json()
assert body["max_employees"] == 5
assert body["automation_enabled"] is True
assert body["mcp_enabled"] is True
assert body["monthly_credit_budget"] is None
assert body["max_employees_limit"] == settings.engine_max_employees
too_many = await client.put(
f"{POLICIES}/{org_id}",
json={"max_employees": settings.engine_max_employees + 1},
headers=owner,
)
assert too_many.status_code == 422
negative = await client.put(
f"{POLICIES}/{org_id}", json={"monthly_credit_budget": -1}, headers=owner
)
assert negative.status_code == 422
empty = await client.put(f"{POLICIES}/{org_id}", json={}, headers=owner)
assert empty.status_code == 422
updated = await client.put(
f"{POLICIES}/{org_id}",
json={
"max_employees": 8,
"monthly_credit_budget": 250.5,
"automation_enabled": False,
"mcp_enabled": False,
},
headers=owner,
)
assert updated.status_code == 200, updated.text
assert updated.json()["max_employees"] == 8
assert updated.json()["automation_enabled"] is False
assert updated.json()["mcp_enabled"] is False
async with session_factory() as session:
row = await session.get(OrgPolicyRow, org_id)
assert row is not None
assert row.max_employees == 8
assert row.monthly_credit_budget == 250.5
# Agent 2 reads the policy through the same service.
service = build_model_grant_service(session)
policy = await service.get_org_policy(org_id)
assert policy.max_employees == 8
assert policy.automation_enabled is False
assert await service.mcp_enabled(org_id) is False
assert "org_policy.update" in await _audit_actions(session_factory, org_id)
async def test_policy_is_isolated_per_organization(
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
):
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
owner = auth_header(platform_owner_token)
await client.put(f"{POLICIES}/{org_a}", json={"max_employees": 2}, headers=owner)
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
assert a.json()["max_employees"] == 2
assert b.json()["max_employees"] == 5