feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- Core-Resolver resolve_workspace_scope(): Zuweisungs-Check, leere Werte fallen weg; Exemptions System-Admin + workspaces:configure_modules (Editor-Deadlock) - require_workspace_scope(module_key) FastAPI-Dependency (deps.py) - expand_folder_scope(): Ordner-Subtree (zyklensicher) für ContactFolder + DMS Folder; scope_uuid_set() fail-closed - contacts: folder_ids-Subtree + contact_types auf GET /contacts, List-Cache bei aktivem Scope deaktiviert (Cache-Leak-Gefahr) - dms: folder_ids-Subtree + file_types (semantische Matcher) auf /files, Baum-Reduktion auf /folders - mail: account_ids auf /mails, /threads, /accounts - calendar: calendar_ids auf /calendar/entries, /calendars - Frontend-Defaults: getModuleConfig() im workspaceStore, ContactsList default_saved_view_id, Calendar default_view - Tests: 21/21 neu (TDD rot→grün), Regression 81 passed, Checker 0, tsc clean, Vitest grün, Build OK
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
"""N3 — Workspace-Scopes in Backend-Listen (Phase N).
|
||||
|
||||
Backend respects X-Workspace-ID on list endpoints: contacts (folder subtree
|
||||
+ types), dms (folder subtree + file types), mail (account subsets + picker),
|
||||
calendar (calendar subsets + picker). Pure AND-restriction — never grants.
|
||||
|
||||
Exemption rule (editor deadlock): system admins and holders of
|
||||
``workspaces:configure_modules`` bypass scope filtering — the N2 scope
|
||||
editor loads its value options through the same endpoints and needs the
|
||||
unrestricted lists. Admins keep the full view everywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _make_member(
|
||||
db: AsyncSession,
|
||||
seed: dict,
|
||||
email: str,
|
||||
extra_perms: dict[str, dict[str, bool]] | None = None,
|
||||
):
|
||||
"""Create a non-admin user with module read permissions."""
|
||||
from app.core.auth import hash_password
|
||||
from app.models.role import Role
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
perms: dict[str, dict[str, bool]] = {
|
||||
"contacts": {"read": True},
|
||||
"dms": {"read": True},
|
||||
"mail": {"read": True},
|
||||
"calendar": {"read": True},
|
||||
"workspaces": {"read": True},
|
||||
}
|
||||
if extra_perms:
|
||||
for module, actions in extra_perms.items():
|
||||
perms.setdefault(module, {}).update(actions)
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=email.split("@")[0].title(),
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
role = Role(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
name=f"ws-member-{uuid.uuid4().hex[:8]}",
|
||||
permissions=perms,
|
||||
denied_permissions=[],
|
||||
field_permissions={},
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
is_default=True,
|
||||
role="viewer",
|
||||
role_id=role.id,
|
||||
)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _seed_contacts_data(db: AsyncSession, seed: dict, member_id) -> dict:
|
||||
"""Folders F1 > F2 (subtree), F3 (other); contacts across folders.
|
||||
|
||||
Beta is a PERSON inside the folder scope — proves that folder and type
|
||||
scopes combine as AND (Beta passes folder, fails type).
|
||||
"""
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact_folder import ContactFolder
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
f1 = ContactFolder(tenant_id=tenant, name="Vertrieb", user_id=seed["admin_a"].id)
|
||||
db.add(f1)
|
||||
await db.flush()
|
||||
f2 = ContactFolder(tenant_id=tenant, name="Angebote", user_id=seed["admin_a"].id, parent_id=f1.id)
|
||||
db.add(f2)
|
||||
await db.flush()
|
||||
f3 = ContactFolder(tenant_id=tenant, name="Support", user_id=seed["admin_a"].id)
|
||||
db.add(f3)
|
||||
await db.flush()
|
||||
|
||||
def _contact(folder, name, ctype, displayname):
|
||||
return Contact(
|
||||
tenant_id=tenant,
|
||||
folder_id=folder.id if folder else None,
|
||||
type=ctype,
|
||||
name=name,
|
||||
displayname=displayname,
|
||||
owner_id=None,
|
||||
)
|
||||
|
||||
c1 = _contact(f1, "Acme GmbH", "company", "Acme GmbH")
|
||||
c2 = _contact(f2, "Beta Person", "person", "Beta")
|
||||
c3 = _contact(f3, "Gamma Person", "person", "Gamma")
|
||||
c4 = _contact(None, "Delta GmbH", "company", "Delta GmbH")
|
||||
db.add_all([c1, c2, c3, c4])
|
||||
await db.flush()
|
||||
return {"f1": f1, "f2": f2, "f3": f3}
|
||||
|
||||
|
||||
async def _seed_dms_data(db: AsyncSession, seed: dict, member_id) -> dict:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
d1 = Folder(tenant_id=tenant, name="Angebote", created_by=seed["admin_a"].id)
|
||||
db.add(d1)
|
||||
await db.flush()
|
||||
d2 = Folder(tenant_id=tenant, name="2026", created_by=seed["admin_a"].id, parent_id=d1.id)
|
||||
db.add(d2)
|
||||
await db.flush()
|
||||
d3 = Folder(tenant_id=tenant, name="Verträge", created_by=seed["admin_a"].id)
|
||||
db.add(d3)
|
||||
await db.flush()
|
||||
|
||||
def _file(folder, name, mime):
|
||||
return DmsFile(
|
||||
tenant_id=tenant,
|
||||
folder_id=folder.id if folder else None,
|
||||
name=name,
|
||||
mime_type=mime,
|
||||
size_bytes=10,
|
||||
storage_path=f"/tmp/{name}",
|
||||
uploaded_by=seed["admin_a"].id,
|
||||
owner_id=None,
|
||||
)
|
||||
|
||||
files = [
|
||||
_file(d1, "angebot.pdf", "application/pdf"),
|
||||
_file(d2, "logo.png", "image/png"),
|
||||
_file(d3, "vertrag.pdf", "application/pdf"),
|
||||
_file(d1, "brief.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||
_file(None, "lose.pdf", "application/pdf"),
|
||||
]
|
||||
db.add_all(files)
|
||||
await db.flush()
|
||||
return {"d1": d1, "d2": d2, "d3": d3}
|
||||
|
||||
|
||||
async def _seed_mail_data(db: AsyncSession, seed: dict, member_id) -> dict:
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
a1 = MailAccount(
|
||||
tenant_id=tenant,
|
||||
user_id=member_id,
|
||||
email_address="vertrieb@example.com",
|
||||
display_name="Vertrieb",
|
||||
imap_host="imap.example.com",
|
||||
smtp_host="smtp.example.com",
|
||||
username="vertrieb",
|
||||
encrypted_password="x",
|
||||
owner_id=member_id,
|
||||
)
|
||||
a2 = MailAccount(
|
||||
tenant_id=tenant,
|
||||
user_id=member_id,
|
||||
email_address="info@example.com",
|
||||
display_name="Info",
|
||||
imap_host="imap.example.com",
|
||||
smtp_host="smtp.example.com",
|
||||
username="info",
|
||||
encrypted_password="x",
|
||||
owner_id=member_id,
|
||||
)
|
||||
db.add_all([a1, a2])
|
||||
await db.flush()
|
||||
folder = MailFolder(tenant_id=tenant, account_id=a1.id, name="INBOX", imap_name="INBOX")
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
m1 = Mail(
|
||||
tenant_id=tenant,
|
||||
account_id=a1.id,
|
||||
folder_id=folder.id,
|
||||
message_id="<m1@example.com>",
|
||||
subject="Angebot",
|
||||
from_address="kunde@example.com",
|
||||
)
|
||||
m2 = Mail(
|
||||
tenant_id=tenant,
|
||||
account_id=a2.id,
|
||||
folder_id=folder.id,
|
||||
message_id="<m2@example.com>",
|
||||
subject="Rechnung",
|
||||
from_address="kunde2@example.com",
|
||||
)
|
||||
db.add_all([m1, m2])
|
||||
await db.flush()
|
||||
return {"a1": a1, "a2": a2}
|
||||
|
||||
|
||||
async def _seed_calendar_data(db: AsyncSession, seed: dict, member_id) -> dict:
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
cal1 = Calendar(tenant_id=tenant, name="Vertrieb", owner_id=member_id)
|
||||
cal2 = Calendar(tenant_id=tenant, name="Intern", owner_id=member_id)
|
||||
db.add_all([cal1, cal2])
|
||||
await db.flush()
|
||||
now = datetime(2026, 9, 1, 9, 0, tzinfo=UTC)
|
||||
e1 = CalendarEntry(
|
||||
tenant_id=tenant,
|
||||
calendar_id=cal1.id,
|
||||
entry_type="appointment",
|
||||
title="Kundentermin",
|
||||
start_at=now,
|
||||
created_by=member_id,
|
||||
)
|
||||
e2 = CalendarEntry(
|
||||
tenant_id=tenant,
|
||||
calendar_id=cal2.id,
|
||||
entry_type="appointment",
|
||||
title="Team",
|
||||
start_at=now,
|
||||
created_by=member_id,
|
||||
)
|
||||
db.add_all([e1, e2])
|
||||
await db.flush()
|
||||
return {"cal1": cal1, "cal2": cal2}
|
||||
|
||||
|
||||
async def _make_workspace(
|
||||
db: AsyncSession,
|
||||
seed: dict,
|
||||
member,
|
||||
module_configs: dict[str, dict],
|
||||
) -> uuid.UUID:
|
||||
"""Workspace + member assignment + module configs (via service)."""
|
||||
from app.services import workspace_service
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
ws = await workspace_service.create_workspace(
|
||||
db, tenant, seed["admin_a"].id, f"ScopeWS-{uuid.uuid4().hex[:6]}"
|
||||
)
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
await workspace_service.assign_user(db, tenant, ws_id, member.id, role="member")
|
||||
modules = [
|
||||
{"module_key": key, "is_visible": True, "menu_order": i, "config": cfg}
|
||||
for i, (key, cfg) in enumerate(module_configs.items())
|
||||
]
|
||||
if modules:
|
||||
await workspace_service.set_workspace_modules(db, tenant, ws_id, modules)
|
||||
await db.commit()
|
||||
return ws_id
|
||||
|
||||
|
||||
async def _build_seed(
|
||||
db: AsyncSession,
|
||||
member_email: str,
|
||||
config_builder: Callable[[dict], dict[str, dict]],
|
||||
seeders: list,
|
||||
) -> tuple[dict, uuid.UUID, str]:
|
||||
"""Seed tenant + member + data, THEN build the workspace config with real IDs."""
|
||||
seed = await seed_tenant_and_users(db)
|
||||
member = await _make_member(db, seed, member_email)
|
||||
data: dict = {}
|
||||
for seeder in seeders:
|
||||
data.update(await seeder(db, seed, member.id))
|
||||
ws_id = await _make_workspace(db, seed, member, config_builder(data))
|
||||
return data, ws_id, member_email
|
||||
|
||||
|
||||
# ─── Fixtures (4 plugins active: contacts, dms, mail, calendar) ─
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with the four N3 plugins registered and activated."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.permission_registry import (
|
||||
init_permission_registry,
|
||||
register_plugin_permissions,
|
||||
)
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.builtins.calendar.plugin import CalendarPlugin
|
||||
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
||||
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||
from app.plugins.builtins.mail.plugin import MailPlugin
|
||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
init_permission_registry(active_plugin_names={"contacts", "dms", "mail", "calendar", "permissions"})
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
for plugin in (
|
||||
PermissionsPlugin(),
|
||||
ContactsPlugin(),
|
||||
DmsPlugin(),
|
||||
MailPlugin(),
|
||||
CalendarPlugin(),
|
||||
):
|
||||
registry.register_plugin(plugin)
|
||||
if plugin.manifest.permissions:
|
||||
register_plugin_permissions(plugin.name, plugin.manifest.permissions)
|
||||
reset_plugin_service_for_testing(registry)
|
||||
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with sf() as session:
|
||||
# permissions first (dms depends on it)
|
||||
for name in ("permissions", "contacts", "dms", "mail", "calendar"):
|
||||
await registry.install(session, name)
|
||||
await registry.activate(session, name)
|
||||
await session.commit()
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_client(scope_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=scope_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed(scope_app, db_session: AsyncSession) -> tuple:
|
||||
"""Contacts: folder scope F1 (subtree includes F2)."""
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-member@example.com",
|
||||
lambda data: {"contacts": {"folder_ids": [str(data["f1"].id)]}},
|
||||
[_seed_contacts_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_types(scope_app, db_session: AsyncSession) -> tuple:
|
||||
"""Contacts: type scope companies only."""
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-types@example.com",
|
||||
lambda data: {"contacts": {"contact_types": ["company"]}},
|
||||
[_seed_contacts_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_combined(scope_app, db_session: AsyncSession) -> tuple:
|
||||
"""Contacts: folder + type scopes combined (AND proof)."""
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-combined@example.com",
|
||||
lambda data: {
|
||||
"contacts": {
|
||||
"folder_ids": [str(data["f1"].id)],
|
||||
"contact_types": ["company"],
|
||||
}
|
||||
},
|
||||
[_seed_contacts_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_dms(scope_app, db_session: AsyncSession) -> tuple:
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-dms@example.com",
|
||||
lambda data: {"dms": {"folder_ids": [str(data["d1"].id)]}},
|
||||
[_seed_dms_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_dms_types(scope_app, db_session: AsyncSession) -> tuple:
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-dms-types@example.com",
|
||||
lambda data: {"dms": {"file_types": ["image/"]}},
|
||||
[_seed_dms_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_mail(scope_app, db_session: AsyncSession) -> tuple:
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-mail@example.com",
|
||||
lambda data: {"mail": {"account_ids": [str(data["a1"].id)]}},
|
||||
[_seed_mail_data],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def scope_seed_cal(scope_app, db_session: AsyncSession) -> tuple:
|
||||
return await _build_seed(
|
||||
db_session,
|
||||
"scope-cal@example.com",
|
||||
lambda data: {"calendar": {"calendar_ids": [str(data["cal1"].id)]}},
|
||||
[_seed_calendar_data],
|
||||
)
|
||||
|
||||
|
||||
# ─── Resolver unit tests ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveWorkspaceScope:
|
||||
async def test_no_header_returns_none(self, db_session: AsyncSession):
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
user = {"tenant_id": str(seed["tenant_a"].id), "user_id": str(seed["admin_a"].id)}
|
||||
assert await resolve_workspace_scope(db_session, user, None, "contacts") is None
|
||||
|
||||
async def test_invalid_header_returns_none(self, db_session: AsyncSession):
|
||||
"""Invalid UUID = no workspace context = no filter (fail-open to RLS/ABAC)."""
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
}
|
||||
assert await resolve_workspace_scope(db_session, user, "not-a-uuid", "contacts") is None
|
||||
|
||||
async def test_system_admin_exempt(self, db_session: AsyncSession):
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "exempt-admin@example.com")
|
||||
ws_id = await _make_workspace(
|
||||
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
|
||||
)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(member.id),
|
||||
"is_system_admin": True,
|
||||
"permissions": ["contacts:read"],
|
||||
}
|
||||
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
|
||||
|
||||
async def test_configure_modules_holder_exempt(self, db_session: AsyncSession):
|
||||
"""Editor deadlock: scope editors need the unrestricted value lists."""
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
editor = await _make_member(
|
||||
db_session,
|
||||
seed,
|
||||
"editor@example.com",
|
||||
extra_perms={"workspaces": {"configure_modules": True}},
|
||||
)
|
||||
ws_id = await _make_workspace(
|
||||
db_session, seed, editor, {"contacts": {"contact_types": ["company"]}}
|
||||
)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(editor.id),
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read", "workspaces:configure_modules"],
|
||||
}
|
||||
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
|
||||
|
||||
async def test_unassigned_user_returns_none(self, db_session: AsyncSession):
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "member@example.com")
|
||||
other = await _make_member(db_session, seed, "other@example.com")
|
||||
ws_id = await _make_workspace(
|
||||
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
|
||||
)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(other.id),
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
}
|
||||
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
|
||||
|
||||
async def test_assigned_user_gets_config(self, db_session: AsyncSession):
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "member@example.com")
|
||||
ws_id = await _make_workspace(
|
||||
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
|
||||
)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(member.id),
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
}
|
||||
scope = await resolve_workspace_scope(db_session, user, str(ws_id), "contacts")
|
||||
assert scope == {"contact_types": ["company"]}
|
||||
|
||||
async def test_module_without_config_returns_none(self, db_session: AsyncSession):
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "member@example.com")
|
||||
ws_id = await _make_workspace(
|
||||
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
|
||||
)
|
||||
user = {
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"user_id": str(member.id),
|
||||
"is_system_admin": False,
|
||||
"permissions": ["dms:read"],
|
||||
}
|
||||
assert await resolve_workspace_scope(db_session, user, str(ws_id), "dms") is None
|
||||
|
||||
|
||||
# ─── HTTP filtering: contacts ──────────────────────────────────
|
||||
|
||||
|
||||
class TestContactsScopeFiltering:
|
||||
async def test_folder_scope_includes_subtree(
|
||||
self, scope_client: AsyncClient, scope_seed: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/contacts", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {i["displayname"] for i in resp.json()["items"]}
|
||||
# F1 + subtree F2: Acme (company, F1) + Beta (person, F2)
|
||||
assert names == {"Acme GmbH", "Beta"}, names
|
||||
|
||||
async def test_type_scope_companies_only(
|
||||
self, scope_client: AsyncClient, scope_seed_types: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_types
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/contacts", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {i["displayname"] for i in resp.json()["items"]}
|
||||
# companies only: Acme (F1) + Delta (no folder) + Company Alpha
|
||||
# (created by the conftest seed_tenant_and_users helper)
|
||||
assert names == {"Acme GmbH", "Delta GmbH", "Company Alpha"}, names
|
||||
|
||||
async def test_combined_scope_is_and(
|
||||
self, scope_client: AsyncClient, scope_seed_combined: tuple
|
||||
):
|
||||
"""Folder AND type: Beta is inside the folder scope but is a person
|
||||
and must be excluded — the sharp AND proof."""
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_combined
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/contacts", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {i["displayname"] for i in resp.json()["items"]}
|
||||
assert names == {"Acme GmbH"}, names
|
||||
|
||||
async def test_no_header_no_filter(
|
||||
self, scope_client: AsyncClient, scope_seed: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed
|
||||
await login_client(client, member_email)
|
||||
|
||||
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 4 own contacts + Company Alpha from the conftest seed
|
||||
assert resp.json()["total"] == 5
|
||||
|
||||
async def test_scope_bypasses_list_cache(
|
||||
self, scope_client: AsyncClient, scope_seed: tuple
|
||||
):
|
||||
"""First unscoped GET caches; the scoped GET must NOT serve that cache."""
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed
|
||||
await login_client(client, member_email)
|
||||
|
||||
unscoped = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||
assert unscoped.status_code == 200
|
||||
assert unscoped.json()["total"] == 5 # cacheable simple query
|
||||
|
||||
scoped = await client.get(
|
||||
"/api/v1/contacts",
|
||||
headers={**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)},
|
||||
)
|
||||
assert scoped.json()["total"] == 2, "Scoped list must not serve the unscoped cache"
|
||||
|
||||
|
||||
# ─── HTTP filtering: dms ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestDmsScopeFiltering:
|
||||
async def test_folder_scope_subtree_files(
|
||||
self, scope_client: AsyncClient, scope_seed_dms: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_dms
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/dms/files", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {f["name"] for f in resp.json()}
|
||||
# d1 + subtree d2: angebot.pdf + brief.docx (d1) + logo.png (d2)
|
||||
assert names == {"angebot.pdf", "brief.docx", "logo.png"}, names
|
||||
|
||||
async def test_file_type_scope_images_only(
|
||||
self, scope_client: AsyncClient, scope_seed_dms_types: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_dms_types
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/dms/files", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {f["name"] for f in resp.json()}
|
||||
assert names == {"logo.png"}, names
|
||||
|
||||
async def test_folder_tree_scoped(
|
||||
self, scope_client: AsyncClient, scope_seed_dms: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_dms
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/dms/folders", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def walk(nodes):
|
||||
for n in nodes:
|
||||
yield n["name"]
|
||||
yield from walk(n.get("children", []))
|
||||
|
||||
names = set(walk(resp.json()))
|
||||
assert names == {"Angebote", "2026"}, names
|
||||
|
||||
|
||||
# ─── HTTP filtering: mail ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestMailScopeFiltering:
|
||||
async def test_mails_scoped_to_account(
|
||||
self, scope_client: AsyncClient, scope_seed_mail: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_mail
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/mail", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
subjects = {m["subject"] for m in resp.json()["mails"]}
|
||||
assert subjects == {"Angebot"}, subjects
|
||||
|
||||
async def test_accounts_picker_scoped(
|
||||
self, scope_client: AsyncClient, scope_seed_mail: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_mail
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/mail/accounts", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
addresses = {a["email"] for a in resp.json()}
|
||||
assert addresses == {"vertrieb@example.com"}, addresses
|
||||
|
||||
async def test_threads_scoped(
|
||||
self, scope_client: AsyncClient, scope_seed_mail: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_mail
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/mail/threads", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
subjects = {th["subject"] for th in resp.json()}
|
||||
assert subjects == {"Angebot"}, subjects
|
||||
|
||||
|
||||
# ─── HTTP filtering: calendar ─────────────────────────────────
|
||||
|
||||
|
||||
class TestCalendarScopeFiltering:
|
||||
async def test_entries_scoped_to_calendar(
|
||||
self, scope_client: AsyncClient, scope_seed_cal: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_cal
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/calendar/entries", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {e["title"] for e in resp.json()}
|
||||
assert titles == {"Kundentermin"}, titles
|
||||
|
||||
async def test_calendars_picker_scoped(
|
||||
self, scope_client: AsyncClient, scope_seed_cal: tuple
|
||||
):
|
||||
client = scope_client
|
||||
data, ws_id, member_email = scope_seed_cal
|
||||
await login_client(client, member_email)
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/calendars", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {c["name"] for c in resp.json()}
|
||||
assert names == {"Vertrieb"}, names
|
||||
|
||||
async def test_admin_bypass_still_sees_all(
|
||||
self, scope_client: AsyncClient, scope_seed_cal: tuple
|
||||
):
|
||||
"""Tenant admin (workspaces:configure_modules via *:*) keeps the full view."""
|
||||
client = scope_client
|
||||
data, ws_id, _ = scope_seed_cal
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await client.get("/api/v1/calendars", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {c["name"] for c in resp.json()}
|
||||
assert names == {"Vertrieb", "Intern"}, names
|
||||
Reference in New Issue
Block a user