"""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).""" 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"], 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()