feat(N1): Scope-Registry via Contract — workspace_scopes() Deklarationen + /scope-definitions Endpoint (#365)
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-31 23:17:54 +02:00
parent 6ed4bb7f98
commit c25356c257
10 changed files with 652 additions and 3 deletions
+21 -2
View File
@@ -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)
@@ -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."""
@@ -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."""
+34
View File
@@ -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."""
+24
View File
@@ -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."""
+19
View File
@@ -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,
+69 -1
View File
@@ -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
+83
View File
@@ -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
+23
View File
@@ -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
+297
View File
@@ -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"