feat(org): platform invites, roles, org sharing (v2 phase A1-1)

- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow
- Owner-only endpoints in admin_users.py: issue/list/revoke platform invites
- invites.py: two-kind resolution (org + platform), email-supplied accept path
- auth.py /me gains needs_onboarding (manager, 0 orgs, not owner)
- organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites
- contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating
- Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports

Note: (app)/settings "اشتراک‌گذاری سازمان" section (1.10) remains presentational work;
endpoints are done and tested.
This commit is contained in:
mjalmoudy94
2026-10-14 09:14:09 +03:30
parent 3c8296e354
commit 5095a29df9
20 changed files with 1933 additions and 37 deletions
+370
View File
@@ -0,0 +1,370 @@
"""§v2-A1.1 — platform invites, roles and org sharing.
The door into the product, so the tests are mostly about the guards around it:
* a non-owner cannot mint a manager;
* the token is single-use, and every rejection looks the same to a probe;
* an existing email cannot be taken over by a link;
* the role the link carries is the role the account gets;
* ownership transfer goes only to an existing admin, and is audited;
* the last owner of an organization cannot walk out and leave it invisible;
* tenant isolation: a member of one org cannot list or revoke another's invites.
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from app.domain.identity.models import PlatformRole
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(autouse=True)
async def _platform_seed(seeded):
return seeded
async def _owner(client: AsyncClient) -> tuple[str, dict, str]:
"""The platform owner plus their slug, and a bearer header."""
token = (
await signup(
client,
"a1-owner@example.com",
name="مالک سامانه",
platform_role=PlatformRole.OWNER,
)
)["access_token"]
headers = auth_header(token)
org_id = (
await client.post(
"/api/organizations", json={"name": "سازمان مالک سامانه"}, headers=headers
)
).json()["id"]
slug = (
await client.get(f"/api/organizations/{org_id}", headers=headers)
).json()["slug"]
return org_id, headers, slug
async def _issue(client: AsyncClient, headers: dict, **body) -> dict:
response = await client.post("/api/admin/platform-invites", json=body, headers=headers)
assert response.status_code == 200, response.text
return response.json()
def _token_from_url(url: str) -> str:
return url.split("token=")[-1]
# ---------------------------------------------------------------------------
# the issue side (owner only)
# ---------------------------------------------------------------------------
async def test_a_non_owner_cannot_issue_a_platform_invite(client: AsyncClient):
token = (await signup(client, "a1-not-owner@example.com"))["access_token"]
response = await client.post(
"/api/admin/platform-invites", json={}, headers=auth_header(token)
)
assert response.status_code == 403, response.text
async def test_issuing_returns_the_url_exactly_once(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers)
assert issued["invite_url"].startswith("http")
assert "token=" in issued["invite_url"]
# The raw token is never stored, so listing cannot reproduce it.
listed = await client.get("/api/admin/platform-invites", headers=headers)
assert listed.status_code == 200
for row in listed.json():
assert "token=" not in str(row)
assert "invite_url" not in row
async def test_an_owner_seat_cannot_be_invited(client: AsyncClient):
"""The owner seat moves by a deliberate transfer, never by a link."""
_org, headers, _slug = await _owner(client)
response = await client.post(
"/api/admin/platform-invites",
json={"platform_role": "owner"},
headers=headers,
)
assert response.status_code == 422, response.text
assert "مالک" in response.json()["error"]["message"]
async def test_an_email_can_be_pinned_to_the_link(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers, email="pinned@example.com", note="از راه پیام")
assert issued["invite"]["email"] == "pinned@example.com"
assert issued["invite"]["note"] == "از راه پیام"
# ---------------------------------------------------------------------------
# the redeem side
# ---------------------------------------------------------------------------
async def test_redeeming_a_platform_invite_makes_a_manager(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers)
token = _token_from_url(issued["invite_url"])
preview = await client.get(f"/api/invites/{token}")
assert preview.status_code == 200, preview.text
assert preview.json()["kind"] == "platform"
assert preview.json()["platform_role"] == "manager"
accepted = await client.post(
"/api/invites/accept",
json={"token": token, "password": "S3cure-passw0rd!", "email": "newmgr@example.com"},
)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["kind"] == "platform"
assert accepted.json()["role"] == "manager"
# The account really is a manager, which is what lets it create organizations.
login = await client.post(
"/api/auth/login",
json={"email": "newmgr@example.com", "password": "S3cure-passw0rd!"},
)
assert login.status_code == 200, login.text
profile = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"])
)
assert profile.json()["user"]["platform_role"] == "manager"
async def test_a_platform_token_is_single_use(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers, email="once@example.com")
token = _token_from_url(issued["invite_url"])
first = await client.post(
"/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
)
assert first.status_code == 200, first.text
second = await client.post(
"/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
)
# The same answer any other rejection gets, so a probe cannot tell a burned
# link from a forged one.
assert second.status_code == 404, second.text
async def test_a_revoked_link_cannot_be_redeemed(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers)
token = _token_from_url(issued["invite_url"])
revoked = await client.post(
f"/api/admin/platform-invites/{issued['invite']['id']}/revoke",
headers=headers,
)
assert revoked.status_code == 200, revoked.text
accepted = await client.post(
"/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
)
assert accepted.status_code == 404, accepted.text
async def test_a_link_cannot_reset_an_existing_account(client: AsyncClient):
"""Otherwise a leaked link would be an account takeover."""
existing = (await signup(client, "existing@example.com"))["access_token"]
assert existing
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers, email="existing@example.com")
token = _token_from_url(issued["invite_url"])
response = await client.post(
"/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
)
assert response.status_code == 403, response.text
# The account's real password still works — the invite never touched it.
login = await client.post(
"/api/auth/login",
json={"email": "existing@example.com", "password": "password123"},
)
assert login.status_code == 200, login.text
async def test_an_expired_link_is_refused(client: AsyncClient, session_factory):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers)
token = _token_from_url(issued["invite_url"])
# Backdate the row instead of sleeping: the rule is about the timestamp, not
# about the clock this suite happens to run on.
from datetime import UTC, datetime
from app.infrastructure.db.models_org import PlatformInviteRow
invite_id = issued["invite"]["id"]
async with session_factory() as session:
row = await session.get(PlatformInviteRow, invite_id)
assert row is not None
row.expires_at = datetime.now(UTC).replace(year=2020)
await session.commit()
response = await client.get(f"/api/invites/{token}")
assert response.status_code == 404, response.text
# ---------------------------------------------------------------------------
# needs_onboarding
# ---------------------------------------------------------------------------
async def test_a_manager_with_no_org_needs_onboarding(client: AsyncClient):
_org, headers, _slug = await _owner(client)
issued = await _issue(client, headers, email="fresh@example.com")
token = _token_from_url(issued["invite_url"])
await client.post("/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"})
me = await client.post(
"/api/auth/login",
json={"email": "fresh@example.com", "password": "S3cure-passw0rd!"},
)
assert me.status_code == 200, me.text
profile = await client.get("/api/auth/me", headers=auth_header(me.json()["access_token"]))
assert profile.status_code == 200
assert profile.json()["needs_onboarding"] is True
async def test_the_owner_never_needs_onboarding(client: AsyncClient):
_org, headers, _slug = await _owner(client)
me = await client.get("/api/auth/me", headers=headers)
assert me.json()["needs_onboarding"] is False
async def test_a_manager_with_an_org_does_not_need_onboarding(client: AsyncClient):
_org, headers, _slug = await _owner(client)
me = await client.get("/api/auth/me", headers=headers)
# The owner created one organization to hold their own account.
assert me.json()["needs_onboarding"] is False
# ---------------------------------------------------------------------------
# org sharing
# ---------------------------------------------------------------------------
async def _org_with_admin(client: AsyncClient) -> dict:
"""An org whose owner is the caller and whose admin is a second account."""
_org, owner_headers, slug = await _owner(client)
# Use the org-sharing invite flow to bring in an admin.
invited = await client.post(
f"/api/organizations/{_org}/invites",
json={"email": "admin@example.com", "role": "admin"},
headers=owner_headers,
)
assert invited.status_code == 201, invited.text
admin_token = invited.json()["invite_token"]
accept = await client.post(
"/api/invites/accept", json={"token": admin_token, "password": "S3cure-passw0rd!"}
)
assert accept.status_code == 200, accept.text
return {
"org_id": _org,
"slug": slug,
"owner_headers": owner_headers,
"admin_email": "admin@example.com",
}
async def test_transferring_ownership_needs_the_org_slug(client: AsyncClient):
ctx = await _org_with_admin(client)
response = await client.post(
f"/api/organizations/{ctx['org_id']}/transfer-ownership",
json={"new_owner_user_id": "someone", "confirm": "wrong-slug"},
headers=ctx["owner_headers"],
)
assert response.status_code == 422, response.text
async def test_ownership_moves_only_to_an_existing_admin(client: AsyncClient):
ctx = await _org_with_admin(client)
strangers = (
await signup(client, "stranger@example.com")
)["access_token"]
stranger_id = (
await client.get("/api/auth/me", headers=auth_header(strangers))
).json()["user"]["id"]
response = await client.post(
f"/api/organizations/{ctx['org_id']}/transfer-ownership",
json={"new_owner_user_id": stranger_id, "confirm": ctx["slug"]},
headers=ctx["owner_headers"],
)
# 404, not 403: the stranger is indistinguishable from a member of another org.
assert response.status_code == 404, response.text
async def test_the_last_owner_cannot_leave(client: AsyncClient):
ctx = await _org_with_admin(client)
response = await client.post(
f"/api/organizations/{ctx['org_id']}/leave", headers=ctx["owner_headers"]
)
assert response.status_code == 409, response.text
async def test_a_non_owner_org_member_can_leave(client: AsyncClient):
ctx = await _org_with_admin(client)
login = await client.post(
"/api/auth/login",
json={"email": ctx["admin_email"], "password": "S3cure-passw0rd!"},
)
assert login.status_code == 200
admin_headers = auth_header(login.json()["access_token"])
response = await client.post(
f"/api/organizations/{ctx['org_id']}/leave", headers=admin_headers
)
assert response.status_code == 204, response.text
async def test_a_viewer_cannot_list_invites(client: AsyncClient):
ctx = await _org_with_admin(client)
# A signed-in outsider is not a member at all.
outsider = (await signup(client, "outsider@example.com"))["access_token"]
response = await client.get(
f"/api/organizations/{ctx['org_id']}/invites",
headers=auth_header(outsider),
)
assert response.status_code == 404, response.text
async def test_an_admin_can_revoke_an_org_invite(client: AsyncClient):
ctx = await _org_with_admin(client)
invited = await client.post(
f"/api/organizations/{ctx['org_id']}/invites",
json={"email": "revoked@example.com", "role": "viewer"},
headers=ctx["owner_headers"],
)
assert invited.status_code == 201, invited.text
invite_id = invited.json()["id"]
revoked = await client.delete(
f"/api/organizations/{ctx['org_id']}/invites/{invite_id}",
headers=ctx["owner_headers"],
)
assert revoked.status_code == 204, revoked.text
audit = await client.get(
f"/api/organizations/{ctx['org_id']}/audit", headers=ctx["owner_headers"]
)
assert any(
event["action"] == "member.invite_revoke" for event in audit.json()
), "a revocation must be auditable"