"""Regression tests for the pre-release security audit fixes. Covers three classes of finding: - Cross-tenant investor takeover by an external Administrator (scope-check on every write that can mint an access grant or a capital statement for an arbitrary investor). - Internal privilege escalation via role/is_active mass-assignment on the user endpoints. - Server-side enforcement of the forced password change (not just the browser modal). """ from datetime import date from sqlmodel import select from ten31portal.models import ( CapitalAccountStatement, 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 _two_funds(session): """Fund A (managed by `fundadmin`) and Fund B. lp_a is in A, lp_b is in B (out of scope).""" 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 # --- P0-1: cross-tenant investor takeover ------------------------------------------------ def test_administrator_cannot_grant_out_of_scope_investor(client, session, approver): """The takeover chain: grant an out-of-fund investor access to my fund, which would pull them into scope and unlock reset-password. The grant itself must be refused.""" fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) _login(client, "fundadmin") resp = client.put(f"/api/users/{lp_b.id}/access/{fund_a.id}") assert resp.status_code == 403, resp.text # No EntityAccess row was minted, so lp_b never entered the Administrator's scope. rows = session.exec( select(EntityAccess).where( EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id ) ).all() assert rows == [] # And the follow-on reset-password stays blocked. assert client.post(f"/api/users/{lp_b.id}/reset-password", json={"password": "attacker-set-pw"}).status_code == 403 def test_administrator_cannot_create_statement_for_out_of_scope_investor(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) _login(client, "fundadmin") resp = client.post("/api/capital-accounts", json={ "entity_id": fund_a.id, "investor_user_id": lp_b.id, "as_of_date": "2026-03-31", "commitment_dollars": 1000, "beginning_balance_dollars": 0, "contributions_dollars": 1000, "distributions_dollars": 0, "ending_balance_dollars": 1000, }) assert resp.status_code == 403, resp.text assert session.exec( select(CapitalAccountStatement).where( CapitalAccountStatement.investor_user_id == lp_b.id ) ).all() == [] def test_administrator_cannot_mint_exit_grant_for_out_of_scope_investor(client, session, approver): fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) _login(client, "fundadmin") resp = client.put(f"/api/entities/{fund_a.id}/partners/{lp_b.id}/exited", json={"exited_on": "2026-06-30"}) assert resp.status_code == 403, resp.text assert session.exec( select(EntityAccess).where( EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id ) ).all() == [] def test_administrator_can_still_manage_in_scope_investor(client, session, approver): """Positive control: the legitimate in-scope path is untouched.""" fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) _login(client, "fundadmin") # lp_a is already in fund_a, so a statement and a password reset both succeed. assert client.post("/api/capital-accounts", json={ "entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-03-31", "commitment_dollars": 500, "beginning_balance_dollars": 0, "contributions_dollars": 500, "distributions_dollars": 0, "ending_balance_dollars": 500, }).status_code == 201 assert client.post(f"/api/users/{lp_a.id}/reset-password", json={"password": "a-fresh-password"}).status_code == 200 # --- P0-2: internal privilege escalation via mass-assignment ----------------------------- def test_operations_cannot_self_promote_to_approver(client, session): ops = make_user(session, username="ops", role=UserRole.operations) _login(client, "ops") resp = client.patch(f"/api/users/{ops.id}", json={"role": "approver"}) assert resp.status_code == 403, resp.text session.refresh(ops) assert ops.role == UserRole.operations def test_operations_cannot_create_approver(client, session): make_user(session, username="ops", role=UserRole.operations) _login(client, "ops") resp = client.post("/api/users", json={ "name": "Sneaky", "username": "sneaky", "password": "password123", "role": "approver", }) assert resp.status_code == 403, resp.text def test_operations_cannot_deactivate_service_admin(client, session): make_user(session, username="ops", role=UserRole.operations) svc = make_user(session, username="svc", role=UserRole.approver, is_service_admin=True) _login(client, "ops") resp = client.patch(f"/api/users/{svc.id}", json={"is_active": False}) assert resp.status_code == 403, resp.text session.refresh(svc) assert svc.is_active is True def test_operations_cannot_disable_an_approver(client, session): make_user(session, username="ops", role=UserRole.operations) boss = make_user(session, username="boss", role=UserRole.approver) _login(client, "ops") resp = client.patch(f"/api/users/{boss.id}", json={"is_active": False}) assert resp.status_code == 403, resp.text def test_approver_can_still_manage_roles(client, session): """Positive control: an approver retains full role management.""" approver = make_user(session, username="chief", role=UserRole.approver) ops = make_user(session, username="ops", role=UserRole.operations) _login(client, "chief") assert client.patch(f"/api/users/{ops.id}", json={"role": "approver"}).status_code == 200 session.refresh(ops) assert ops.role == UserRole.approver def test_operations_can_still_edit_ordinary_fields(client, session): """Non-privileged edits by an internal admin keep working.""" make_user(session, username="ops", role=UserRole.operations) viewer = make_user(session, username="v", role=UserRole.viewer, name="Old Name") _login(client, "ops") assert client.patch(f"/api/users/{viewer.id}", json={"name": "New Name"}).status_code == 200 session.refresh(viewer) assert viewer.name == "New Name" # --- P0-3: server-side forced password change -------------------------------------------- def test_must_change_password_blocks_api_until_rotated(client, session): make_user(session, username="lp", role=UserRole.investor, password="Ten31Portal", must_change_password=True) resp = client.post("/api/auth/login", json={"login": "lp", "password": "Ten31Portal"}) assert resp.status_code == 200, resp.text # /me is allowed (the client needs it to learn it must prompt), but data endpoints are not. assert client.get("/api/auth/me").status_code == 200 assert client.get("/api/entities").status_code == 403 assert client.get("/api/documents").status_code == 403 # Rotating the password lifts the block. changed = client.post("/api/auth/change-password", json={ "current_password": "Ten31Portal", "new_password": "my-own-strong-pw", }) assert changed.status_code == 200, changed.text assert client.get("/api/entities").status_code == 200 # --- P1/P2: transport and header hardening ----------------------------------------------- def test_security_headers_present(client): resp = client.get("/api/health") assert resp.headers["X-Content-Type-Options"] == "nosniff" assert resp.headers["X-Frame-Options"] == "DENY" assert "default-src 'self'" in resp.headers["Content-Security-Policy"] assert "frame-ancestors 'none'" in resp.headers["Content-Security-Policy"] def test_session_cookie_is_hardened(client, session): make_user(session, username="u", role=UserRole.approver) resp = client.post("/api/auth/login", json={"login": "u", "password": "password123"}) assert resp.status_code == 200 set_cookie = resp.headers["set-cookie"].lower() assert "httponly" in set_cookie assert "samesite=lax" in set_cookie assert "max-age=" in set_cookie # bounded lifetime, not the 14-day default def test_download_is_no_store(client, session, tmp_path, monkeypatch): """Document downloads must not be cached on shared machines.""" from ten31portal import storage from ten31portal.models import Document, DocumentCategory, Entity, EntityType monkeypatch.setattr(storage, "DOCS_DIR", str(tmp_path)) (tmp_path / "file.pdf").write_bytes(b"%PDF-1.4 test") ent = Entity(name="F", type=EntityType.fund) session.add(ent) session.commit() doc = Document(entity_id=ent.id, category=DocumentCategory.k1, title="K1", original_filename="k1.pdf", content_type="application/pdf", size_bytes=13, storage_path="file.pdf", uploaded_by=None) session.add(doc) session.commit() make_user(session, username="staff", role=UserRole.approver) _login(client, "staff") resp = client.get(f"/api/documents/{doc.id}/download") assert resp.status_code == 200, resp.text assert resp.headers["Cache-Control"] == "no-store" # --- P2-9: import preview must not leak cross-fund investors ------------------------------ def test_external_admin_preview_requires_a_fund(client, session, approver): _two_funds(session) _login(client, "fundadmin") # No entity_id: refused before any name-matching can leak which accounts exist. resp = client.post( "/api/import/capital-accounts/preview", files={"file": ("x.xlsx", b"not-a-real-workbook", "application/octet-stream")}, ) assert resp.status_code == 400, resp.text