ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
+117
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user