ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- 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:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+117 -10
View File
@@ -1,8 +1,13 @@
"""Invite redemption: the link an admin hands out must actually work.
Covers the full loop — create the invite, preview it as the invitee, redeem it,
and the three ways redemption can legitimately fail. Also pins the absolute
invite URL, since a bare path is not clickable for the person receiving it.
and the ways redemption can legitimately fail. Also pins the absolute invite
URL, since a bare path is not clickable for the person receiving it.
Because signup is closed, the invite link doubles as the account-creation
path: a brand-new invitee redeems it with a password and gets an account bound
to the invited email. The token can never reset an existing account's password
— that is what the "account already exists" rejection guards.
"""
from __future__ import annotations
@@ -13,7 +18,7 @@ from tests.conftest import auth_header, signup
async def _owner_with_org(client: AsyncClient, name: str = "Acme") -> tuple[str, str]:
"""``(token, org_id)`` for a freshly signed-up owner."""
"""``(token, org_id)`` for a freshly created manager and their organization."""
body = await signup(client, "owner@example.com", name="Owner")
headers = auth_header(body["access_token"])
org = await client.post("/api/organizations", json={"name": name}, headers=headers)
@@ -104,19 +109,121 @@ async def test_invite_cannot_be_redeemed_by_a_different_account(client: AsyncCli
async def test_unknown_invite_token_is_not_found(client: AsyncClient):
user = await signup(client, "nobody@example.com")
response = await client.get(
"/api/invites/definitely-not-a-real-token",
headers=auth_header(user["access_token"]),
)
response = await client.get("/api/invites/definitely-not-a-real-token")
assert response.status_code == 404
async def test_accepting_an_invite_requires_authentication(client: AsyncClient):
async def test_invite_preview_works_without_a_session(client: AsyncClient):
"""A brand-new invitee must see what they were invited to *before* they can
log in — otherwise the login button on the accept screen is a dead end."""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
preview = await client.get(f"/api/invites/{raw}")
assert preview.status_code == 200, preview.text
body = preview.json()
assert body["organization_name"] == "Acme"
assert body["email"] == "newbie@example.com"
assert body["role"] == "member"
# No account exists for the invited email yet, so the UI offers to create
# one instead of demanding a login.
assert body["has_account"] is False
assert body["already_member"] is False
async def test_invite_preview_reports_an_existing_account(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "existing@example.com", "member"))[
"invite_token"
]
await signup(client, "existing@example.com", name="Existing")
preview = await client.get(f"/api/invites/{raw}")
assert preview.status_code == 200, preview.text
assert preview.json()["has_account"] is True
async def test_anonymous_redemption_creates_the_account_and_joins(client: AsyncClient):
"""The closed-signup flow: the link is the capability, the password makes
the account private, and the org membership is created in one step."""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
accepted = await client.post(
"/api/invites/accept", json={"token": raw, "password": "password123"}
)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["organization_id"] == org_id
assert accepted.json()["role"] == "member"
# The account exists for exactly the invited email, with that password, as
# a plain platform user — an invite must never mint a manager.
login = await client.post(
"/api/auth/login", json={"email": "newbie@example.com", "password": "password123"}
)
assert login.status_code == 200, login.text
token = login.json()["access_token"]
assert login.json()["user"]["platform_role"] == "user"
me = await client.get("/api/auth/me", headers=auth_header(token))
joined = [o for o in me.json()["organizations"] if o["id"] == org_id]
assert joined and joined[0]["role"] == "member"
async def test_anonymous_redemption_without_a_password_is_rejected(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
response = await client.post("/api/invites/accept", json={"token": raw})
assert response.status_code == 401
assert response.status_code == 422
assert response.json()["error"]["code"] == "validation_error"
async def test_anonymous_redemption_rejects_a_short_password(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
response = await client.post(
"/api/invites/accept", json={"token": raw, "password": "123"}
)
assert response.status_code == 422
async def test_a_link_cannot_reset_an_existing_account(client: AsyncClient):
"""A leaked invite must not become a password reset for the real account.
Whoever holds the link can create an account *only if none exists* for the
invited email; otherwise they must authenticate first.
"""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "taken@example.com", "member"))[
"invite_token"
]
await signup(client, "taken@example.com", password="password123")
response = await client.post(
"/api/invites/accept", json={"token": raw, "password": "attacker-password"}
)
assert response.status_code == 403
assert "وارد شوید" in response.json()["error"]["message"]
# The real account's password is untouched, and the invite is not burned:
# the invitee can still redeem it after signing in.
login = await client.post(
"/api/auth/login", json={"email": "taken@example.com", "password": "password123"}
)
assert login.status_code == 200
accepted = await client.post(
"/api/invites/accept",
json={"token": raw},
headers=auth_header(login.json()["access_token"]),
)
assert accepted.status_code == 200, accepted.text