Files
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- 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 تمیز.
2026-10-13 21:58:41 +03:30

147 lines
5.5 KiB
Python

"""Authentication flow tests."""
from __future__ import annotations
from httpx import AsyncClient
from tests.conftest import auth_header, signup
async def test_health_is_public(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
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_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_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": "nope@example.com", "password": "password123", "name": ""},
)
assert response.status_code == 404
async def test_login_succeeds_with_correct_password(client: AsyncClient):
await signup(client, "bob@example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "bob@example.com", "password": "password123"}
)
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):
await signup(client, "carol@example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "carol@example.com", "password": "wrong-password"}
)
assert response.status_code == 401
async def test_login_is_case_insensitive_on_email(client: AsyncClient):
await signup(client, "Dave@Example.com", password="password123")
response = await client.post(
"/api/auth/login", json={"email": "dave@example.com", "password": "password123"}
)
assert response.status_code == 200
async def test_me_requires_authentication(client: AsyncClient):
response = await client.get("/api/auth/me")
assert response.status_code == 401
async def test_me_rejects_garbage_token(client: AsyncClient):
response = await client.get("/api/auth/me", headers=auth_header("not-a-real-token"))
assert response.status_code == 401
async def test_me_returns_profile_and_organizations(client: AsyncClient):
body = await signup(client, "erin@example.com", name="ارین")
token = body["access_token"]
response = await client.get("/api/auth/me", headers=auth_header(token))
assert response.status_code == 200
payload = response.json()
assert payload["user"]["email"] == "erin@example.com"
assert payload["organizations"] == []
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