"""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"