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:
+20
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
||||
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section).
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract: workspace_scopes() für contacts/dms/mail/calendar, Pydantic fail-closed, `/api/v1/workspaces/scope-definitions` — siehe Phase-N1-Section). **N2 ✓** (Dynamischer Scope-Editor: WorkspaceScopeEditor ersetzt JSON-Textarea, Filter-UI aus Registry, Speicherung workspace_modules.config — siehe Phase-N2-Section). **Nächster Schritt:** N3 Erste vier Module integrieren (Backend respektiert X-Workspace-ID bei Listen, additive UND-Einschränkung).
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung: contacts Ordner-Subtree/Typen + Cache-Bypass, dms Ordner/Datei-Typen, mail Postfächer, calendar Kalender; Frontend-Defaults Saved-View/Kalender-Ansicht — siehe Phase-N3-Section). **Nächster Schritt:** N4 Restliche Module (Tasks, Kommunikation, Wiki, Reports/Dokumente, Automation, Tags, Suche, Navigation; Dashboard-Schnittstelle workspace_widgets).
|
||||
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
|
||||
|
||||
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||
@@ -104,6 +104,25 @@
|
||||
|
||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
||||
|
||||
## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅
|
||||
|
||||
**Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||
|
||||
**Umgesetzt:**
|
||||
- **Core-Resolver** `resolve_workspace_scope()` (workspace_scope_service.py): X-Workspace-ID → Workspace aktiv/Tenant → User-Zuweisung → Modul-config; leere Dimensionswerte fallen weg. Exemptions: System-Admins + `workspaces:configure_modules`-Inhaber — löst den Editor-Deadlock (N2-Scope-Editor lädt Wertoptionen über dieselben Endpoints).
|
||||
- **FastAPI-Dependency** `require_workspace_scope(module_key)` (deps.py) — Header-Parsing gekapselt, einzeilige Nutzung pro Route.
|
||||
- **Ordner-Subtree** `expand_folder_scope()`: self + descendants (zyklensicher) für ContactFolder + DMS Folder — Ordner-Scopes gelten inkl. Unterordnern. `scope_uuid_set()`: fail-closed (garbage UUIDs → leere Menge).
|
||||
- **Listen-Filter (additive UND-Einschränkung, kein Umbau):** contacts (folder_ids-Subtree + contact_types auf GET /contacts; List-Cache bei aktivem Scope deaktiviert — Cross-Workspace-Leak-Gefahr beseitigt), dms (folder_ids-Subtree + file_types auf GET /files, Baum-Reduktion auf GET /folders; semantische Typ-Matcher pdf/image/spreadsheet/word/other), mail (account_ids auf GET /mails, /threads, /accounts-Picker), calendar (calendar_ids auf GET /calendar/entries + /calendars-Picker).
|
||||
- **Frontend-Defaults:** `getModuleConfig(moduleKey)` im workspaceStore; ContactsList wendet `default_saved_view_id` beim Mount an (admin-definierte Standard-Ansicht), Calendar setzt `default_view` (day/week/month/range) bei Workspace-Wechsel.
|
||||
|
||||
**Verifiziert (2026-09-01):**
|
||||
- TDD: Rot (7 ImportError + 14 Fixture-Errors) → ✅ Grün **21/21** (Resolver 7, Contacts 5 mit Cache-Bypass-Beweis + scharfem AND-Beweis (Beta=Person im Ordner-Scope fällt raus), DMS 3, Mail 3, Calendar 3 inkl. Admin-Bypass)
|
||||
- ✅ Regression: N1 + N2 + Workspaces + test_mail **81 passed**
|
||||
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: nur per Stash bewiesener Vorbestand (N806/UP017)
|
||||
- ✅ Frontend: tsc clean, Vitest (workspaceStore 16/16, CalendarPage, ContactsList) grün, Production-Build OK
|
||||
|
||||
**Offen in Phase N:** N4 restliche Module (Tasks „nur meine", Kommunikation-Räume, Wiki-Kategorien, Reports-Vorlagen, Automation-Agenten, Tags, Search-Provider, Navigation-Defaults) + Dashboard-Schnittstelle (workspace_widgets begrenzt Widget-TYP-Angebot).
|
||||
|
||||
## Phase N2 — Dynamischer Scope-Editor (2026-09-01) ✅
|
||||
|
||||
**Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||
|
||||
+25
-1
@@ -7,7 +7,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -382,6 +382,30 @@ async def get_current_user_id(
|
||||
return uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
def require_workspace_scope(module_key: str):
|
||||
"""FastAPI dependency factory (Phase N3): resolve the active workspace
|
||||
scope config for a module from the X-Workspace-ID header.
|
||||
|
||||
Returns the scope dict (e.g. ``{"folder_ids": [...]}``) or ``None``
|
||||
when no restriction applies (no header, admin, unassigned, empty config).
|
||||
Callers apply it as a pure AND-restriction — never a grant.
|
||||
|
||||
Usage:
|
||||
scope: dict | None = Depends(require_workspace_scope("contacts"))
|
||||
"""
|
||||
|
||||
async def _resolve(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"),
|
||||
) -> dict[str, Any] | None:
|
||||
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||
|
||||
return await resolve_workspace_scope(db, current_user, x_workspace_id, module_key)
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
def require_active_plugin(plugin_name: str):
|
||||
"""FastAPI dependency factory: require that a plugin is active.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin, require_permission
|
||||
from app.deps import get_current_user, require_admin, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.calendar.ics_utils import (
|
||||
export_entries_to_ics,
|
||||
ics_events_to_entry_data,
|
||||
@@ -168,8 +168,13 @@ async def _check_write_permission(
|
||||
async def list_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
|
||||
):
|
||||
"""AC1: GET /api/v1/calendars → 200 + calendar list."""
|
||||
"""AC1: GET /api/v1/calendars → 200 + calendar list.
|
||||
|
||||
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||
AND-restriction (calendar subsets) — never a grant.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await db.execute(
|
||||
select(Calendar).where(
|
||||
@@ -178,6 +183,13 @@ async def list_calendars(
|
||||
)
|
||||
)
|
||||
cals = result.scalars().all()
|
||||
# Phase N3: workspace scope — calendar picker restriction
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
|
||||
if calendar_scope is not None:
|
||||
cals = [c for c in cals if c.id in calendar_scope]
|
||||
return [_calendar_to_dict(c) for c in cals]
|
||||
|
||||
|
||||
@@ -359,8 +371,13 @@ async def list_entries(
|
||||
end: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
|
||||
):
|
||||
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
|
||||
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range.
|
||||
|
||||
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||
AND-restriction (calendar subsets) — never a grant.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = current_user.get("role", "viewer")
|
||||
@@ -370,6 +387,14 @@ async def list_entries(
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
# Phase N3: workspace scope — calendar subsets, pure AND
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
|
||||
if calendar_scope is not None:
|
||||
query = query.where(CalendarEntry.calendar_id.in_(calendar_scope))
|
||||
|
||||
# Filter private entries: only owner + admin can see
|
||||
if role != "admin":
|
||||
query = query.where(
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.commands.contact_commands import (
|
||||
)
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import check_single_entity_access
|
||||
from app.deps import get_current_user, get_redis_dep, require_permission
|
||||
from app.deps import get_current_user, get_redis_dep, require_permission, require_workspace_scope
|
||||
from app.models.contact import Contact
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.plugins.registry import get_registry
|
||||
@@ -70,10 +70,13 @@ async def list_contacts(
|
||||
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("contacts:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("contacts")),
|
||||
):
|
||||
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
||||
|
||||
Supports keyset pagination via ``cursor`` parameter for large datasets.
|
||||
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||
AND-restriction (folder subtree + contact types) — never a grant.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
@@ -87,6 +90,7 @@ async def list_contacts(
|
||||
user_id=user_id,
|
||||
is_system_admin=is_admin,
|
||||
cursor=cursor,
|
||||
workspace_scope=workspace_scope,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.dms.common import (
|
||||
_parse_uuid,
|
||||
)
|
||||
@@ -35,8 +35,13 @@ async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
|
||||
):
|
||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).
|
||||
|
||||
Phase N3: an active workspace scope (X-Workspace-ID) reduces the tree to
|
||||
the folder subtree — pure AND-restriction, never a grant.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
# Fetch all non-deleted folders for tenant with visibility filter
|
||||
@@ -52,6 +57,16 @@ async def list_folders(
|
||||
result = await db.execute(query)
|
||||
all_folders = result.scalars().all()
|
||||
|
||||
# Phase N3: reduce to the scope subtree (folder_ids dimension)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import expand_folder_scope
|
||||
|
||||
scope_folder_ids = workspace_scope.get("folder_ids")
|
||||
if isinstance(scope_folder_ids, list) and scope_folder_ids:
|
||||
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
|
||||
allowed = subtree or set()
|
||||
all_folders = [f for f in all_folders if f.id in allowed]
|
||||
|
||||
# Build lookup map
|
||||
folder_map: dict[uuid.UUID, dict] = {}
|
||||
for f in all_folders:
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import LocalStorage, get_storage_backend
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
|
||||
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
|
||||
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
||||
@@ -245,8 +245,13 @@ async def get_file(
|
||||
async def list_all_files(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
|
||||
):
|
||||
"""List all non-deleted files for the current tenant."""
|
||||
"""List all non-deleted files for the current tenant.
|
||||
|
||||
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||
AND-restriction — folder subtree + file types. Never a grant.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
@@ -258,9 +263,32 @@ async def list_all_files(
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
|
||||
# Phase N3: workspace scope filters (folder subtree + file types)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import (
|
||||
DMS_FILE_TYPE_MATCHERS,
|
||||
expand_folder_scope,
|
||||
)
|
||||
|
||||
scope_folder_ids = workspace_scope.get("folder_ids")
|
||||
if isinstance(scope_folder_ids, list) and scope_folder_ids:
|
||||
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
|
||||
query = query.where(DmsFile.folder_id.in_(subtree or set()))
|
||||
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
# file_types needs Python-side matching (semantic matchers, not SQL-LIKE)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import DMS_FILE_TYPE_MATCHERS
|
||||
|
||||
scope_file_types = workspace_scope.get("file_types")
|
||||
if isinstance(scope_file_types, list) and scope_file_types:
|
||||
matchers = [DMS_FILE_TYPE_MATCHERS[t] for t in scope_file_types if t in DMS_FILE_TYPE_MATCHERS]
|
||||
if matchers:
|
||||
files = [f for f in files if any(m(f.mime_type) for m in matchers)]
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(f.id),
|
||||
|
||||
@@ -21,7 +21,7 @@ import app.plugins.builtins.mail.services as mail_services
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import require_permission
|
||||
from app.deps import require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.mail.models import (
|
||||
ContactPgpKey,
|
||||
Mail,
|
||||
@@ -214,7 +214,8 @@ async def _check_delegate_access(
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(
|
||||
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
|
||||
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
@@ -223,6 +224,13 @@ async def list_accounts(
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
# Phase N3: workspace scope (X-Workspace-ID) — account picker restriction.
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||
if account_scope is not None:
|
||||
query = query.where(MailAccount.id.in_(account_scope))
|
||||
accounts = (await db.execute(query)).scalars().all()
|
||||
return [account_to_response(a) for a in accounts]
|
||||
|
||||
@@ -878,12 +886,20 @@ async def list_threads(
|
||||
account_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("mail:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||
if account_id:
|
||||
a_id = _parse_uuid(account_id, "account_id")
|
||||
stmt = stmt.where(Mail.account_id == a_id)
|
||||
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||
if account_scope is not None:
|
||||
stmt = stmt.where(Mail.account_id.in_(account_scope))
|
||||
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
|
||||
threads: dict[str, dict] = {}
|
||||
for mail in mails:
|
||||
@@ -1887,6 +1903,7 @@ async def list_mails(
|
||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("mail:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||
@@ -1896,6 +1913,13 @@ async def list_mails(
|
||||
if account_id:
|
||||
a_id = _parse_uuid(account_id, "account_id")
|
||||
stmt = stmt.where(Mail.account_id == a_id)
|
||||
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||
if account_scope is not None:
|
||||
stmt = stmt.where(Mail.account_id.in_(account_scope))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
|
||||
# Dynamic sorting
|
||||
sort_columns = {
|
||||
|
||||
@@ -146,6 +146,7 @@ async def list_contacts(
|
||||
user_id: uuid.UUID | None = None,
|
||||
is_system_admin: bool = False,
|
||||
cursor: str | None = None,
|
||||
workspace_scope: dict | None = None,
|
||||
) -> dict:
|
||||
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
||||
|
||||
@@ -155,11 +156,17 @@ async def list_contacts(
|
||||
filtered to ``id > cursor`` instead of using OFFSET. This is much faster
|
||||
for large datasets. When ``cursor`` is not provided, classic page/page_size
|
||||
offset pagination is used (backward compatible).
|
||||
|
||||
Phase N3: ``workspace_scope`` (from X-Workspace-ID) applies folder-subtree
|
||||
and contact-type restrictions as a pure AND on top of all other filters —
|
||||
never a grant. An active scope also disables the list cache (the cache key
|
||||
is workspace-dependent).
|
||||
"""
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
|
||||
# I.4 Performance: Cache simple list queries (no search, no cursor, first 3 pages)
|
||||
use_cache = not search and not cursor and page <= 3 and not folder_id
|
||||
# N3: an active workspace scope is user-dependent — never serve the shared cache
|
||||
use_cache = not search and not cursor and page <= 3 and not folder_id and not workspace_scope
|
||||
cache_key = f"contacts:list:{tenant_id}:{page}:{page_size}:{contact_type or 'all'}:{sort_by}:{sort_order}:{user_id or 'admin'}:{is_system_admin}"
|
||||
if use_cache:
|
||||
from app.core.cache import cache_get
|
||||
@@ -185,6 +192,25 @@ async def list_contacts(
|
||||
if folder_id:
|
||||
base = base.where(Contact.folder_id == uuid.UUID(folder_id))
|
||||
|
||||
# Phase N3: workspace scope (X-Workspace-ID) — pure AND-restriction.
|
||||
# Empty dimension values were already dropped by resolve_workspace_scope.
|
||||
if workspace_scope:
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.services.workspace_scope_service import expand_folder_scope
|
||||
|
||||
scope_folder_ids = workspace_scope.get("folder_ids")
|
||||
if isinstance(scope_folder_ids, list) and scope_folder_ids:
|
||||
subtree = await expand_folder_scope(db, ContactFolder, scope_folder_ids)
|
||||
if subtree:
|
||||
base = base.where(Contact.folder_id.in_(subtree))
|
||||
else:
|
||||
# Restrict to a non-existent set: everything is excluded
|
||||
base = base.where(Contact.folder_id.in_(set()))
|
||||
|
||||
scope_types = workspace_scope.get("contact_types")
|
||||
if isinstance(scope_types, list) and scope_types:
|
||||
base = base.where(Contact.type.in_(scope_types))
|
||||
|
||||
if search:
|
||||
base = base.where(
|
||||
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
|
||||
|
||||
@@ -17,15 +17,179 @@ Without an active workspace there is no filter (backward compatible).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser
|
||||
from app.schemas.workspace import WorkspaceModuleScopes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def resolve_workspace_scope(
|
||||
db: AsyncSession,
|
||||
current_user: dict[str, Any],
|
||||
x_workspace_id: str | None,
|
||||
module_key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve the active workspace scope config for a module (Phase N3).
|
||||
|
||||
Returns the module's scope values (e.g. ``{"folder_ids": [...]}``) or
|
||||
``None`` when no restriction applies:
|
||||
- no X-Workspace-ID header / invalid UUID → no filter (backward compatible)
|
||||
- system admins → exempt
|
||||
- holders of ``workspaces:configure_modules`` → exempt (editor deadlock:
|
||||
the N2 scope editor loads its value options through the same endpoints)
|
||||
- user not assigned to the workspace → no filter (fail-open to RLS/ABAC;
|
||||
the /context endpoint reports ``not_assigned`` separately)
|
||||
- module not configured or config has only empty values → no filter
|
||||
|
||||
Security invariant: the returned config is applied as a pure
|
||||
AND-restriction by the callers — a workspace can never grant visibility.
|
||||
"""
|
||||
if not x_workspace_id:
|
||||
return None
|
||||
try:
|
||||
ws_id = uuid.UUID(x_workspace_id)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
if current_user.get("is_system_admin"):
|
||||
return None
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, "workspaces:configure_modules"):
|
||||
return None
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# Workspace exists, is active and belongs to the tenant
|
||||
ws = (
|
||||
await db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == ws_id,
|
||||
Workspace.tenant_id == tenant_id,
|
||||
Workspace.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ws is None:
|
||||
return None
|
||||
|
||||
# User must be assigned to the workspace
|
||||
wu = (
|
||||
await db.execute(
|
||||
select(WorkspaceUser).where(
|
||||
WorkspaceUser.workspace_id == ws_id,
|
||||
WorkspaceUser.user_id == user_id,
|
||||
WorkspaceUser.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if wu is None:
|
||||
return None
|
||||
|
||||
# Module config for the requested module
|
||||
wm = (
|
||||
await db.execute(
|
||||
select(WorkspaceModule).where(
|
||||
WorkspaceModule.workspace_id == ws_id,
|
||||
WorkspaceModule.tenant_id == tenant_id,
|
||||
WorkspaceModule.module_key == module_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if wm is None:
|
||||
return None
|
||||
|
||||
config = wm.config or {}
|
||||
# Empty values (empty list / None / "") mean no restriction per dimension
|
||||
active = {k: v for k, v in config.items() if v}
|
||||
return active or None
|
||||
|
||||
|
||||
async def expand_folder_scope(
|
||||
db: AsyncSession,
|
||||
model: type,
|
||||
root_ids: list[str],
|
||||
) -> set[uuid.UUID] | None:
|
||||
"""Expand folder scope IDs to the full subtree (self + descendants).
|
||||
|
||||
Works for any folder model with ``id``/``parent_id`` (ContactFolder,
|
||||
DMS Folder). Returns ``None`` when no valid IDs remain — callers treat
|
||||
that as no restriction. Cycles are tolerated (visited set).
|
||||
"""
|
||||
if not root_ids:
|
||||
return None
|
||||
try:
|
||||
frontier = {uuid.UUID(v) for v in root_ids}
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
if not frontier:
|
||||
return None
|
||||
|
||||
rows = (
|
||||
await db.execute(select(model.id, model.parent_id))
|
||||
).all()
|
||||
children_of: dict[uuid.UUID | None, set[uuid.UUID]] = {}
|
||||
for fid, parent in rows:
|
||||
children_of.setdefault(parent, set()).add(fid)
|
||||
|
||||
result = set(frontier)
|
||||
queue = list(frontier)
|
||||
while queue:
|
||||
current = queue.pop()
|
||||
for child in children_of.get(current, set()):
|
||||
if child not in result:
|
||||
result.add(child)
|
||||
queue.append(child)
|
||||
return result
|
||||
|
||||
|
||||
def scope_uuid_set(raw: Any) -> set[uuid.UUID] | None:
|
||||
"""Convert a scope dimension value into a set of UUIDs (Phase N3).
|
||||
|
||||
Returns ``None`` when the value is absent or not a list (no restriction).
|
||||
Returns an EMPTY set when the list contains no valid UUIDs — callers use
|
||||
it with ``.in_(empty)`` so a configured-but-garbage scope restricts to
|
||||
nothing (fail-closed AND-restriction). Real editor configs always carry
|
||||
valid UUIDs; this only guards against hand-corrupted JSONB.
|
||||
"""
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return None
|
||||
result: set[uuid.UUID] = set()
|
||||
for value in raw:
|
||||
try:
|
||||
result.add(uuid.UUID(value))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
DMS_FILE_TYPE_MATCHERS: dict[str, Any] = {
|
||||
"application/pdf": lambda mime: mime == "application/pdf",
|
||||
"image/": lambda mime: mime.startswith("image/"),
|
||||
"spreadsheet": lambda mime: "spreadsheet" in mime or "excel" in mime,
|
||||
"word": lambda mime: "word" in mime,
|
||||
"other": lambda mime: (
|
||||
mime != "application/pdf"
|
||||
and not mime.startswith("image/")
|
||||
and "spreadsheet" not in mime
|
||||
and "excel" not in mime
|
||||
and "word" not in mime
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _parse_contribution(plugin_name: str, contribution: Any) -> WorkspaceModuleScopes | None:
|
||||
"""Validate a single raw contribution; ``None`` when invalid (fail-closed).
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useWindowStore } from '@/store/windowStore';
|
||||
import { IcsControls } from '@/components/calendar/IcsControls';
|
||||
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
||||
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
@@ -112,6 +113,18 @@ export function CalendarPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// N3: workspace default calendar view (admin-defined per workspace).
|
||||
// Applied once when the workspace context provides a default_view;
|
||||
// user view switches stay sticky (only re-applied on workspace change).
|
||||
const workspaceDefaultView = useWorkspaceStore(s => s.getModuleConfig('calendar')?.default_view) as CalendarViewMode | undefined;
|
||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
|
||||
useEffect(() => {
|
||||
if (workspaceDefaultView && ['day', 'week', 'month', 'range'].includes(workspaceDefaultView)) {
|
||||
setViewMode(workspaceDefaultView);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceDefaultView, activeWorkspaceId]);
|
||||
|
||||
// Compute date range based on viewMode
|
||||
const range = useMemo(() => {
|
||||
if (viewMode === 'month') {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -331,6 +332,17 @@ export function ContactsListPage() {
|
||||
if (activeViewId === id) setActiveViewId(null);
|
||||
}, [activeViewId, deleteViewMut]);
|
||||
|
||||
// N3: workspace default saved view — applied once when views are loaded
|
||||
// and the user has not chosen a view yet (admin-defined per workspace).
|
||||
const workspaceDefaultViewId = useWorkspaceStore(s => s.getModuleConfig('contacts')?.default_saved_view_id);
|
||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
|
||||
useEffect(() => {
|
||||
if (activeViewId || !workspaceDefaultViewId || savedViews.length === 0) return;
|
||||
const view = savedViews.find(v => v.id === workspaceDefaultViewId);
|
||||
if (view) applySavedView(view);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceDefaultViewId, activeWorkspaceId, savedViews]);
|
||||
|
||||
// Save current filter configuration — via API
|
||||
const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {
|
||||
createFilterMut.mutate({ name, entity_type: 'contacts', filter_criteria: filterState as any });
|
||||
|
||||
@@ -136,4 +136,37 @@ describe('workspaceStore', () => {
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── N3: getModuleConfig (scope defaults for pages) ─────────
|
||||
|
||||
it('getModuleConfig returns the module config from the active context', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: { default_saved_view_id: 'view-42' } },
|
||||
{ module_key: 'calendar', menu_order: 1, config: { default_view: 'week' } },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({
|
||||
default_saved_view_id: 'view-42',
|
||||
});
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('calendar')).toEqual({
|
||||
default_view: 'week',
|
||||
});
|
||||
});
|
||||
|
||||
it('getModuleConfig returns empty object without workspace context (backward compatible)', () => {
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({});
|
||||
});
|
||||
|
||||
it('getModuleConfig returns empty object for unconfigured modules', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('mail')).toEqual({});
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ interface WorkspaceStoreState {
|
||||
// Helpers
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||
visibleModuleKeys: () => Set<string>;
|
||||
getModuleConfig: (moduleKey: string) => Record<string, any>;
|
||||
hasWorkspaces: () => boolean;
|
||||
// Reset
|
||||
reset: () => void;
|
||||
@@ -111,6 +112,14 @@ export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
||||
);
|
||||
},
|
||||
|
||||
getModuleConfig: (moduleKey: string) => {
|
||||
// N3: scope defaults (e.g. default_saved_view_id, default_view) come
|
||||
// from the active workspace context; no context = no defaults.
|
||||
const ctx = get().context;
|
||||
const mod = (ctx?.modules ?? []).find(m => m.module_key === moduleKey);
|
||||
return mod?.config ?? {};
|
||||
},
|
||||
|
||||
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||
|
||||
reset: () => {
|
||||
|
||||
@@ -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