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:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user