"""External Administrator role (0.2.42): full management, fenced to granted entities. An Administrator (UserRole.fund_administrator) runs the same admin screens as internal staff — users, partners, documents, capital accounts, imports — but only for the funds granted to them via EntityAccess. These tests pin the fence. """ import io from datetime import date from sqlmodel import select from ten31portal.models import ( CapitalAccountStatement, Document, Entity, EntityAccess, EntityType, User, UserRole, ) from tests.conftest import make_user def _login(client, username, password="password123"): client.post("/api/auth/logout") resp = client.post("/api/auth/login", json={"login": username, "password": password}) assert resp.status_code == 200, resp.text return resp def _setup(session): """Two funds; the Administrator manages fund A only. One LP in each fund.""" fund_a = Entity(name="Fund A", type=EntityType.fund) fund_b = Entity(name="Fund B", type=EntityType.fund) session.add(fund_a) session.add(fund_b) session.commit() admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator, name="Outside Administrator") session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id)) lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha") session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id)) lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta") session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id)) session.commit() return fund_a, fund_b, admin, lp_a, lp_b def _stmt(entity_id, investor_id, as_of=date(2026, 3, 31), balance=1_000_000_00): return CapitalAccountStatement( entity_id=entity_id, investor_user_id=investor_id, as_of_date=as_of, commitment_cents=balance, beginning_balance_cents=0, contributions_cents=balance, distributions_cents=0, ending_balance_cents=balance, ) def test_administrator_sees_only_their_funds_users(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) _login(client, "fundadmin") users = client.get("/api/users").json() assert {u["username"] for u in users} == {"lp-a"} # Their fund's investor is reachable; the other fund's — and staff — are not. assert client.get(f"/api/users/{lp_a.id}").status_code == 200 assert client.get(f"/api/users/{lp_b.id}").status_code == 403 assert client.get(f"/api/users/{approver.id}").status_code == 403 def test_administrator_creates_investors_only_on_their_funds(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) _login(client, "fundadmin") # Investor on fund A: allowed; out-of-scope fund ids are silently dropped. resp = client.post("/api/users", json={ "name": "New LP", "username": "new-lp", "password": "secretpw", "role": "investor", "entity_ids": [fund_a.id, fund_b.id], }) assert resp.status_code == 201, resp.text assert resp.json()["entity_ids"] == [fund_a.id] # Staff roles are privilege escalation. resp = client.post("/api/users", json={ "name": "Sneaky", "username": "sneaky", "password": "secretpw", "role": "operations", "entity_ids": [], }) assert resp.status_code == 403 # An investor with no in-scope fund would be invisible to its creator. resp = client.post("/api/users", json={ "name": "Orphan", "username": "orphan", "password": "secretpw", "role": "investor", "entity_ids": [fund_b.id], }) assert resp.status_code == 400 def test_administrator_partner_management_is_scoped(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) _login(client, "fundadmin") assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200 assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403 resp = client.put( f"/api/entities/{fund_a.id}/partners/{lp_a.id}/exited", json={"exited_on": "2026-06-30"}, ) assert resp.status_code == 200 resp = client.put( f"/api/entities/{fund_b.id}/partners/{lp_b.id}/exited", json={"exited_on": "2026-06-30"}, ) assert resp.status_code == 403 def test_administrator_documents_upload_and_delete(client, session, approver, monkeypatch, tmp_path): monkeypatch.setattr("ten31portal.storage.DOCS_DIR", str(tmp_path)) fund_a, fund_b, admin, lp_a, lp_b = _setup(session) doc_b = Document( entity_id=fund_b.id, category="statement", title="B statement", original_filename="b.pdf", content_type="application/pdf", size_bytes=1, storage_path="x-b", ) session.add(doc_b) session.commit() _login(client, "fundadmin") resp = client.post( "/api/documents", data={"entity_id": str(fund_a.id), "category": "statement", "title": "Q1 statement"}, files={"file": ("q1.pdf", io.BytesIO(b"pdf"), "application/pdf")}, ) assert resp.status_code == 201, resp.text doc_a_id = resp.json()["id"] assert client.delete(f"/api/documents/{doc_a_id}").json() == {"status": "deleted"} assert client.delete(f"/api/documents/{doc_b.id}").status_code == 403 def test_administrator_capital_accounts_are_scoped(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) session.add(_stmt(fund_a.id, lp_a.id)) stmt_b = _stmt(fund_b.id, lp_b.id) session.add(stmt_b) session.commit() _login(client, "fundadmin") rows = client.get("/api/capital-accounts").json() assert {r["entity_id"] for r in rows} == {fund_a.id} resp = client.post("/api/capital-accounts", json={ "entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-06-30", "commitment_dollars": 100, "beginning_balance_dollars": 0, "contributions_dollars": 100, "distributions_dollars": 0, "ending_balance_dollars": 110, }) assert resp.status_code == 201, resp.text resp = client.post("/api/capital-accounts", json={ "entity_id": fund_b.id, "investor_user_id": lp_b.id, "as_of_date": "2026-06-30", "commitment_dollars": 100, "beginning_balance_dollars": 0, "contributions_dollars": 100, "distributions_dollars": 0, "ending_balance_dollars": 110, }) assert resp.status_code == 403 assert client.delete(f"/api/capital-accounts/{stmt_b.id}").status_code == 403 def test_administrator_deletes_only_investors_solely_in_their_funds(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) # lp_a also joins fund B — deleting them would reach beyond the Administrator's fence. session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id)) solo = make_user(session, username="solo", role=UserRole.investor, name="Solo LP") session.add(EntityAccess(user_id=solo.id, entity_id=fund_a.id)) session.commit() solo_id = solo.id _login(client, "fundadmin") assert client.delete(f"/api/users/{lp_a.id}").status_code == 403 assert client.delete(f"/api/users/{approver.id}").status_code == 403 assert client.delete(f"/api/users/{solo_id}").json() == {"status": "deleted"} session.expire_all() # the API deleted through its own session; drop our cached copy assert session.exec(select(User).where(User.id == solo_id)).first() is None assert session.exec( select(EntityAccess).where(EntityAccess.user_id == solo_id) ).first() is None def test_internal_admin_delete_guards(auth_client, session, approver): service = make_user(session, username="svc", role=UserRole.operations, is_service_admin=True) victim = make_user(session, username="victim", role=UserRole.investor) assert auth_client.delete(f"/api/users/{service.id}").status_code == 400 assert auth_client.delete(f"/api/users/{approver.id}").status_code == 400 # self assert auth_client.delete(f"/api/users/{victim.id}").json() == {"status": "deleted"} def test_administrator_update_preserves_other_funds_grants(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id)) session.commit() _login(client, "fundadmin") # Submitting only in-scope grants must not strip the investor's fund B access. resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": [fund_a.id]}) assert resp.status_code == 200 assert set(resp.json()["entity_ids"]) == {fund_a.id, fund_b.id} # Revoking their own fund keeps fund B untouched. resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": []}) assert resp.status_code == 200 assert resp.json()["entity_ids"] == [fund_b.id] def test_administrator_blocked_from_internal_surfaces(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) _login(client, "fundadmin") assert client.get("/api/audit").status_code == 403 assert client.post("/api/entities", json={"name": "New Fund", "type": "fund"}).status_code == 403 # eNAV import into a fund outside their grants (or with no fund chosen) is refused. resp = client.post( "/api/import/schedule", files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")}, ) assert resp.status_code == 403 resp = client.post( f"/api/import/schedule?entity_id={fund_b.id}", files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")}, ) assert resp.status_code == 403 def test_administrator_reads_rounds_and_holdings_in_scope(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _setup(session) _login(client, "fundadmin") assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200 assert client.get(f"/api/entities/{fund_b.id}/rounds").status_code == 403 assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200 assert client.get(f"/api/entities/{fund_b.id}/holdings").status_code == 403