ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
+63
-15
@@ -13,37 +13,36 @@ async def test_health_is_public(client: AsyncClient):
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
async def test_signup_returns_token_and_user(client: AsyncClient):
|
||||
async def test_signup_helper_returns_token_and_user(client: AsyncClient):
|
||||
"""There is no public signup endpoint — the test helper is the owner-side
|
||||
creation path — and what it returns must carry the platform role."""
|
||||
body = await signup(client, "alice@example.com", name="آلیس")
|
||||
assert body["access_token"]
|
||||
assert body["user"]["email"] == "alice@example.com"
|
||||
assert body["user"]["name"] == "آلیس"
|
||||
assert body["user"]["platform_role"] == "manager"
|
||||
assert body["user"]["is_manager"] is True
|
||||
assert body["user"]["is_platform_owner"] is False
|
||||
assert "password" not in str(body).lower() or "password_hash" not in str(body)
|
||||
|
||||
|
||||
async def test_signup_never_returns_password_hash(client: AsyncClient):
|
||||
async def test_signup_helper_never_returns_password_hash(client: AsyncClient):
|
||||
body = await signup(client, "hashcheck@example.com")
|
||||
assert "password_hash" not in body["user"]
|
||||
assert "password" not in body["user"]
|
||||
|
||||
|
||||
async def test_signup_rejects_duplicate_email(client: AsyncClient):
|
||||
await signup(client, "dup@example.com")
|
||||
async def test_public_signup_endpoint_is_gone(client: AsyncClient):
|
||||
"""Signup is closed: accounts come from the owner or from an invite link.
|
||||
|
||||
A stray old client must not silently succeed — the route simply does not
|
||||
exist anymore.
|
||||
"""
|
||||
response = await client.post(
|
||||
"/api/auth/signup",
|
||||
json={"email": "dup@example.com", "password": "password123", "name": ""},
|
||||
json={"email": "nope@example.com", "password": "password123", "name": ""},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error"]["code"] == "conflict"
|
||||
|
||||
|
||||
async def test_signup_rejects_short_password(client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/api/auth/signup",
|
||||
json={"email": "short@example.com", "password": "123", "name": ""},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_login_succeeds_with_correct_password(client: AsyncClient):
|
||||
@@ -53,6 +52,9 @@ async def test_login_succeeds_with_correct_password(client: AsyncClient):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"]
|
||||
payload = response.json()["user"]
|
||||
assert payload["platform_role"] == "manager"
|
||||
assert payload["is_platform_owner"] is False
|
||||
|
||||
|
||||
async def test_login_fails_with_wrong_password(client: AsyncClient):
|
||||
@@ -96,3 +98,49 @@ async def test_logout_is_recorded_and_returns_204(client: AsyncClient):
|
||||
body = await signup(client, "frank@example.com")
|
||||
response = await client.post("/api/auth/logout", headers=auth_header(body["access_token"]))
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
async def test_change_password_replaces_the_old_one(client: AsyncClient):
|
||||
"""A temp password handed out by the owner must become a private one.
|
||||
|
||||
Without this, every account created by someone else would live forever on
|
||||
a secret two people know.
|
||||
"""
|
||||
body = await signup(client, "grace@example.com", password="password123")
|
||||
headers = auth_header(body["access_token"])
|
||||
|
||||
response = await client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "newpassword456"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 204, response.text
|
||||
|
||||
stale = await client.post(
|
||||
"/api/auth/login", json={"email": "grace@example.com", "password": "password123"}
|
||||
)
|
||||
assert stale.status_code == 401
|
||||
fresh = await client.post(
|
||||
"/api/auth/login", json={"email": "grace@example.com", "password": "newpassword456"}
|
||||
)
|
||||
assert fresh.status_code == 200
|
||||
|
||||
|
||||
async def test_change_password_rejects_a_wrong_current_password(client: AsyncClient):
|
||||
body = await signup(client, "heidi@example.com", password="password123")
|
||||
response = await client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "not-my-password", "new_password": "newpassword456"},
|
||||
headers=auth_header(body["access_token"]),
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_change_password_rejects_a_short_new_password(client: AsyncClient):
|
||||
body = await signup(client, "ivan@example.com", password="password123")
|
||||
response = await client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "123"},
|
||||
headers=auth_header(body["access_token"]),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user