Files
Ten31-Portal/backend/tests/test_administrator_viewer.py
Jonathan Kirkwood 858bbe10da 0.2.45: Administrator (view only) role
New external role administrator_viewer: signs into the admin interface
and reads everything for its granted funds and SPVs (overview, partners,
capital accounts with every investor's statements, documents, valuation
history) but every write is refused: no imports, uploads, deletions,
entity edits, exit marking, or account management. No migration needed;
roles are stored as strings.

Internal admins can flip an Administrator between full management and
view only via a new Access level dropdown in Users > Manage. The
user-list endpoint is read-widened for the viewer role so investor names
resolve on its screens; all mutating endpoints keep the stricter gate.
2026-08-11 15:37:44 -05:00

121 lines
4.8 KiB
Python

"""Administrator (view only) role (0.2.45): reads everything on its granted entities,
changes nothing anywhere."""
import io
from datetime import date
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, EntityType, 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):
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()
viewer = make_user(session, username="viewonly", role=UserRole.administrator_viewer,
name="Read Only Admin")
session.add(EntityAccess(user_id=viewer.id, entity_id=fund_a.id))
lp = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
session.add(EntityAccess(user_id=lp.id, entity_id=fund_a.id))
session.add(CapitalAccountStatement(
entity_id=fund_a.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
commitment_cents=100_00, beginning_balance_cents=0, contributions_cents=100_00,
distributions_cents=0, ending_balance_cents=110_00,
))
session.commit()
return fund_a, fund_b, viewer, lp
def test_viewer_reads_only_their_fund(client, session, approver):
fund_a, fund_b, viewer, lp = _setup(session)
_login(client, "viewonly")
entities = client.get("/api/entities").json()
assert [e["name"] for e in entities] == ["Fund A"]
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
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
# Sees every investor's statements within the fund, plus their names.
rows = client.get("/api/capital-accounts").json()
assert {r["entity_id"] for r in rows} == {fund_a.id}
users = client.get("/api/users").json()
assert {u["username"] for u in users} == {"lp-a"}
docs = client.get("/api/documents").json()
assert isinstance(docs, list)
def test_viewer_cannot_change_anything(client, session, approver):
fund_a, fund_b, viewer, lp = _setup(session)
_login(client, "viewonly")
# Entity records
assert client.patch(f"/api/entities/{fund_a.id}", json={"name": "X"}).status_code == 403
assert client.put(
f"/api/entities/{fund_a.id}/partners/{lp.id}/exited", json={"exited_on": "2026-06-30"}
).status_code == 403
assert client.delete(f"/api/entities/{fund_a.id}/partners").status_code == 403
# Documents
resp = client.post(
"/api/documents",
data={"entity_id": str(fund_a.id), "category": "statement"},
files={"file": ("x.pdf", io.BytesIO(b"pdf"), "application/pdf")},
)
assert resp.status_code == 403
# Capital accounts
assert client.post("/api/capital-accounts", json={
"entity_id": fund_a.id, "investor_user_id": lp.id, "as_of_date": "2026-06-30",
"commitment_dollars": 1, "beginning_balance_dollars": 0,
"contributions_dollars": 1, "distributions_dollars": 0, "ending_balance_dollars": 1,
}).status_code == 403
# Imports
assert client.post(
f"/api/import/schedule?entity_id={fund_a.id}",
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
).status_code == 403
assert client.post(
"/api/import/capital-accounts/batch",
data={"entity_id": str(fund_a.id)},
files=[("files", ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream"))],
).status_code == 403
# User management
assert client.post("/api/users", json={
"name": "N", "username": "n", "password": "secretpw",
"role": "investor", "entity_ids": [fund_a.id],
}).status_code == 403
assert client.patch(f"/api/users/{lp.id}", json={"is_active": False}).status_code == 403
assert client.delete(f"/api/users/{lp.id}").status_code == 403
assert client.get("/api/users/access-matrix").status_code == 403
def test_internal_admin_toggles_administrator_level(auth_client, session):
admin_acct = make_user(session, username="mgr", role=UserRole.fund_administrator,
name="Managing Admin")
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "administrator_viewer"})
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "administrator_viewer"
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "fund_administrator"})
assert resp.status_code == 200
assert resp.json()["role"] == "fund_administrator"