Files
Ten31-Portal/backend/tests/test_administrator.py
Jonathan Kirkwood ae967494bd 0.2.42: external Administrator role with entity-scoped management
The external fund_administrator role (relabeled Administrator) now signs
into the full admin interface, fenced to the funds and SPVs granted to
it via EntityAccess:

- Partners, capital accounts, documents (upload and delete), entity
  edits, and eNAV imports for its own funds only; no fund creation,
  valuation sign-off, audit log, or investor view.
- Scoped user management: sees and manages only investors tied to its
  funds; creates investor accounts only; updates preserve grants on
  funds outside its scope.
- New DELETE /api/users/{id} (in-app Delete user button) with the
  cascade cleanup factored out of the CLI; Service Admin and self are
  protected, and an Administrator can only delete an investor who
  belongs solely to its funds.
- Internal fund_admin relabeled 'Staff (all funds)' and dropped from
  the create picker to end the two-similar-names confusion.
- Version badge removed from the UI (sidebar and portal header); the
  build version now logs to the browser console instead.
- deploy/.startos (signing key) added to .gitignore.
2026-08-10 15:38:39 -05:00

242 lines
9.9 KiB
Python

"""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