Files
leocrm/tests/test_workspace_scopes.py
T
Agent Zero c25356c257
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(N1): Scope-Registry via Contract — workspace_scopes() Deklarationen + /scope-definitions Endpoint (#365)
- 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
2026-08-31 23:17:54 +02:00

298 lines
12 KiB
Python

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