Files
smart-org/apps/api/tests/test_platform_hierarchy.py
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

520 lines
20 KiB
Python

"""The platform hierarchy: one owner, owner-created managers, invited tenants.
This is the security core of the identity model, so every rule gets both sides
tested — the allow and the deny:
1. **Exactly one owner.** The database refuses a second owner row outright;
the API refuses to mint or demote one. The seat moves only through a
transactional transfer that demotes the previous owner in the same step.
2. **Only the owner and managers create organizations.** A plain user may not.
3. **Only the owner creates managers.** No manager can mint another manager,
and no manager can enter the admin console.
4. **A departing manager's organizations move to the owner** — never orphaned,
never deleted with their data.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.domain.identity.models import Role
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from tests.conftest import auth_header
async def _create_org(client: AsyncClient, token: str, name: str) -> dict:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def _login(client: AsyncClient, email: str, password: str) -> str:
response = await client.post(
"/api/auth/login", json={"email": email, "password": password}
)
assert response.status_code == 200, response.text
return response.json()["access_token"]
async def _owner_creates_user(
client: AsyncClient,
owner_token: str,
email: str,
*,
platform_role: str = "manager",
name: str = "",
) -> dict:
response = await client.post(
"/api/admin/users",
json={"email": email, "name": name, "platform_role": platform_role},
headers=auth_header(owner_token),
)
assert response.status_code == 201, response.text
return response.json()
# ---------------------------------------------------------------------------
# Exactly one owner
# ---------------------------------------------------------------------------
async def test_only_one_platform_owner_may_exist(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The invariant is enforced by the database, not by habit.
Even a direct INSERT — the kind a future code path or a migration would
perform — cannot produce a second owner row.
"""
from sqlalchemy.exc import IntegrityError as _IntegrityError
async with session_factory() as session:
session.add(
UserRow(
id="second-owner-row",
email="second-owner@example.com",
password_hash="x",
platform_role="owner",
)
)
with pytest.raises(_IntegrityError):
await session.flush()
await session.rollback()
async def test_owner_cannot_be_created_through_the_api(
client: AsyncClient, platform_owner_token: str
):
response = await client.post(
"/api/admin/users",
json={"email": "wannabe@example.com", "platform_role": "owner"},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 422
assert "مالکیت" in response.json()["error"]["message"]
async def test_owner_account_is_immutable_from_the_api(
client: AsyncClient, platform_owner_token: str
):
"""No API path may demote, deactivate or delete the single owner seat."""
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
owner_id = me.json()["user"]["id"]
demote = await client.patch(
f"/api/admin/users/{owner_id}",
json={"platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert demote.status_code == 403
deactivate = await client.patch(
f"/api/admin/users/{owner_id}",
json={"is_active": False},
headers=auth_header(platform_owner_token),
)
assert deactivate.status_code == 403
delete = await client.delete(
f"/api/admin/users/{owner_id}", headers=auth_header(platform_owner_token)
)
assert delete.status_code == 403
async def test_transfer_ownership_moves_the_seat_and_keeps_exactly_one(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The safety valve for a lost owner account — transactional by design."""
manager = await _owner_creates_user(
client, platform_owner_token, "successor@example.com", name="جانشین"
)
successor_token = await _login(
client, "successor@example.com", manager["temp_password"]
)
response = await client.post(
"/api/admin/transfer-ownership",
json={"user_id": manager["user"]["id"]},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json()["platform_role"] == "owner"
async with session_factory() as session:
owners = (
(await session.execute(select(UserRow).where(UserRow.platform_role == "owner")))
.scalars()
.all()
)
assert [o.email for o in owners] == ["successor@example.com"]
# The previous owner is demoted to manager in the same step — a manager,
# not a powerless user, so they keep the ability to run organizations.
previous = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
assert previous.json()["user"]["platform_role"] == "manager"
# And the successor really is the owner: the console opens for them.
console = await client.get("/api/admin/users", headers=auth_header(successor_token))
assert console.status_code == 200
# ---------------------------------------------------------------------------
# Organization creation is gated by platform role
# ---------------------------------------------------------------------------
async def test_plain_user_cannot_create_an_organization(
client: AsyncClient, platform_owner_token: str, plain_user_token: str
):
response = await client.post(
"/api/organizations",
json={"name": "سازمان ممنوعه"},
headers=auth_header(plain_user_token),
)
assert response.status_code == 403
assert "دعوت" in response.json()["error"]["message"]
async def test_manager_can_create_an_organization(client: AsyncClient, manager_token: str):
org = await _create_org(client, manager_token, "سازمان مدیر")
assert org["owner_user_id"] == (
await client.get("/api/auth/me", headers=auth_header(manager_token))
).json()["user"]["id"]
async def test_owner_can_create_an_organization(client: AsyncClient, platform_owner_token: str):
org = await _create_org(client, platform_owner_token, "سازمان مالک")
assert org["name"] == "سازمان مالک"
# ---------------------------------------------------------------------------
# Only the owner creates managers
# ---------------------------------------------------------------------------
async def test_manager_cannot_create_accounts(client: AsyncClient, manager_token: str):
response = await client.post(
"/api/admin/users",
json={"email": "sneaky@example.com", "platform_role": "manager"},
headers=auth_header(manager_token),
)
assert response.status_code == 403
async def test_plain_user_cannot_create_accounts(client: AsyncClient, plain_user_token: str):
response = await client.post(
"/api/admin/users",
json={"email": "sneaky2@example.com", "platform_role": "manager"},
headers=auth_header(plain_user_token),
)
assert response.status_code == 403
async def test_manager_cannot_reach_the_admin_console(client: AsyncClient, manager_token: str):
for path in ("/api/admin/users", "/api/admin/models", "/api/admin/organizations"):
response = await client.get(path, headers=auth_header(manager_token))
assert response.status_code == 403, path
async def test_owner_creates_a_manager_whose_temp_password_works(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "manager1@example.com", name="مدیر یک"
)
assert created["user"]["platform_role"] == "manager"
assert created["user"]["is_manager"] is True
assert created["user"]["owned_organizations"] == 0
temp = created["temp_password"]
assert temp
login = await client.post(
"/api/auth/login",
json={"email": "manager1@example.com", "password": temp},
)
assert login.status_code == 200, login.text
assert login.json()["user"]["platform_role"] == "manager"
async def test_duplicate_email_is_rejected(client: AsyncClient, platform_owner_token: str):
await _owner_creates_user(client, platform_owner_token, "twin@example.com")
response = await client.post(
"/api/admin/users",
json={"email": "twin@example.com", "platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 409
async def test_owner_can_create_plain_users(client: AsyncClient, platform_owner_token: str):
created = await _owner_creates_user(
client,
platform_owner_token,
"plain@example.com",
platform_role="user",
name="کاربر عادی",
)
assert created["user"]["platform_role"] == "user"
assert created["user"]["is_manager"] is False
async def test_owner_resets_a_forgotten_temp_password(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "forgetful@example.com", name="فراموشکار"
)
first = created["temp_password"]
reset = await client.post(
f"/api/admin/users/{created['user']['id']}/reset-password",
headers=auth_header(platform_owner_token),
)
assert reset.status_code == 200, reset.text
second = reset.json()["temp_password"]
assert second and second != first
stale = await client.post(
"/api/auth/login", json={"email": "forgetful@example.com", "password": first}
)
assert stale.status_code == 401
fresh = await client.post(
"/api/auth/login", json={"email": "forgetful@example.com", "password": second}
)
assert fresh.status_code == 200
# ---------------------------------------------------------------------------
# A departing manager's organizations move to the owner
# ---------------------------------------------------------------------------
async def test_demoting_a_manager_transfers_organizations_to_the_owner(
client: AsyncClient, platform_owner_token: str, session_factory
):
manager = await _owner_creates_user(
client, platform_owner_token, "leaving@example.com", name="خداحافظ"
)
manager_token = await _login(
client, "leaving@example.com", manager["temp_password"]
)
org = await _create_org(client, manager_token, "سازمان خداحافظ")
demote = await client.patch(
f"/api/admin/users/{manager['user']['id']}",
json={"platform_role": "user"},
headers=auth_header(platform_owner_token),
)
assert demote.status_code == 200, demote.text
assert demote.json()["platform_role"] == "user"
assert demote.json()["owned_organizations"] == 0
async with session_factory() as session:
row = await session.get(OrganizationRow, org["id"])
assert row is not None
owner_id = (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]
assert row.owner_user_id == owner_id
memberships = (
(
await session.execute(
select(MembershipRow).where(MembershipRow.organization_id == org["id"])
)
)
.scalars()
.all()
)
by_user = {m.user_id: m for m in memberships}
# The new owner can actually see the tenant...
assert by_user[owner_id].role == Role.OWNER.value
# ...and the demoted manager keeps working access without ownership.
assert by_user[manager["user"]["id"]].role == Role.ADMIN.value
async def test_deleting_a_manager_transfers_organizations_and_memberships(
client: AsyncClient, platform_owner_token: str, session_factory
):
manager = await _owner_creates_user(
client, platform_owner_token, "doomed@example.com", name="حذف‌شونده"
)
manager_token = await _login(client, "doomed@example.com", manager["temp_password"])
org = await _create_org(client, manager_token, "سازمان حذف‌شونده")
response = await client.delete(
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["deleted_user_id"] == manager["user"]["id"]
assert [t["organization_id"] for t in body["transferred_organizations"]] == [org["id"]]
async with session_factory() as session:
assert await session.get(UserRow, manager["user"]["id"]) is None
row = await session.get(OrganizationRow, org["id"])
assert row is not None, "the organization must survive its owner's deletion"
owner_id = (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]
assert row.owner_user_id == owner_id
stale = (
await session.execute(
select(MembershipRow).where(
MembershipRow.user_id == manager["user"]["id"]
)
)
).scalars().all()
assert stale == [], "the deleted account's memberships must be gone"
# ---------------------------------------------------------------------------
# Managers create and join organizations
# ---------------------------------------------------------------------------
async def test_manager_can_join_another_managers_organization_by_invite(
client: AsyncClient, platform_owner_token: str
):
"""The invited manager keeps their own organization and gains a second."""
first = await _owner_creates_user(
client, platform_owner_token, "first@example.com", name="مدیر یکم"
)
second = await _owner_creates_user(
client, platform_owner_token, "second@example.com", name="مدیر دوم"
)
first_token = await _login(client, "first@example.com", first["temp_password"])
second_token = await _login(client, "second@example.com", second["temp_password"])
own_org = await _create_org(client, first_token, "سازمان مدیر یکم")
# The second manager has a tenant of their own before being invited.
second_own_org = await _create_org(client, second_token, "سازمان مدیر دوم")
invite = await client.post(
f"/api/organizations/{own_org['id']}/invites",
json={"email": "second@example.com", "role": "admin"},
headers=auth_header(first_token),
)
assert invite.status_code == 201, invite.text
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(second_token),
)
assert accepted.status_code == 200, accepted.text
me = await client.get("/api/auth/me", headers=auth_header(second_token))
roles = {o["id"]: o["role"] for o in me.json()["organizations"]}
# Joined the other manager's organization with the invited role...
assert roles[own_org["id"]] == "admin"
# ...and still owns their own, untouched.
assert roles[second_own_org["id"]] == "owner"
assert len(me.json()["organizations"]) == 2
async def test_promoting_a_plain_user_to_manager_is_possible(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "promoted@example.com", platform_role="user"
)
promoted = await client.patch(
f"/api/admin/users/{created['user']['id']}",
json={"platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert promoted.status_code == 200, promoted.text
assert promoted.json()["platform_role"] == "manager"
# The new tier takes effect immediately: organizations are now allowed.
promoted_token = await _login(
client, "promoted@example.com", created["temp_password"]
)
org = await _create_org(client, promoted_token, "سازمان ارتقایافته")
assert org["name"] == "سازمان ارتقایافته"
async def test_deactivating_an_account_blocks_login(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "frozen@example.com", name="مسدود"
)
response = await client.patch(
f"/api/admin/users/{created['user']['id']}",
json={"is_active": False},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json()["is_active"] is False
login = await client.post(
"/api/auth/login",
json={"email": "frozen@example.com", "password": created["temp_password"]},
)
assert login.status_code == 401
async def test_user_list_reports_roles_and_owned_organization_counts(
client: AsyncClient, platform_owner_token: str
):
manager = await _owner_creates_user(
client, platform_owner_token, "listed@example.com", name="فهرست"
)
manager_token = await _login(client, "listed@example.com", manager["temp_password"])
await _create_org(client, manager_token, "سازمان فهرست")
listing = await client.get("/api/admin/users", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
rows = {u["email"]: u for u in listing.json()}
assert rows["listed@example.com"]["platform_role"] == "manager"
assert rows["listed@example.com"]["owned_organizations"] == 1
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
owner_email = me.json()["user"]["email"]
assert rows[owner_email]["platform_role"] == "owner"
assert rows[owner_email]["is_platform_owner"] is True
async def test_admin_user_endpoints_require_authentication(client: AsyncClient):
assert (await client.get("/api/admin/users")).status_code == 401
assert (
await client.post("/api/admin/users", json={"email": "x@example.com"})
).status_code == 401
async def test_every_seat_move_is_audited(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The transfer and the deletion are the two irreversible moves; both must
leave a trail that names the actor and the moved organizations."""
from app.domain.audit.models import AuditAction
manager = await _owner_creates_user(
client, platform_owner_token, "audited@example.com", name="ممیزی"
)
manager_token = await _login(client, "audited@example.com", manager["temp_password"])
org = await _create_org(client, manager_token, "سازمان ممیزی")
response = await client.delete(
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
)
assert response.status_code == 200
from app.infrastructure.repositories.audit import AuditRepository
async with session_factory() as session:
events = await AuditRepository(session).list_platform(limit=100)
actions = {e.action: e for e in events}
assert AuditAction.USER_CREATE in actions
assert AuditAction.USER_DELETE in actions
delete_event = actions[AuditAction.USER_DELETE]
assert delete_event.metadata_json["transferred_organizations"] == [org["id"]]
assert delete_event.actor_user_id == (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]