ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد. - ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users (فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند. در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد. - ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود. - تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت، با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش انجام میشود. - افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی. - فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup، گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ. - PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت). - تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
+79
-22
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -21,9 +20,12 @@ from sqlalchemy.pool import StaticPool
|
||||
# and binding the package to the same name would shadow it.
|
||||
import app.infrastructure.db.models as _orm_models # noqa: F401
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, get_session
|
||||
from app.core.database import Base, enable_sqlite_foreign_keys, get_session
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.domain.base import new_id
|
||||
from app.infrastructure.db.models import UserRow
|
||||
from app.domain.identity.models import PlatformRole, User
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
# The internal "full" app holds every HTTP handler; the public app (app.main.app)
|
||||
# now serves only /health + /api/ws, so REST tests must target the full app.
|
||||
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||
@@ -64,6 +66,9 @@ async def engine():
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
# Same pragma the app engine gets, so ON DELETE CASCADE behaves here the
|
||||
# way it does on Postgres.
|
||||
enable_sqlite_foreign_keys(test_engine)
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield test_engine
|
||||
@@ -109,12 +114,20 @@ async def client(engine, session_factory):
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
# Publish the factory for the module-level ``signup`` helper below. The
|
||||
# in-memory engine each test builds is private to its fixtures; a helper
|
||||
# that needs the same database has to reach it through this door.
|
||||
_session_factory_holder["factory"] = session_factory
|
||||
app.dependency_overrides[get_session] = _override_session
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
#: Set by the ``client`` fixture; read by :func:`signup`.
|
||||
_session_factory_holder: dict[str, async_sessionmaker] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake model provider — the only sanctioned stand-in for agy in tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,13 +199,45 @@ def _fake_model_provider(monkeypatch):
|
||||
|
||||
|
||||
async def signup(
|
||||
client: AsyncClient, email: str, password: str = "password123", name: str = ""
|
||||
client: AsyncClient,
|
||||
email: str,
|
||||
password: str = "password123",
|
||||
name: str = "",
|
||||
platform_role: PlatformRole = PlatformRole.MANAGER,
|
||||
) -> dict:
|
||||
response = await client.post(
|
||||
"/api/auth/signup", json={"email": email, "password": password, "name": name}
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
"""Create an account directly in the database and mint a token for it.
|
||||
|
||||
There is no public signup endpoint anymore — accounts are created by the
|
||||
platform owner or by redeeming an invite — so this helper writes the row
|
||||
those flows would and returns the same shape ``POST /auth/signup`` used to.
|
||||
The ``client`` argument is kept for the historical call signature.
|
||||
|
||||
The default platform role is ``manager`` because most tests exercise the
|
||||
tenant surface, and creating an organization requires that tier. Tests
|
||||
that model a plain invited user pass ``platform_role=PlatformRole.USER``.
|
||||
"""
|
||||
factory = _session_factory_holder["factory"]
|
||||
async with factory() as session:
|
||||
user = await UserRepository(session).create(
|
||||
User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
platform_role=platform_role,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return {
|
||||
"access_token": create_access_token(user.id),
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.display_name,
|
||||
"platform_role": user.platform_role.value,
|
||||
"is_manager": user.is_manager,
|
||||
"is_platform_owner": user.is_platform_owner,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict[str, str]:
|
||||
@@ -201,22 +246,30 @@ def auth_header(token: str) -> dict[str, str]:
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def platform_owner_token(client: AsyncClient, session_factory) -> str:
|
||||
"""A signed-up user promoted to platform owner.
|
||||
"""A token for the one and only platform owner.
|
||||
|
||||
The promotion is done directly in the database because there is deliberately
|
||||
no API that can grant platform-owner status.
|
||||
Owner seats are not grantable through the API by design (a manager must
|
||||
not be able to mint themselves an owner), so the fixture creates the
|
||||
account with the owner role directly — the same seat the seed installs.
|
||||
"""
|
||||
# ``example.com`` is a documentation domain and passes email validation.
|
||||
# Do NOT use ``.local``/``.test``: those are special-use TLDs and newer
|
||||
# ``email-validator`` releases reject them, which fails the fixture.
|
||||
email = "platform-owner@example.com"
|
||||
body = await signup(client, email, name="مالک سامانه")
|
||||
async with session_factory() as session:
|
||||
row = (
|
||||
await session.execute(select(UserRow).where(UserRow.email == email))
|
||||
).scalar_one()
|
||||
row.is_platform_owner = True
|
||||
await session.commit()
|
||||
body = await signup(client, email, name="مالک سامانه", platform_role=PlatformRole.OWNER)
|
||||
return body["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager_token(client: AsyncClient) -> str:
|
||||
"""A token for a plain manager account (the org-creating tier)."""
|
||||
body = await signup(client, "manager@example.com", name="مدیر")
|
||||
return body["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plain_user_token(client: AsyncClient) -> str:
|
||||
"""A token for a plain user — the tier that may not create organizations."""
|
||||
body = await signup(
|
||||
client, "plain-user@example.com", name="کاربر عادی", platform_role=PlatformRole.USER
|
||||
)
|
||||
return body["access_token"]
|
||||
|
||||
|
||||
@@ -328,6 +381,10 @@ async def seeded(session_factory):
|
||||
id=provider_id, name="agy", type="agy", base_url="", is_active=True
|
||||
)
|
||||
)
|
||||
# Flush the provider before its models: with foreign keys enforced the
|
||||
# batched model INSERT must not run before the row it references
|
||||
# exists.
|
||||
await session.flush()
|
||||
for internal, display, capabilities, effort in SYSTEM_MODELS:
|
||||
session.add(
|
||||
AiModelRow(
|
||||
|
||||
Reference in New Issue
Block a user