ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
+49
-17
@@ -71,28 +71,59 @@ async def test_seeded_platform_owner_can_log_in(client: AsyncClient, seeded_inst
|
||||
assert me.json()["user"]["is_platform_owner"] is True
|
||||
|
||||
|
||||
async def test_seeded_accounts_belong_to_the_sample_organization(
|
||||
async def test_seeded_demo_manager_owns_the_sample_organization(
|
||||
client: AsyncClient, seeded_install
|
||||
):
|
||||
"""A login that lands on an empty dashboard is a broken demo.
|
||||
|
||||
Both seeded accounts must see the sample organization, otherwise every
|
||||
screen behind the login renders nothing.
|
||||
The demo account is a *manager* — the tier that creates organizations — so
|
||||
it must own the sample organization and land on a populated dashboard.
|
||||
"""
|
||||
for email, password in (
|
||||
(seeded_install.DEMO_EMAIL, seeded_install.DEMO_PASSWORD),
|
||||
(seeded_install.PLATFORM_OWNER_EMAIL, seeded_install.PLATFORM_OWNER_PASSWORD),
|
||||
):
|
||||
login = await client.post("/api/auth/login", json={"email": email, "password": password})
|
||||
assert login.status_code == 200, login.text
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": seeded_install.DEMO_EMAIL,
|
||||
"password": seeded_install.DEMO_PASSWORD,
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
assert login.json()["user"]["platform_role"] == "manager"
|
||||
assert login.json()["user"]["is_manager"] is True
|
||||
assert login.json()["user"]["is_platform_owner"] is False
|
||||
|
||||
me = await client.get(
|
||||
"/api/auth/me", headers=auth_header(login.json()["access_token"])
|
||||
)
|
||||
organizations = me.json()["organizations"]
|
||||
assert organizations, f"{email} belongs to no organization"
|
||||
assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME
|
||||
assert organizations[0]["role"] == "owner"
|
||||
me = await client.get(
|
||||
"/api/auth/me", headers=auth_header(login.json()["access_token"])
|
||||
)
|
||||
organizations = me.json()["organizations"]
|
||||
assert organizations, "the demo manager belongs to no organization"
|
||||
assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME
|
||||
assert organizations[0]["role"] == "owner"
|
||||
|
||||
|
||||
async def test_platform_owner_is_not_joined_to_any_organization(
|
||||
client: AsyncClient, seeded_install
|
||||
):
|
||||
"""The product rule: the owner observes tenants from the console.
|
||||
|
||||
Auto-joining the owner to a tenant would put the single owner seat inside
|
||||
customer data, which is exactly what the hierarchy is designed to avoid.
|
||||
"""
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": seeded_install.PLATFORM_OWNER_EMAIL,
|
||||
"password": seeded_install.PLATFORM_OWNER_PASSWORD,
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
|
||||
me = await client.get(
|
||||
"/api/auth/me", headers=auth_header(login.json()["access_token"])
|
||||
)
|
||||
assert me.status_code == 200, me.text
|
||||
assert me.json()["user"]["is_platform_owner"] is True
|
||||
assert me.json()["user"]["platform_role"] == "owner"
|
||||
assert me.json()["organizations"] == []
|
||||
|
||||
|
||||
async def test_seeded_install_can_use_the_product(
|
||||
@@ -216,4 +247,5 @@ async def test_seed_is_idempotent(client: AsyncClient, seeded_install, session_f
|
||||
|
||||
assert after == before, f"re-seeding changed row counts: {before} -> {after}"
|
||||
assert after["organizations"] == 1
|
||||
assert after["memberships"] == 2, "one per seeded account, and no duplicates"
|
||||
# Only the demo manager: the platform owner stays out of tenants.
|
||||
assert after["memberships"] == 1
|
||||
|
||||
Reference in New Issue
Block a user