feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
Check Cross-Plugin Imports / check (push) Has been cancelled

- Core-Resolver resolve_workspace_scope(): Zuweisungs-Check, leere Werte fallen weg; Exemptions System-Admin + workspaces:configure_modules (Editor-Deadlock)
- require_workspace_scope(module_key) FastAPI-Dependency (deps.py)
- expand_folder_scope(): Ordner-Subtree (zyklensicher) für ContactFolder + DMS Folder; scope_uuid_set() fail-closed
- contacts: folder_ids-Subtree + contact_types auf GET /contacts, List-Cache bei aktivem Scope deaktiviert (Cache-Leak-Gefahr)
- dms: folder_ids-Subtree + file_types (semantische Matcher) auf /files, Baum-Reduktion auf /folders
- mail: account_ids auf /mails, /threads, /accounts
- calendar: calendar_ids auf /calendar/entries, /calendars
- Frontend-Defaults: getModuleConfig() im workspaceStore, ContactsList default_saved_view_id, Calendar default_view
- Tests: 21/21 neu (TDD rot→grün), Regression 81 passed, Checker 0, tsc clean, Vitest grün, Build OK
This commit is contained in:
Agent Zero
2026-09-01 10:27:23 +02:00
parent b40adfdd3a
commit 26506a5027
14 changed files with 1156 additions and 13 deletions
+747
View File
@@ -0,0 +1,747 @@
"""N3 — Workspace-Scopes in Backend-Listen (Phase N).
Backend respects X-Workspace-ID on list endpoints: contacts (folder subtree
+ types), dms (folder subtree + file types), mail (account subsets + picker),
calendar (calendar subsets + picker). Pure AND-restriction — never grants.
Exemption rule (editor deadlock): system admins and holders of
``workspaces:configure_modules`` bypass scope filtering — the N2 scope
editor loads its value options through the same endpoints and needs the
unrestricted lists. Admins keep the full view everywhere.
"""
from __future__ import annotations
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
# ─── Helpers ──────────────────────────────────────────────────
async def _make_member(
db: AsyncSession,
seed: dict,
email: str,
extra_perms: dict[str, dict[str, bool]] | None = None,
):
"""Create a non-admin user with module read permissions."""
from app.core.auth import hash_password
from app.models.role import Role
from app.models.user import User, UserTenant
perms: dict[str, dict[str, bool]] = {
"contacts": {"read": True},
"dms": {"read": True},
"mail": {"read": True},
"calendar": {"read": True},
"workspaces": {"read": True},
}
if extra_perms:
for module, actions in extra_perms.items():
perms.setdefault(module, {}).update(actions)
user = User(
email=email,
name=email.split("@")[0].title(),
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db.add(user)
await db.flush()
role = Role(
tenant_id=seed["tenant_a"].id,
name=f"ws-member-{uuid.uuid4().hex[:8]}",
permissions=perms,
denied_permissions=[],
field_permissions={},
)
db.add(role)
await db.flush()
ut = UserTenant(
user_id=user.id,
tenant_id=seed["tenant_a"].id,
is_default=True,
role="viewer",
role_id=role.id,
)
db.add(ut)
await db.flush()
return user
async def _seed_contacts_data(db: AsyncSession, seed: dict, member_id) -> dict:
"""Folders F1 > F2 (subtree), F3 (other); contacts across folders.
Beta is a PERSON inside the folder scope — proves that folder and type
scopes combine as AND (Beta passes folder, fails type).
"""
from app.models.contact import Contact
from app.models.contact_folder import ContactFolder
tenant = seed["tenant_a"].id
f1 = ContactFolder(tenant_id=tenant, name="Vertrieb", user_id=seed["admin_a"].id)
db.add(f1)
await db.flush()
f2 = ContactFolder(tenant_id=tenant, name="Angebote", user_id=seed["admin_a"].id, parent_id=f1.id)
db.add(f2)
await db.flush()
f3 = ContactFolder(tenant_id=tenant, name="Support", user_id=seed["admin_a"].id)
db.add(f3)
await db.flush()
def _contact(folder, name, ctype, displayname):
return Contact(
tenant_id=tenant,
folder_id=folder.id if folder else None,
type=ctype,
name=name,
displayname=displayname,
owner_id=None,
)
c1 = _contact(f1, "Acme GmbH", "company", "Acme GmbH")
c2 = _contact(f2, "Beta Person", "person", "Beta")
c3 = _contact(f3, "Gamma Person", "person", "Gamma")
c4 = _contact(None, "Delta GmbH", "company", "Delta GmbH")
db.add_all([c1, c2, c3, c4])
await db.flush()
return {"f1": f1, "f2": f2, "f3": f3}
async def _seed_dms_data(db: AsyncSession, seed: dict, member_id) -> dict:
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.models import Folder
tenant = seed["tenant_a"].id
d1 = Folder(tenant_id=tenant, name="Angebote", created_by=seed["admin_a"].id)
db.add(d1)
await db.flush()
d2 = Folder(tenant_id=tenant, name="2026", created_by=seed["admin_a"].id, parent_id=d1.id)
db.add(d2)
await db.flush()
d3 = Folder(tenant_id=tenant, name="Verträge", created_by=seed["admin_a"].id)
db.add(d3)
await db.flush()
def _file(folder, name, mime):
return DmsFile(
tenant_id=tenant,
folder_id=folder.id if folder else None,
name=name,
mime_type=mime,
size_bytes=10,
storage_path=f"/tmp/{name}",
uploaded_by=seed["admin_a"].id,
owner_id=None,
)
files = [
_file(d1, "angebot.pdf", "application/pdf"),
_file(d2, "logo.png", "image/png"),
_file(d3, "vertrag.pdf", "application/pdf"),
_file(d1, "brief.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
_file(None, "lose.pdf", "application/pdf"),
]
db.add_all(files)
await db.flush()
return {"d1": d1, "d2": d2, "d3": d3}
async def _seed_mail_data(db: AsyncSession, seed: dict, member_id) -> dict:
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
tenant = seed["tenant_a"].id
a1 = MailAccount(
tenant_id=tenant,
user_id=member_id,
email_address="vertrieb@example.com",
display_name="Vertrieb",
imap_host="imap.example.com",
smtp_host="smtp.example.com",
username="vertrieb",
encrypted_password="x",
owner_id=member_id,
)
a2 = MailAccount(
tenant_id=tenant,
user_id=member_id,
email_address="info@example.com",
display_name="Info",
imap_host="imap.example.com",
smtp_host="smtp.example.com",
username="info",
encrypted_password="x",
owner_id=member_id,
)
db.add_all([a1, a2])
await db.flush()
folder = MailFolder(tenant_id=tenant, account_id=a1.id, name="INBOX", imap_name="INBOX")
db.add(folder)
await db.flush()
m1 = Mail(
tenant_id=tenant,
account_id=a1.id,
folder_id=folder.id,
message_id="<m1@example.com>",
subject="Angebot",
from_address="kunde@example.com",
)
m2 = Mail(
tenant_id=tenant,
account_id=a2.id,
folder_id=folder.id,
message_id="<m2@example.com>",
subject="Rechnung",
from_address="kunde2@example.com",
)
db.add_all([m1, m2])
await db.flush()
return {"a1": a1, "a2": a2}
async def _seed_calendar_data(db: AsyncSession, seed: dict, member_id) -> dict:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
tenant = seed["tenant_a"].id
cal1 = Calendar(tenant_id=tenant, name="Vertrieb", owner_id=member_id)
cal2 = Calendar(tenant_id=tenant, name="Intern", owner_id=member_id)
db.add_all([cal1, cal2])
await db.flush()
now = datetime(2026, 9, 1, 9, 0, tzinfo=UTC)
e1 = CalendarEntry(
tenant_id=tenant,
calendar_id=cal1.id,
entry_type="appointment",
title="Kundentermin",
start_at=now,
created_by=member_id,
)
e2 = CalendarEntry(
tenant_id=tenant,
calendar_id=cal2.id,
entry_type="appointment",
title="Team",
start_at=now,
created_by=member_id,
)
db.add_all([e1, e2])
await db.flush()
return {"cal1": cal1, "cal2": cal2}
async def _make_workspace(
db: AsyncSession,
seed: dict,
member,
module_configs: dict[str, dict],
) -> uuid.UUID:
"""Workspace + member assignment + module configs (via service)."""
from app.services import workspace_service
tenant = seed["tenant_a"].id
ws = await workspace_service.create_workspace(
db, tenant, seed["admin_a"].id, f"ScopeWS-{uuid.uuid4().hex[:6]}"
)
ws_id = uuid.UUID(ws["id"])
await workspace_service.assign_user(db, tenant, ws_id, member.id, role="member")
modules = [
{"module_key": key, "is_visible": True, "menu_order": i, "config": cfg}
for i, (key, cfg) in enumerate(module_configs.items())
]
if modules:
await workspace_service.set_workspace_modules(db, tenant, ws_id, modules)
await db.commit()
return ws_id
async def _build_seed(
db: AsyncSession,
member_email: str,
config_builder: Callable[[dict], dict[str, dict]],
seeders: list,
) -> tuple[dict, uuid.UUID, str]:
"""Seed tenant + member + data, THEN build the workspace config with real IDs."""
seed = await seed_tenant_and_users(db)
member = await _make_member(db, seed, member_email)
data: dict = {}
for seeder in seeders:
data.update(await seeder(db, seed, member.id))
ws_id = await _make_workspace(db, seed, member, config_builder(data))
return data, ws_id, member_email
# ─── Fixtures (4 plugins active: contacts, dms, mail, calendar) ─
@pytest_asyncio.fixture
async def scope_app(engine: AsyncEngine, redis_client):
"""FastAPI app with the four N3 plugins registered and activated."""
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.core.db import close_engine, reset_engine_for_testing
from app.core.permission_registry import (
init_permission_registry,
register_plugin_permissions,
)
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.builtins.calendar.plugin import CalendarPlugin
from app.plugins.builtins.contacts.plugin import ContactsPlugin
from app.plugins.builtins.dms.plugin import DmsPlugin
from app.plugins.builtins.mail.plugin import MailPlugin
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
init_permission_registry(active_plugin_names={"contacts", "dms", "mail", "calendar", "permissions"})
container = get_container()
await container.initialize()
for plugin in (
PermissionsPlugin(),
ContactsPlugin(),
DmsPlugin(),
MailPlugin(),
CalendarPlugin(),
):
registry.register_plugin(plugin)
if plugin.manifest.permissions:
register_plugin_permissions(plugin.name, plugin.manifest.permissions)
reset_plugin_service_for_testing(registry)
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with sf() as session:
# permissions first (dms depends on it)
for name in ("permissions", "contacts", "dms", "mail", "calendar"):
await registry.install(session, name)
await registry.activate(session, name)
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def scope_client(scope_app) -> AsyncClient:
transport = ASGITransport(app=scope_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def scope_seed(scope_app, db_session: AsyncSession) -> tuple:
"""Contacts: folder scope F1 (subtree includes F2)."""
return await _build_seed(
db_session,
"scope-member@example.com",
lambda data: {"contacts": {"folder_ids": [str(data["f1"].id)]}},
[_seed_contacts_data],
)
@pytest_asyncio.fixture
async def scope_seed_types(scope_app, db_session: AsyncSession) -> tuple:
"""Contacts: type scope companies only."""
return await _build_seed(
db_session,
"scope-types@example.com",
lambda data: {"contacts": {"contact_types": ["company"]}},
[_seed_contacts_data],
)
@pytest_asyncio.fixture
async def scope_seed_combined(scope_app, db_session: AsyncSession) -> tuple:
"""Contacts: folder + type scopes combined (AND proof)."""
return await _build_seed(
db_session,
"scope-combined@example.com",
lambda data: {
"contacts": {
"folder_ids": [str(data["f1"].id)],
"contact_types": ["company"],
}
},
[_seed_contacts_data],
)
@pytest_asyncio.fixture
async def scope_seed_dms(scope_app, db_session: AsyncSession) -> tuple:
return await _build_seed(
db_session,
"scope-dms@example.com",
lambda data: {"dms": {"folder_ids": [str(data["d1"].id)]}},
[_seed_dms_data],
)
@pytest_asyncio.fixture
async def scope_seed_dms_types(scope_app, db_session: AsyncSession) -> tuple:
return await _build_seed(
db_session,
"scope-dms-types@example.com",
lambda data: {"dms": {"file_types": ["image/"]}},
[_seed_dms_data],
)
@pytest_asyncio.fixture
async def scope_seed_mail(scope_app, db_session: AsyncSession) -> tuple:
return await _build_seed(
db_session,
"scope-mail@example.com",
lambda data: {"mail": {"account_ids": [str(data["a1"].id)]}},
[_seed_mail_data],
)
@pytest_asyncio.fixture
async def scope_seed_cal(scope_app, db_session: AsyncSession) -> tuple:
return await _build_seed(
db_session,
"scope-cal@example.com",
lambda data: {"calendar": {"calendar_ids": [str(data["cal1"].id)]}},
[_seed_calendar_data],
)
# ─── Resolver unit tests ─────────────────────────────────────
class TestResolveWorkspaceScope:
async def test_no_header_returns_none(self, db_session: AsyncSession):
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
user = {"tenant_id": str(seed["tenant_a"].id), "user_id": str(seed["admin_a"].id)}
assert await resolve_workspace_scope(db_session, user, None, "contacts") is None
async def test_invalid_header_returns_none(self, db_session: AsyncSession):
"""Invalid UUID = no workspace context = no filter (fail-open to RLS/ABAC)."""
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(seed["admin_a"].id),
"is_system_admin": False,
"permissions": ["contacts:read"],
}
assert await resolve_workspace_scope(db_session, user, "not-a-uuid", "contacts") is None
async def test_system_admin_exempt(self, db_session: AsyncSession):
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
member = await _make_member(db_session, seed, "exempt-admin@example.com")
ws_id = await _make_workspace(
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(member.id),
"is_system_admin": True,
"permissions": ["contacts:read"],
}
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
async def test_configure_modules_holder_exempt(self, db_session: AsyncSession):
"""Editor deadlock: scope editors need the unrestricted value lists."""
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
editor = await _make_member(
db_session,
seed,
"editor@example.com",
extra_perms={"workspaces": {"configure_modules": True}},
)
ws_id = await _make_workspace(
db_session, seed, editor, {"contacts": {"contact_types": ["company"]}}
)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(editor.id),
"is_system_admin": False,
"permissions": ["contacts:read", "workspaces:configure_modules"],
}
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
async def test_unassigned_user_returns_none(self, db_session: AsyncSession):
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
member = await _make_member(db_session, seed, "member@example.com")
other = await _make_member(db_session, seed, "other@example.com")
ws_id = await _make_workspace(
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(other.id),
"is_system_admin": False,
"permissions": ["contacts:read"],
}
assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None
async def test_assigned_user_gets_config(self, db_session: AsyncSession):
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
member = await _make_member(db_session, seed, "member@example.com")
ws_id = await _make_workspace(
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(member.id),
"is_system_admin": False,
"permissions": ["contacts:read"],
}
scope = await resolve_workspace_scope(db_session, user, str(ws_id), "contacts")
assert scope == {"contact_types": ["company"]}
async def test_module_without_config_returns_none(self, db_session: AsyncSession):
from app.services.workspace_scope_service import resolve_workspace_scope
seed = await seed_tenant_and_users(db_session)
member = await _make_member(db_session, seed, "member@example.com")
ws_id = await _make_workspace(
db_session, seed, member, {"contacts": {"contact_types": ["company"]}}
)
user = {
"tenant_id": str(seed["tenant_a"].id),
"user_id": str(member.id),
"is_system_admin": False,
"permissions": ["dms:read"],
}
assert await resolve_workspace_scope(db_session, user, str(ws_id), "dms") is None
# ─── HTTP filtering: contacts ──────────────────────────────────
class TestContactsScopeFiltering:
async def test_folder_scope_includes_subtree(
self, scope_client: AsyncClient, scope_seed: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/contacts", headers=headers)
assert resp.status_code == 200, resp.text
names = {i["displayname"] for i in resp.json()["items"]}
# F1 + subtree F2: Acme (company, F1) + Beta (person, F2)
assert names == {"Acme GmbH", "Beta"}, names
async def test_type_scope_companies_only(
self, scope_client: AsyncClient, scope_seed_types: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_types
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/contacts", headers=headers)
assert resp.status_code == 200, resp.text
names = {i["displayname"] for i in resp.json()["items"]}
# companies only: Acme (F1) + Delta (no folder) + Company Alpha
# (created by the conftest seed_tenant_and_users helper)
assert names == {"Acme GmbH", "Delta GmbH", "Company Alpha"}, names
async def test_combined_scope_is_and(
self, scope_client: AsyncClient, scope_seed_combined: tuple
):
"""Folder AND type: Beta is inside the folder scope but is a person
and must be excluded — the sharp AND proof."""
client = scope_client
data, ws_id, member_email = scope_seed_combined
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/contacts", headers=headers)
assert resp.status_code == 200, resp.text
names = {i["displayname"] for i in resp.json()["items"]}
assert names == {"Acme GmbH"}, names
async def test_no_header_no_filter(
self, scope_client: AsyncClient, scope_seed: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed
await login_client(client, member_email)
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200, resp.text
# 4 own contacts + Company Alpha from the conftest seed
assert resp.json()["total"] == 5
async def test_scope_bypasses_list_cache(
self, scope_client: AsyncClient, scope_seed: tuple
):
"""First unscoped GET caches; the scoped GET must NOT serve that cache."""
client = scope_client
data, ws_id, member_email = scope_seed
await login_client(client, member_email)
unscoped = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
assert unscoped.status_code == 200
assert unscoped.json()["total"] == 5 # cacheable simple query
scoped = await client.get(
"/api/v1/contacts",
headers={**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)},
)
assert scoped.json()["total"] == 2, "Scoped list must not serve the unscoped cache"
# ─── HTTP filtering: dms ──────────────────────────────────────
class TestDmsScopeFiltering:
async def test_folder_scope_subtree_files(
self, scope_client: AsyncClient, scope_seed_dms: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_dms
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/dms/files", headers=headers)
assert resp.status_code == 200, resp.text
names = {f["name"] for f in resp.json()}
# d1 + subtree d2: angebot.pdf + brief.docx (d1) + logo.png (d2)
assert names == {"angebot.pdf", "brief.docx", "logo.png"}, names
async def test_file_type_scope_images_only(
self, scope_client: AsyncClient, scope_seed_dms_types: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_dms_types
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/dms/files", headers=headers)
assert resp.status_code == 200, resp.text
names = {f["name"] for f in resp.json()}
assert names == {"logo.png"}, names
async def test_folder_tree_scoped(
self, scope_client: AsyncClient, scope_seed_dms: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_dms
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/dms/folders", headers=headers)
assert resp.status_code == 200, resp.text
def walk(nodes):
for n in nodes:
yield n["name"]
yield from walk(n.get("children", []))
names = set(walk(resp.json()))
assert names == {"Angebote", "2026"}, names
# ─── HTTP filtering: mail ─────────────────────────────────────
class TestMailScopeFiltering:
async def test_mails_scoped_to_account(
self, scope_client: AsyncClient, scope_seed_mail: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_mail
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/mail", headers=headers)
assert resp.status_code == 200, resp.text
subjects = {m["subject"] for m in resp.json()["mails"]}
assert subjects == {"Angebot"}, subjects
async def test_accounts_picker_scoped(
self, scope_client: AsyncClient, scope_seed_mail: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_mail
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/mail/accounts", headers=headers)
assert resp.status_code == 200, resp.text
addresses = {a["email"] for a in resp.json()}
assert addresses == {"vertrieb@example.com"}, addresses
async def test_threads_scoped(
self, scope_client: AsyncClient, scope_seed_mail: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_mail
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/mail/threads", headers=headers)
assert resp.status_code == 200, resp.text
subjects = {th["subject"] for th in resp.json()}
assert subjects == {"Angebot"}, subjects
# ─── HTTP filtering: calendar ─────────────────────────────────
class TestCalendarScopeFiltering:
async def test_entries_scoped_to_calendar(
self, scope_client: AsyncClient, scope_seed_cal: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_cal
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/calendar/entries", headers=headers)
assert resp.status_code == 200, resp.text
titles = {e["title"] for e in resp.json()}
assert titles == {"Kundentermin"}, titles
async def test_calendars_picker_scoped(
self, scope_client: AsyncClient, scope_seed_cal: tuple
):
client = scope_client
data, ws_id, member_email = scope_seed_cal
await login_client(client, member_email)
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/calendars", headers=headers)
assert resp.status_code == 200, resp.text
names = {c["name"] for c in resp.json()}
assert names == {"Vertrieb"}, names
async def test_admin_bypass_still_sees_all(
self, scope_client: AsyncClient, scope_seed_cal: tuple
):
"""Tenant admin (workspaces:configure_modules via *:*) keeps the full view."""
client = scope_client
data, ws_id, _ = scope_seed_cal
await login_client(client, "admin@tenanta.com")
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
resp = await client.get("/api/v1/calendars", headers=headers)
assert resp.status_code == 200, resp.text
names = {c["name"] for c in resp.json()}
assert names == {"Vertrieb", "Intern"}, names