b3e259fc25
Check Cross-Plugin Imports / check (push) Has been cancelled
- dashboards-Tabelle (Layout JSONB, Tabs, is_default, partial unique name index) - 6 CRUD-Endpoints /api/v1/dashboards, Owner-only (saved_views-Präzedenz), Audit - Lazy Default-Seed aus MiniApp-Registry (permission-gefiltert, 12-Spalten-Flow) - CORE_PERMISSIONS dashboard:read/write (fixt Phantom-Permission in dashboard.py) - Migration 0144: RLS crm_api+crm_worker + konvergenter Fix der 3 Phase-L-Policies - Tests: test_dashboards_backend.py 23/23 (TDD rot->grün); Regression 162/163
551 lines
22 KiB
Python
551 lines
22 KiB
Python
"""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()
|