From c25356c257fe3d50ab86e0c3e6ac8a345183ccbe Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 31 Aug 2026 23:17:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(N1):=20Scope-Registry=20via=20Contract=20?= =?UTF-8?q?=E2=80=94=20workspace=5Fscopes()=20Deklarationen=20+=20/scope-d?= =?UTF-8?q?efinitions=20Endpoint=20(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspace_scopes() Contract-Hook (document_placeholders-Muster): Plugins deklarieren Scope-Dimensionen inkl. Wertequellen - Deklarationen: contacts (Ordner/Typen/Saved-View), dms (Ordner/Datei-Typen), mail (Postfächer), calendar (Kalender/Standard-Ansicht) - Pydantic fail-closed (schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1-Pfade, SSRF-sicher), WorkspaceScopeDimension, WorkspaceModuleScopes - Aggregator workspace_scope_service.py: discovered-Plugins, ARCH-014-safe, Crash-sicher, ungültige Deklarationen verworfen - GET /api/v1/workspaces/scope-definitions (workspaces:configure_modules) vor /{workspace_id} registriert - Security-Invariante: Scope = reine UND-Einschränkung (Workspace ∧ RLS ∧ ABAC ∧ Permissions) - Tests: 18/18 neu (TDD rot→grün), Regression 17/17, Checker 0 Verstöße, Ruff clean - Doku: api-documentation.md Workspaces-Sektion, PROGRESS.md Phase N1 --- PROGRESS.md | 23 +- app/plugins/builtins/calendar/contracts.py | 35 +++ app/plugins/builtins/contacts/contracts.py | 47 ++++ app/plugins/builtins/dms/contracts.py | 34 +++ app/plugins/builtins/mail/contracts.py | 24 ++ app/routes/workspaces.py | 19 ++ app/schemas/workspace.py | 70 ++++- app/services/workspace_scope_service.py | 83 ++++++ docs/api-documentation.md | 23 ++ tests/test_workspace_scopes.py | 297 +++++++++++++++++++++ 10 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 app/services/workspace_scope_service.py create mode 100644 tests/test_workspace_scopes.py diff --git a/PROGRESS.md b/PROGRESS.md index d131a49..c0fc261 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,8 +7,8 @@ **Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten. **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). **Nächster Schritt:** Phase N Workspace-Scopes (N1-N4) oder Phase P Notizen-App (P1-P5) — beide user-abgestimmt und startklar in der Roadmap. -- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. +- **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). **Nächster Schritt:** N2 Dynamischer Scope-Editor. - **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 N1 — Scope-Registry via Contract (2026-08-31) ✅ + +**Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14) + +**Umgesetzt:** +- **Contract-Hook `workspace_scopes()`** (document_placeholders-Muster, #359-Philosophie): Plugins deklarieren Scope-Dimensionen ihres Moduls inkl. Wertequellen; der generische Editor bleibt modul-agnostisch. +- **Deklarationen (4 N3-Module):** contacts (folder_ids via /api/v1/contact-folders, contact_types Firmen/Personen, default_saved_view_id via /api/v1/saved-views?entity_type=contact), dms (folder_ids via /api/v1/dms/folders, file_types PDF/Bilder/Tabellen/Dokumente/Sonstige), mail (account_ids via /api/v1/mail/accounts), calendar (calendar_ids via /api/v1/calendars, default_view Tag/Woche/Monat/Zeitraum — Frontend-Ansichten live abgeglichen). +- **Pydantic fail-closed** (app/schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1/-Pfade — SSRF-sicher per Konstruktion; Validator), WorkspaceScopeDimension (multiselect/select ohne options UND value_source → ValidationError), WorkspaceModuleScopes (module_key + min. 1 Dimension). +- **Aggregator** (app/services/workspace_scope_service.py): iteriert discovered Plugins, lazy-loadet Contracts, ARCH-014-safe (deaktivierte bleiben weg), Crash-sicher pro Plugin, ungültige Deklarationen verworfen (Warning-Log). +- **Endpoint** `GET /api/v1/workspaces/scope-definitions` (workspaces:configure_modules — Admin-Kontext) — VOR /{workspace_id} registriert (Route-Order-Falle, test_plugin_route_order-Klasse). + +**Verifiziert (2026-08-31):** +- TDD: Rot (ImportError) → ✅ Grün **18/18** (tests/test_workspace_scopes.py: Pydantic-Unit 4, Contract-Deklarationen 8, Aggregator fail-closed 1, HTTP-Endpoint 2 (Admin bekommt alle 4 Module, Viewer-403), Route-Order 1, Value-Endpoint-Existenz via OpenAPI 1 (431 Pfade, app.routes enthält nur _IncludedRouter-Wrapper — isinstance-Scan versagt, OpenAPI kanonisch), /context-config-Regression 1) +- ✅ Regression: test_workspaces.py **17/17** +- ✅ Cross-Plugin-Checker: 0 Verstöße (495 Dateien) +- ✅ Ruff clean (alle 8 geänderten Dateien; UP037-Quote-Fix) + +**Offen in Phase N:** N2 Dynamischer Scope-Editor (WorkspaceManager rendert Filter-UI aus /scope-definitions, Speicherung in workspace_modules.config), N3 Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung), N4 restliche Module. + ## Phase M6 — Weitere Hosts (2026-08-30) ✅ — PHASE M KOMPLETT **Spec:** [#364](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/364) | **Roadmap:** Phase M, M6 (letzter Task) diff --git a/app/plugins/builtins/calendar/contracts.py b/app/plugins/builtins/calendar/contracts.py index 959750f..3f3d674 100644 --- a/app/plugins/builtins/calendar/contracts.py +++ b/app/plugins/builtins/calendar/contracts.py @@ -47,6 +47,41 @@ class CalendarContract: ] } + # ─── Workspace Scopes contribution (Phase N1, #359 pattern) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des calendar-Moduls für den Workspace-Editor (N1).""" + return [ + { + "module_key": "calendar", + "dimensions": [ + { + "key": "calendar_ids", + "label": "Kalender", + "control": "multiselect", + "value_source": { + "endpoint": "/api/v1/calendars", + "items_path": "", + "value_key": "id", + "label_key": "name", + }, + }, + { + "key": "default_view", + "label": "Standard-Ansicht", + "control": "select", + "options": [ + {"value": "day", "label": "Tag"}, + {"value": "week", "label": "Woche"}, + {"value": "month", "label": "Monat"}, + {"value": "range", "label": "Zeitraum"}, + ], + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/plugins/builtins/contacts/contracts.py b/app/plugins/builtins/contacts/contracts.py index 0077565..62acd5f 100644 --- a/app/plugins/builtins/contacts/contracts.py +++ b/app/plugins/builtins/contacts/contracts.py @@ -349,6 +349,53 @@ class ContactsContract: data[key] = value if value is not None else "" return data + # ─── Workspace Scopes contribution (Phase N1, #359 pattern) ─── + # Declares the scope dimensions the contacts module supports; the N2 + # workspace editor renders its filter UI from these definitions. Scope + # VALUES live per workspace in workspace_modules.config (JSONB). + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des contacts-Moduls für den Workspace-Editor.""" + return [ + { + "module_key": "contacts", + "dimensions": [ + { + "key": "folder_ids", + "label": "Kontakt-Ordner", + "control": "multiselect", + "value_source": { + "endpoint": "/api/v1/contact-folders", + "items_path": "items", + "value_key": "id", + "label_key": "name", + }, + }, + { + "key": "contact_types", + "label": "Kontakt-Typen", + "control": "multiselect", + "options": [ + {"value": "company", "label": "Firmen"}, + {"value": "person", "label": "Personen"}, + ], + }, + { + "key": "default_saved_view_id", + "label": "Standard-Ansicht", + "control": "select", + "value_source": { + "endpoint": "/api/v1/saved-views?entity_type=contact", + "items_path": "", + "value_key": "id", + "label_key": "name", + }, + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/plugins/builtins/dms/contracts.py b/app/plugins/builtins/dms/contracts.py index 68a661a..cbccb64 100644 --- a/app/plugins/builtins/dms/contracts.py +++ b/app/plugins/builtins/dms/contracts.py @@ -15,6 +15,40 @@ class DmsContract: DmsFile = DmsFile Folder = Folder + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des dms-Moduls für den Workspace-Editor (N1).""" + return [ + { + "module_key": "dms", + "dimensions": [ + { + "key": "folder_ids", + "label": "DMS-Ordner", + "control": "multiselect", + "value_source": { + "endpoint": "/api/v1/dms/folders", + "items_path": "", + "value_key": "id", + "label_key": "name", + }, + }, + { + "key": "file_types", + "label": "Datei-Typen", + "control": "multiselect", + "options": [ + {"value": "application/pdf", "label": "PDF"}, + {"value": "image/", "label": "Bilder"}, + {"value": "spreadsheet", "label": "Tabellen"}, + {"value": "word", "label": "Dokumente"}, + {"value": "other", "label": "Sonstige"}, + ], + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/plugins/builtins/mail/contracts.py b/app/plugins/builtins/mail/contracts.py index 1c715a0..008d9ff 100644 --- a/app/plugins/builtins/mail/contracts.py +++ b/app/plugins/builtins/mail/contracts.py @@ -64,6 +64,30 @@ class MailContract: ] } + # ─── Workspace Scopes contribution (Phase N1, #359 pattern) ─── + + @staticmethod + def workspace_scopes() -> list[dict]: + """Scope-Dimensionen des mail-Moduls für den Workspace-Editor (N1).""" + return [ + { + "module_key": "mail", + "dimensions": [ + { + "key": "account_ids", + "label": "Postfächer", + "control": "multiselect", + "value_source": { + "endpoint": "/api/v1/mail/accounts", + "items_path": "", + "value_key": "id", + "label_key": "email", + }, + }, + ], + } + ] + @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" diff --git a/app/routes/workspaces.py b/app/routes/workspaces.py index 56b1514..b5255e9 100644 --- a/app/routes/workspaces.py +++ b/app/routes/workspaces.py @@ -126,6 +126,25 @@ async def workspace_context( return ctx +@router.get("/scope-definitions") +async def workspace_scope_definitions( + current_user: dict = Depends(require_permission("workspaces:configure_modules")), +): + """N1: Scope-Dimensionen aller Module für den Workspace-Editor. + + Aggregiert ``workspace_scopes()``-Contract-Beiträge der Plugins + (document_placeholders-Muster): pro module_key die filterbaren + Dimensionen inkl. Wertequellen. Der N2-Editor rendert daraus das + Filter-UI; die Werte landen in ``workspace_modules.config``. + + Admin-only: Scope-Definitionen konfigurieren Module-Teilmengen — + das ist Workspace-Admin-Kontext (configure_modules), kein Lesen. + """ + from app.services.workspace_scope_service import get_scope_definitions + + return {"modules": get_scope_definitions()} + + @router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( body: WorkspaceCreate, diff --git a/app/schemas/workspace.py b/app/schemas/workspace.py index 3c42422..bcaf1a4 100644 --- a/app/schemas/workspace.py +++ b/app/schemas/workspace.py @@ -2,7 +2,9 @@ from __future__ import annotations -from pydantic import BaseModel +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator class WorkspaceBase(BaseModel): @@ -34,6 +36,72 @@ class WorkspaceResponse(WorkspaceBase): model_config = {"from_attributes": True} +# ─── N1: Workspace-Scope-Registry (Phase N) ─────────────────── + + +class ScopeOption(BaseModel): + """Ein auswählbarer Wert einer Scope-Dimension.""" + + value: str + label: str + + +class ScopeValueSource(BaseModel): + """Deklariert, woher der Editor wählbare Werte lädt. + + Security (fail-closed): nur interne ``/api/v1/...``-Pfade sind + erlaubt — keine absoluten URLs, keine protokoll-relativen Pfade, + keine externen Hosts (SSRF-sicher per Konstruktion). + """ + + endpoint: str + items_path: str = "items" + value_key: str = "id" + label_key: str = "name" + + @field_validator("endpoint") + @classmethod + def _must_be_internal_api_path(cls, v: str) -> str: + if not v.startswith("/api/v1/"): + raise ValueError( + "value_source endpoint must be an internal /api/v1/ path" + ) + return v + + +class WorkspaceScopeDimension(BaseModel): + """Eine filterbare Dimension eines Moduls im Workspace (N1). + + ``multiselect``/``select`` brauchen zwingend ``options`` oder + ``value_source`` (fail-closed gegen leere Filter-UI); ``toggle`` + ist ein reiner Schalter mit ``default``. + """ + + key: str = Field(..., min_length=1, max_length=100) + label: str = Field(..., min_length=1, max_length=200) + control: Literal["multiselect", "select", "toggle"] + options: list[ScopeOption] = Field(default_factory=list) + value_source: ScopeValueSource | None = None + default: bool | str | None = None + + @model_validator(mode="after") + def _options_or_source_required(self) -> WorkspaceScopeDimension: + if self.control in ("multiselect", "select"): + if not self.options and self.value_source is None: + raise ValueError( + f"scope dimension '{self.key}': control '{self.control}' " + "requires options or value_source" + ) + return self + + +class WorkspaceModuleScopes(BaseModel): + """Scope-Contribution eines Plugins für ein Modul.""" + + module_key: str = Field(..., min_length=1, max_length=100) + dimensions: list[WorkspaceScopeDimension] = Field(..., min_length=1) + + class WorkspaceModuleResponse(BaseModel): id: str workspace_id: str diff --git a/app/services/workspace_scope_service.py b/app/services/workspace_scope_service.py new file mode 100644 index 0000000..662908d --- /dev/null +++ b/app/services/workspace_scope_service.py @@ -0,0 +1,83 @@ +"""Workspace-Scope-Registry aggregation (Phase N1, Roadmap Phase N). + +Plugins declare the scope dimensions their modules support via the contract +hook ``workspace_scopes()`` — same aggregation pattern as +``document_placeholders()`` (#359 philosophy): the module owns its domain, +the generic consumer stays module-agnostic. + +The N2 workspace editor renders filter UI from these definitions; scope +VALUES are stored per workspace in ``workspace_modules.config`` (JSONB). +N3 list filtering applies them as pure AND-restrictions. + +Security-Invariante (Phase N): a workspace can never GRANT visibility — +effective visibility is always Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions. +Without an active workspace there is no filter (backward compatible). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import ValidationError + +from app.schemas.workspace import WorkspaceModuleScopes + +logger = logging.getLogger(__name__) + + +def _parse_contribution(plugin_name: str, contribution: Any) -> WorkspaceModuleScopes | None: + """Validate a single raw contribution; ``None`` when invalid (fail-closed). + + Every declaration must pass the Pydantic schema — invalid dimensions + (e.g. a multiselect without options AND value_source) are dropped with a + warning instead of breaking the editor endpoint. + """ + if not isinstance(contribution, dict): + return None + try: + return WorkspaceModuleScopes.model_validate(contribution) + except ValidationError as exc: + logger.warning( + "Invalid workspace_scopes contribution from plugin '%s': %s", + plugin_name, + exc, + ) + return None + + +def get_scope_definitions() -> dict[str, list[dict[str, Any]]]: + """Aggregate ``workspace_scopes()`` contributions from all discovered plugins. + + Returns ``module_key -> [dimension, ...]`` for the N2 editor. Contracts + of explicitly deactivated plugins stay gone (ARCH-014 unregister marker); + never-loaded contracts lazy-load on first access (document_placeholders + precedent). A plugin crash while declaring scopes never fails the + endpoint — its contribution is simply skipped. + """ + from app.plugins.builtins.contracts import get_contract_registry + from app.plugins.registry import get_registry + + modules: dict[str, list[dict[str, Any]]] = {} + for plugin_name in get_registry().list_discovered(): + contract = get_contract_registry().get_contract(plugin_name) + if contract is None: + continue + fn = getattr(contract, "workspace_scopes", None) + if fn is None: + continue + try: + contributions = fn() or [] + except Exception: # noqa: BLE001 + logger.exception( + "workspace_scopes() raised for plugin '%s' — skipping", plugin_name + ) + continue + for contribution in contributions: + parsed = _parse_contribution(plugin_name, contribution) + if parsed is None: + continue + modules.setdefault(parsed.module_key, []).extend( + dimension.model_dump() for dimension in parsed.dimensions + ) + return modules diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 4124de3..f7ed87e 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -296,6 +296,29 @@ Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to te | DELETE | `/api/v1/dashboards/{id}` | Soft-delete (deleting the default promotes the next one). | | POST | `/api/v1/dashboards/{id}/set-default` | Mark as the user's default (exactly one). | +### workspaces (14 endpoints) + +Workspaces are UI/navigation context only — they never affect permissions or data access (Phase N). Visibility is always Workspace ∧ RLS ∧ ABAC ∧ Permissions. Tab-local workspace selection via `X-Workspace-ID` header; the API client interceptor sets it per tab. Requires `workspaces:*` core permissions. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/workspaces` | List all workspaces for the tenant. | +| POST | `/api/v1/workspaces` | Create workspace (creator becomes manager; only one default per tenant). | +| GET | `/api/v1/workspaces/my` | Workspaces assigned to the current user. | +| GET | `/api/v1/workspaces/context` | Active workspace context (modules + widgets) — X-Workspace-ID header, falls back to user default. | +| GET | `/api/v1/workspaces/scope-definitions` | N1: aggregated `workspace_scopes()` contract contributions — per module_key the filterable scope dimensions (control type, options, value_source) for the workspace editor. Admin (`workspaces:configure_modules`). | +| GET | `/api/v1/workspaces/{id}` | Get one workspace with modules and user count. | +| PUT | `/api/v1/workspaces/{id}` | Update workspace. | +| DELETE | `/api/v1/workspaces/{id}` | Soft-delete workspace (is_active=false). | +| POST | `/api/v1/workspaces/{id}/modules` | Replace module assignments (module_key, is_visible, menu_order, config — config JSONB stores Phase N scope values). | +| POST | `/api/v1/workspaces/{id}/users` | Assign user (member/manager, same-tenant check). | +| DELETE | `/api/v1/workspaces/{id}/users/{user_id}` | Remove user assignment. | +| GET | `/api/v1/workspaces/{id}/widgets` | List workspace widgets (multiple instances of same key allowed). | +| POST | `/api/v1/workspaces/{id}/widgets` | Create widget (position, size, config). | +| PUT | `/api/v1/workspaces/{id}/widgets/{widget_id}` | Update widget. | +| DELETE | `/api/v1/workspaces/{id}/widgets/{widget_id}` | Delete widget. | +| POST | `/api/v1/workspaces/{id}/set-default` | Set as the current user's default workspace. | + --- ## Plugin Routes diff --git a/tests/test_workspace_scopes.py b/tests/test_workspace_scopes.py new file mode 100644 index 0000000..ae7226a --- /dev/null +++ b/tests/test_workspace_scopes.py @@ -0,0 +1,297 @@ +"""N1 — Workspace-Scope-Registry (Contract-Muster, Roadmap Phase N). + +Plugins deklarieren ``workspace_scopes()``: verfügbare Scope-Dimensionen +pro Modul inkl. Wertequellen (Multiselects für Ordner/Postfächer/Kalender, +Toggles, Standard-Ansichten). Der N2-Editor rendert daraus automatisch +Filter-UI; Speicherort ist ``workspace_modules.config`` (JSONB, vorhanden +und bereits über /context ausgeliefert). + +Security-Invariante (Phase N): Scope = reine UND-Einschränkung. +Sichtbarkeit = Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions — ein Workspace +kann NIE mehr sichtbar machen, nur weniger. Ohne aktiven Workspace kein +Filter (rückwärtskompatibel, wie Sidebar). +""" + +from __future__ import annotations + +import pytest +from fastapi.routing import APIRoute +from httpx import AsyncClient +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.conftest import login_client, seed_tenant_and_users + +SCOPE_DEFINITIONS_PATH = "/api/v1/workspaces/scope-definitions" +N1_MODULES = ("contacts", "dms", "mail", "calendar") + + +# ─── Unit: Pydantic-Validierung (fail-closed) ────────────────── + + +def test_dimension_select_requires_options_or_source(): + """multiselect ohne options UND ohne value_source muss abgelehnt werden.""" + from app.schemas.workspace import WorkspaceScopeDimension + + with pytest.raises(ValidationError): + WorkspaceScopeDimension(key="folder_ids", label="Ordner", control="multiselect") + with pytest.raises(ValidationError): + WorkspaceScopeDimension(key="default_view", label="Ansicht", control="select") + + +def test_dimension_toggle_needs_no_source(): + """toggle braucht keine Wertequelle — reiner Schalter.""" + from app.schemas.workspace import WorkspaceScopeDimension + + dim = WorkspaceScopeDimension( + key="only_mine", label="Nur meine", control="toggle", default=False + ) + assert dim.default is False + + +def test_value_source_must_be_internal_api_path(): + """Nur interne /api/v1-Pfade als Wertequelle (kein SSRF, keine externen URLs).""" + from app.schemas.workspace import ScopeValueSource + + for bad in ( + "https://evil.example.com/folders", + "http://127.0.0.1:8000/api/v1/x", + "//api/v1/contact-folders", + "api/v1/contact-folders", + "/api/v2/other", + ): + with pytest.raises(ValidationError): + ScopeValueSource(endpoint=bad) + # interne Pfade inkl. Query sind erlaubt + ok = ScopeValueSource(endpoint="/api/v1/saved-views?entity_type=contact") + assert ok.endpoint.startswith("/api/v1/") + + +def test_contribution_requires_module_key_and_dimensions(): + from app.schemas.workspace import WorkspaceModuleScopes + + with pytest.raises(ValidationError): + WorkspaceModuleScopes(module_key="contacts") + with pytest.raises(ValidationError): + WorkspaceModuleScopes(module_key="", dimensions=[]) + + +# ─── Unit: Contract-Deklarationen der 4 N3-Module ────────────── + + +@pytest.mark.parametrize("plugin_name", N1_MODULES) +def test_contract_declares_valid_scopes(plugin_name: str): + """Jedes N3-Modul deklariert workspace_scopes() für seinen module_key, + und jede Deklaration ist Pydantic-validierbar (fail-closed).""" + from app.plugins.builtins.contracts import get_contract + from app.schemas.workspace import WorkspaceModuleScopes + + contract = get_contract(plugin_name) + assert contract is not None, f"Contract für {plugin_name} fehlt" + fn = getattr(contract, "workspace_scopes", None) + assert callable(fn), f"{plugin_name} deklariert workspace_scopes() nicht" + + contributions = fn() or [] + assert contributions, f"{plugin_name}: mindestens eine Contribution" + + module_keys = set() + for contribution in contributions: + parsed = WorkspaceModuleScopes.model_validate(contribution) + module_keys.add(parsed.module_key) + assert plugin_name in module_keys, ( + f"{plugin_name}: module_key '{plugin_name}' fehlt in {module_keys}" + ) + + +def test_contacts_dimensions_cover_folder_types_view(): + """Roadmap N3 contacts: Ordner-Teilmengen, Firmen/Personen-Filter, + Standard-Saved-View.""" + from app.plugins.builtins.contracts import get_contract + + dims = {d["key"]: d for c in get_contract("contacts").workspace_scopes() for d in c["dimensions"]} + assert "folder_ids" in dims, "Ordner-Teilmenge fehlt" + assert dims["folder_ids"]["value_source"]["endpoint"] == "/api/v1/contact-folders" + assert "contact_types" in dims, "Firmen/Personen-Filter fehlt" + type_values = {o["value"] for o in dims["contact_types"]["options"]} + assert {"company", "person"} <= type_values + assert "default_saved_view_id" in dims, "Standard-Ansicht fehlt" + + +def test_dms_dimensions_cover_folders_and_file_types(): + """Roadmap N3 dms: Ordner-Teilmengen + Datei-Typ-Filter.""" + from app.plugins.builtins.contracts import get_contract + + dims = {d["key"]: d for c in get_contract("dms").workspace_scopes() for d in c["dimensions"]} + assert "folder_ids" in dims + assert dims["folder_ids"]["value_source"]["endpoint"] == "/api/v1/dms/folders" + assert "file_types" in dims + assert dims["file_types"]["options"], "Datei-Typ-Optionen fehlen" + + +def test_mail_dimensions_cover_accounts(): + """Roadmap N3 mail: Postfach-Teilmengen.""" + from app.plugins.builtins.contracts import get_contract + + dims = {d["key"]: d for c in get_contract("mail").workspace_scopes() for d in c["dimensions"]} + assert "account_ids" in dims + assert dims["account_ids"]["value_source"]["endpoint"] == "/api/v1/mail/accounts" + + +def test_calendar_dimensions_cover_calendars_and_default_view(): + """Roadmap N3 calendar: Kalender-Teilmengen + Standard-Ansicht.""" + from app.plugins.builtins.contracts import get_contract + + dims = {d["key"]: d for c in get_contract("calendar").workspace_scopes() for d in c["dimensions"]} + assert "calendar_ids" in dims + assert dims["calendar_ids"]["value_source"]["endpoint"] == "/api/v1/calendars" + assert "default_view" in dims + view_values = {o["value"] for o in dims["default_view"]["options"]} + assert {"day", "week", "month", "range"} <= view_values + + +# ─── Unit: Aggregator fail-closed ────────────────────────────── + + +def test_invalid_contribution_dropped_by_parser(): + """Ungültige Deklarationen werden verworfen (None), gültige geparst.""" + from app.services.workspace_scope_service import _parse_contribution + + bad = { + "module_key": "fake", + "dimensions": [ + {"key": "nope", "label": "Ohne Quelle", "control": "multiselect"}, + ], + } + assert _parse_contribution("fakeplugin", bad) is None + + good = { + "module_key": "fake", + "dimensions": [ + { + "key": "ok", + "label": "OK", + "control": "multiselect", + "options": [{"value": "a", "label": "A"}], + }, + ], + } + parsed = _parse_contribution("fakeplugin", good) + assert parsed is not None + assert parsed.module_key == "fake" + + +# ─── HTTP: Scope-Definitionen-Endpoint ───────────────────────── + + +class TestScopeDefinitionsEndpoint: + async def test_admin_gets_definitions_for_all_n1_modules( + self, client: AsyncClient, db_session: AsyncSession + ): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + resp = await client.get(SCOPE_DEFINITIONS_PATH) + assert resp.status_code == 200, resp.text + modules = resp.json()["modules"] + for key in N1_MODULES: + assert key in modules, f"Modul {key} fehlt in Scope-Definitionen" + assert isinstance(modules[key], list) and modules[key], ( + f"{key}: Dimensionen leer" + ) + + async def test_viewer_without_workspaces_permission_gets_403( + self, client: AsyncClient, db_session: AsyncSession + ): + await seed_tenant_and_users(db_session) + await login_client(client, "viewer@tenanta.com") + + resp = await client.get(SCOPE_DEFINITIONS_PATH) + assert resp.status_code == 403 + + +# ─── HTTP: Route-Order & Value-Endpoint-Existenz ─────────────── + + +def test_scope_definitions_route_registered_before_dynamic_workspace_id(): + """FastAPI matcht in Registrierungsreihenfolge: /scope-definitions muss + VOR /{workspace_id} stehen, sonst wird es als workspace_id verschluckt + (gleiche Fehlerklasse wie test_plugin_route_order).""" + from app.routes.workspaces import router + + paths = [r.path for r in router.routes if isinstance(r, APIRoute)] + assert SCOPE_DEFINITIONS_PATH in paths, "/scope-definitions Route fehlt" + assert "/api/v1/workspaces/{workspace_id}" in paths + assert paths.index(SCOPE_DEFINITIONS_PATH) < paths.index( + "/api/v1/workspaces/{workspace_id}" + ), "/scope-definitions muss vor /{workspace_id} registriert werden" + + +async def test_declared_value_endpoints_exist_in_app(app): + """Jede deklarierte value_source-Endpoint muss als GET-Route in der + FastAPI-App existieren (keine Geister-Quellen im Editor). + + Prüft über das OpenAPI-Schema: ``app.routes`` enthält hier nur + ``_IncludedRouter``-Wrapper, deren flacher isinstance-Scan keine + APIRoute-Objekte mehr liefert — OpenAPI dagegen aggregiert den + vollständigen Pfad-/Methoden-Satz kanonisch. + """ + from app.plugins.builtins.contracts import get_contract + + paths = app.openapi().get("paths", {}) + get_paths = {p for p, ops in paths.items() if "get" in ops} + for plugin_name in N1_MODULES: + contract = get_contract(plugin_name) + assert contract is not None + for contribution in contract.workspace_scopes() or []: + for dim in contribution.get("dimensions", []): + source = dim.get("value_source") + if not source: + continue + endpoint_path = source["endpoint"].split("?")[0] + assert endpoint_path in get_paths, ( + f"{plugin_name}: deklarierter Value-Endpoint {endpoint_path} " + "existiert nicht als GET-Route" + ) + + +# ─── Regression: /context liefert Modul-config mit aus ────────── + + +class TestContextConfigRegression: + """Roadmap N1: '/context liefert config der Module mit aus' — der + Bestand fließt config bereits durch; dieser Test sichert den Contract + für den N2-Editor (Speicher = workspace_modules.config).""" + + async def test_context_returns_module_config( + self, client: AsyncClient, db_session: AsyncSession + ): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + ws = await client.post("/api/v1/workspaces", json={"name": "ScopeWS"}) + assert ws.status_code == 201, ws.text + ws_id = ws.json()["id"] + + scope_config = {"folder_ids": ["11111111-1111-1111-1111-111111111111"], "contact_types": ["company"]} + mod = await client.post( + f"/api/v1/workspaces/{ws_id}/modules", + json={ + "modules": [ + { + "module_key": "contacts", + "is_visible": True, + "menu_order": 0, + "config": scope_config, + } + ] + }, + ) + assert mod.status_code == 200, mod.text + + ctx = await client.get( + "/api/v1/workspaces/context", headers={"X-Workspace-ID": ws_id} + ) + assert ctx.status_code == 200, ctx.text + entries = [m for m in ctx.json()["modules"] if m["module_key"] == "contacts"] + assert entries, "contacts-Modul fehlt im Context" + assert entries[0]["config"] == scope_config, "config wird nicht ausgeliefert"