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:
@@ -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"
|
||||
Reference in New Issue
Block a user