Files

555 lines
22 KiB
Python
Raw Permalink Normal View History

"""M2 — Dashboard-Backend tests.
Personal dashboards: per-user dashboards with tabs, JSONB layout,
CRUD + set-default endpoints (owner-only, saved_views precedent),
lazy default seed from the MiniApp registry (permission-filtered),
RLS fail-closed with the 0090 pattern (crm_api + crm_worker), and a
convergent fix for the Phase L policies that were created with only
crm_api (measured live on production 2026-08-30).
"""
from __future__ import annotations
import os
import uuid
import pytest
from httpx import AsyncClient
from pydantic import ValidationError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
os.environ.setdefault("RLS_TEST_ADMIN_DB_URL", "postgresql+asyncpg://postgres@localhost:5432/leocrm_test")
@pytest.fixture(autouse=True)
def _clean_miniapp_registry():
"""Fresh MiniApp registry per test (same pattern as test_miniapp_registry)."""
from app.plugins.miniapp_registry import reset_miniapp_registry
reset_miniapp_registry()
yield
reset_miniapp_registry()
def _register_apps(*specs: tuple[str, str, int, int]) -> None:
"""Register test MiniApps: (app_id, permission, order, col_span).
M4: seed only places renderable apps, so test apps carry a component.
"""
from app.plugins.miniapp_registry import get_miniapp_registry
reg = get_miniapp_registry()
for app_id, permission, order, col_span in specs:
reg.register(
app_id=app_id,
name=app_id.replace("_", " ").title(),
plugin_name="test",
permission=permission,
col_span=col_span,
row_span=1,
hosts=["chat", "dashboard", "window"],
component="@/components/dashboard/TestWidget",
order=order,
)
async def _make_user_with_dashboard_perms(db_session: AsyncSession, seed: dict, email: str, role_name: str):
"""Create a tenant-A user whose role grants only dashboard:read/write."""
from app.core.auth import hash_password
from app.models.role import Role
from app.models.user import User, UserTenant
user = User(
email=email,
name=email.split("@")[0].title(),
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db_session.add(user)
await db_session.flush()
role = Role(
tenant_id=seed["tenant_a"].id,
name=role_name,
permissions={"dashboard": {"read": True, "write": True}},
denied_permissions=[],
field_permissions={},
)
db_session.add(role)
await db_session.flush()
db_session.add(
UserTenant(
user_id=user.id,
tenant_id=seed["tenant_a"].id,
is_default=True,
role=role_name,
role_id=role.id,
)
)
await db_session.commit()
return user
# ═══════════════════════════════════════════════════════════════
# Unit: model, permissions, layout validation
# ═══════════════════════════════════════════════════════════════
class TestModelUnit:
def test_dashboard_model_importable(self):
from app.models.dashboard import Dashboard
assert Dashboard.__tablename__ == "dashboards"
def test_dashboard_model_in_models_package(self):
import app.models as m
from app.models.dashboard import Dashboard
assert m.Dashboard is Dashboard
assert "Dashboard" in m.__all__
def test_dashboard_permissions_registered_in_core(self):
"""dashboard:read/write must be valid core permissions (phantom fix).
Before M2, app/routes/dashboard.py required ``dashboard:read`` but it
was registered nowhere — non-admin users could never be granted it.
"""
from app.core.permission_registry import (
CORE_PERMISSIONS,
PermissionRegistry,
)
keys = {p["key"] for p in CORE_PERMISSIONS}
assert "dashboard:read" in keys
assert "dashboard:write" in keys
reg = PermissionRegistry()
reg.initialize()
assert reg.is_valid("dashboard:read")
assert reg.is_valid("dashboard:write")
class TestLayoutValidation:
def test_valid_layout(self):
from app.schemas.dashboard import DashboardLayout
layout = DashboardLayout.model_validate(
{
"version": 1,
"tabs": [
{
"id": "tab-1",
"name": "Start",
"widgets": [
{
"app_id": "recent_contacts",
"settings": {"limit": 5},
"col": 0,
"row": 0,
"col_span": 2,
"row_span": 1,
}
],
}
],
}
)
assert layout.tabs[0].widgets[0].app_id == "recent_contacts"
def test_empty_layout_ok(self):
from app.schemas.dashboard import DashboardLayout
layout = DashboardLayout.model_validate({"version": 1, "tabs": []})
assert layout.tabs == []
def test_invalid_span_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [
{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 13}]}
],
}
)
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 0}]}],
}
)
def test_negative_col_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col": -1}]}],
}
)
def test_missing_app_id_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"col": 0, "row": 0}]}]}
)
# ═══════════════════════════════════════════════════════════════
# API: CRUD + defaults + ownership
# ═══════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestDashboardCrud:
async def test_requires_auth(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 401
async def test_requires_dashboard_read_permission(self, client: AsyncClient, db_session):
"""Viewer role has no dashboard:read -> 403 (permission enforced)."""
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 403
async def test_create_and_list(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/dashboards",
json={"name": "Vertrieb"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["name"] == "Vertrieb"
assert data["is_default"] is True # first dashboard becomes default
assert data["layout"]["version"] == 1
tabs = data["layout"]["tabs"]
assert len(tabs) == 1 # new dashboards start with an empty "Start" tab
assert tabs[0]["name"] == "Start"
assert tabs[0]["widgets"] == []
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
async def test_lazy_seed_on_first_list(self, client: AsyncClient, db_session):
"""First GET seeds a default dashboard from the registry (order-sorted).
Seed layout is a 12-column flow: widgets are placed side by side in
registry order and wrap to the next row when the row is full.
"""
await seed_tenant_and_users(db_session)
_register_apps(
("app_b", "", 20, 2),
("app_a", "", 10, 2),
("app_wide", "", 30, 12),
)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
seeded = items[0]
assert seeded["is_default"] is True
widgets = seeded["layout"]["tabs"][0]["widgets"]
app_ids = [w["app_id"] for w in widgets]
assert app_ids == ["app_a", "app_b", "app_wide"] # registry order
assert widgets[0]["col_span"] == 2
assert widgets[0]["col"] == 0 and widgets[0]["row"] == 0
assert widgets[1]["col"] == 2 and widgets[1]["row"] == 0
assert widgets[2]["col"] == 0 and widgets[2]["row"] == 1 # 12-span wraps
async def test_seed_filters_by_permission(self, client: AsyncClient, db_session):
"""Seed only includes MiniApps the user may see (fail-closed filter)."""
seed = await seed_tenant_and_users(db_session)
_register_apps(
("open_app", "", 10, 1),
("tasks_app", "tasks:read", 20, 1),
)
await _make_user_with_dashboard_perms(db_session, seed, "dash@tenanta.com", "dash_only")
await login_client(client, "dash@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
widgets = resp.json()[0]["layout"]["tabs"][0]["widgets"]
app_ids = {w["app_id"] for w in widgets}
assert "open_app" in app_ids
assert "tasks_app" not in app_ids # no tasks:read -> filtered out
async def test_duplicate_name_409(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
body = {"name": "Vertrieb"}
r1 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER)
assert r1.status_code == 201
r2 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER)
assert r2.status_code == 409
async def test_get_update_delete(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER)
).json()
dash_id = created["id"]
resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["name"] == "Eins"
new_layout = {
"version": 1,
"tabs": [
{
"id": "tab-1",
"name": "Start",
"widgets": [{"app_id": "recent_contacts", "col": 0, "row": 0}],
}
],
}
resp = await client.put(
f"/api/v1/dashboards/{dash_id}",
json={"name": "Eins Neu", "layout": new_layout},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, resp.text
updated = resp.json()
assert updated["name"] == "Eins Neu"
assert updated["layout"]["tabs"][0]["widgets"][0]["app_id"] == "recent_contacts"
assert updated["layout"]["tabs"][0]["widgets"][0]["col_span"] == 1
# invalid layout -> 422
bad_layout = {"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "x", "col_span": 99}]}]}
resp = await client.put(
f"/api/v1/dashboards/{dash_id}",
json={"layout": bad_layout},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
# invalid uuid -> 400
resp = await client.get("/api/v1/dashboards/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400
# unknown uuid -> 404
resp = await client.get(f"/api/v1/dashboards/{uuid.uuid4()}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
resp = await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
async def test_set_default_and_reassignment(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
d1 = (await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER)).json()
d2 = (await client.post("/api/v1/dashboards", json={"name": "Zwei"}, headers=ORIGIN_HEADER)).json()
assert d1["is_default"] is True
assert d2["is_default"] is False
resp = await client.post(f"/api/v1/dashboards/{d2['id']}/set-default", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["is_default"] is True
items = {d["id"]: d for d in (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()}
assert items[d1["id"]]["is_default"] is False
assert items[d2["id"]]["is_default"] is True
# deleting the default promotes the remaining dashboard
resp = await client.delete(f"/api/v1/dashboards/{d2['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1
assert items[0]["id"] == d1["id"]
assert items[0]["is_default"] is True
async def test_delete_last_reseeds_on_next_list(self, client: AsyncClient, db_session):
"""Empty list state is re-seeded on next GET (documented behaviour)."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1
dash_id = items[0]["id"]
await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1 # re-seeded
assert items[0]["is_default"] is True
async def test_create_audit_logged(self, client: AsyncClient, db_session: AsyncSession):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post("/api/v1/dashboards", json={"name": "Audit"}, headers=ORIGIN_HEADER)
assert resp.status_code == 201
result = await db_session.execute(
text("SELECT action, entity_type FROM audit_log WHERE entity_type = 'dashboard'")
)
rows = result.fetchall()
assert any(r[0] == "create" for r in rows)
@pytest.mark.asyncio
class TestOwnershipAndIsolation:
async def test_other_user_gets_404(self, client: AsyncClient, db_session):
"""Dashboards are personal (saved_views precedent): other users with
dashboard:read see 404 on foreign dashboards, never their content."""
import httpx
from httpx import ASGITransport
import app.main
seed = await seed_tenant_and_users(db_session)
await _make_user_with_dashboard_perms(db_session, seed, "second@tenanta.com", "dash_second")
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "second@tenanta.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
# second user only sees their own (seeded) dashboard
resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
items = resp.json()
assert all(d["id"] != created["id"] for d in items)
async def test_editor_without_permission_gets_403(self, client: AsyncClient, db_session):
"""Editor role has no dashboard:read at all -> 403 on every endpoint."""
import httpx
from httpx import ASGITransport
import app.main
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "editor@tenanta.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 403
resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 403
async def test_cross_tenant_isolation(self, client: AsyncClient, db_session):
"""Tenant B admin never sees tenant A dashboards (RLS + owner filter)."""
import httpx
from httpx import ASGITransport
import app.main
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Tenant A Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "admin@tenantb.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
items = (await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert all(d["id"] != created["id"] for d in items)
# ═══════════════════════════════════════════════════════════════
# Migration 0144: RLS convergence (crm_api + crm_worker)
# ═══════════════════════════════════════════════════════════════
def _admin_db_available() -> bool:
try:
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
url = os.environ.get(
"RLS_TEST_ADMIN_DB_URL",
"postgresql+asyncpg://postgres@localhost:5432/leocrm_test",
)
eng = create_async_engine(url, echo=False)
async def _check():
async with eng.connect() as conn:
await conn.execute(text("SELECT 1"))
asyncio.run(_check())
return True
except Exception:
return False
@pytest.mark.asyncio
@pytest.mark.skipif(not _admin_db_available(), reason="Admin DB not available")
class TestRlsConvergence:
async def test_dashboards_policy_scoped_to_both_roles(self):
from sqlalchemy.ext.asyncio import create_async_engine
eng = create_async_engine(
os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False
)
async with eng.connect() as conn:
result = await conn.execute(
text(
"SELECT roles FROM pg_policies "
"WHERE tablename = 'dashboards' "
"AND policyname = 'dashboards_tenant_isolation'"
)
)
row = result.fetchone()
assert row is not None, "dashboards_tenant_isolation policy missing"
roles = set(row[0])
assert "crm_api" in roles
assert "crm_worker" in roles
await eng.dispose()
async def test_phase_l_policies_converged_to_both_roles(self):
"""0143 created letterheads/print_templates/document_assets with only
crm_api (measured on production 2026-08-30). Migration 0144 converges
them to the 0090 pattern (crm_api + crm_worker)."""
from sqlalchemy.ext.asyncio import create_async_engine
eng = create_async_engine(
os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False
)
async with eng.connect() as conn:
result = await conn.execute(
text(
"SELECT tablename, roles FROM pg_policies "
"WHERE tablename IN ('letterheads','print_templates','document_assets') "
"AND policyname LIKE '%tenant_isolation%'"
)
)
rows = result.fetchall()
assert len(rows) == 3
for tablename, roles in rows:
assert "crm_api" in set(roles), f"{tablename}: crm_api missing"
assert "crm_worker" in set(roles), f"{tablename}: crm_worker missing"
await eng.dispose()