c25356c257
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
298 lines
12 KiB
Python
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"
|