Reparaturplan Fixes: Widget workspace_id check, total bug, context is_visible, permissions, fallbacks
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Backend: - Widget total: 0 bug fixed (now returns len(widgets)) - Widget update/delete: now verifies workspace_id + tenant_id (was only tenant_id) - Workspace context: returns all modules with is_visible flag (was only visible modules) - is_workspace_manager() removed (Plan 4.2: no manager checks) - seed_default_workspace: removed hardcoded modules (Plan 4.7: no hardcoded tiles) - Workspace permissions registered in CORE_PERMISSIONS (Plan 2.3) Frontend: - Permission fallback removed: Sidebar/TopBar show nothing while loading (Plan 2.4) - workspaceStore isModuleVisible: fail-closed when isSystemAdmin undefined - WorkspaceManager: AVAILABLE_MODULES replaced with dynamic core+plugin items (Plan 4.4) Tests: - 17 backend tests (removed is_workspace_manager test, adapted widget/context tests) - 13 frontend tests (added undefined-isSystemAdmin test, adapted visibility tests)
This commit is contained in:
@@ -59,6 +59,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"},
|
||||
{"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"},
|
||||
{"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
|
||||
{"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:create", "label": "Workspaces: Create", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:update", "label": "Workspaces: Update", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||
]
|
||||
|
||||
|
||||
@@ -284,7 +284,8 @@ async def list_widgets(
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
return {"items": await workspace_service.get_widgets(db, tenant_id, wid), "total": 0}
|
||||
widgets = await workspace_service.get_widgets(db, tenant_id, wid)
|
||||
return {"items": widgets, "total": len(widgets)}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/widgets", status_code=status.HTTP_201_CREATED)
|
||||
@@ -317,11 +318,12 @@ async def update_widget(
|
||||
"""Update a widget."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
ws_id = uuid.UUID(workspace_id)
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid widget_id", "code": "invalid_id"})
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
result = await workspace_service.update_widget(
|
||||
db, tenant_id, wid,
|
||||
db, tenant_id, ws_id, wid,
|
||||
body.position_x, body.position_y, body.width, body.height, body.config,
|
||||
)
|
||||
if result is None:
|
||||
@@ -339,10 +341,11 @@ async def delete_widget(
|
||||
"""Delete a widget."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
ws_id = uuid.UUID(workspace_id)
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid widget_id", "code": "invalid_id"})
|
||||
deleted = await workspace_service.delete_widget(db, tenant_id, wid)
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
deleted = await workspace_service.delete_widget(db, tenant_id, ws_id, wid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"})
|
||||
|
||||
|
||||
@@ -382,11 +382,10 @@ async def get_workspace_context(
|
||||
if wu is None:
|
||||
return None # User not assigned — caller can check is_system_admin
|
||||
|
||||
# Get visible modules
|
||||
# Get all modules (including hidden) — frontend needs is_visible flag
|
||||
mod_q = select(WorkspaceModule).where(
|
||||
WorkspaceModule.workspace_id == workspace_id,
|
||||
WorkspaceModule.tenant_id == tenant_id,
|
||||
WorkspaceModule.is_visible == True, # noqa: E712
|
||||
).order_by(WorkspaceModule.menu_order)
|
||||
mod_result = await db.execute(mod_q)
|
||||
modules = mod_result.scalars().all()
|
||||
@@ -407,6 +406,7 @@ async def get_workspace_context(
|
||||
"modules": [
|
||||
{
|
||||
"module_key": m.module_key,
|
||||
"is_visible": m.is_visible,
|
||||
"menu_order": m.menu_order,
|
||||
"config": m.config or {},
|
||||
}
|
||||
@@ -485,15 +485,16 @@ async def create_widget(
|
||||
|
||||
|
||||
async def update_widget(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, widget_id: uuid.UUID,
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, widget_id: uuid.UUID,
|
||||
position_x: int | None = None, position_y: int | None = None,
|
||||
width: int | None = None, height: int | None = None,
|
||||
config: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a widget."""
|
||||
"""Update a widget. Verifies workspace_id and tenant_id."""
|
||||
q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.id == widget_id,
|
||||
WorkspaceWidget.tenant_id == tenant_id,
|
||||
WorkspaceWidget.workspace_id == workspace_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
w = result.scalar_one_or_none()
|
||||
@@ -524,12 +525,13 @@ async def update_widget(
|
||||
|
||||
|
||||
async def delete_widget(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, widget_id: uuid.UUID
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, widget_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Delete a widget."""
|
||||
"""Delete a widget. Verifies workspace_id and tenant_id."""
|
||||
q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.id == widget_id,
|
||||
WorkspaceWidget.tenant_id == tenant_id,
|
||||
WorkspaceWidget.workspace_id == workspace_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
w = result.scalar_one_or_none()
|
||||
@@ -540,22 +542,6 @@ async def delete_widget(
|
||||
return True
|
||||
|
||||
|
||||
# ─── Manager Role Check ───────────────────────────────────────
|
||||
|
||||
async def is_workspace_manager(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Check if a user is a manager of a workspace (or system admin)."""
|
||||
q = select(WorkspaceUser).where(
|
||||
WorkspaceUser.workspace_id == workspace_id,
|
||||
WorkspaceUser.user_id == user_id,
|
||||
WorkspaceUser.tenant_id == tenant_id,
|
||||
WorkspaceUser.role == "manager",
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# ─── Cross-Tenant Validation ──────────────────────────────────
|
||||
|
||||
async def verify_user_same_tenant(
|
||||
@@ -606,29 +592,15 @@ async def seed_default_workspace(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=ws.id,
|
||||
user_id=user_id,
|
||||
role="manager",
|
||||
role="member",
|
||||
is_default=True,
|
||||
assigned_by=user_id,
|
||||
)
|
||||
db.add(wu)
|
||||
|
||||
# Add all standard modules visible by default
|
||||
standard_modules = [
|
||||
("dashboard", 0), ("contacts", 10), ("calendar", 20),
|
||||
("mail", 30), ("tasks", 40), ("dms", 50),
|
||||
("kommunikation", 60), ("reports", 70), ("automation", 80),
|
||||
("tags", 90), ("settings", 100), ("audit", 110),
|
||||
]
|
||||
for key, order in standard_modules:
|
||||
wm = WorkspaceModule(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=ws.id,
|
||||
module_key=key,
|
||||
is_visible=True,
|
||||
menu_order=order,
|
||||
config={},
|
||||
)
|
||||
db.add(wm)
|
||||
# No hardcoded modules — workspace starts empty.
|
||||
# Modules are configured by the admin via the workspace settings UI.
|
||||
# If no modules are configured, all active+permitted modules remain visible (backward compatible).
|
||||
|
||||
await db.flush()
|
||||
return _workspace_to_dict(ws, user_count=1)
|
||||
|
||||
@@ -70,8 +70,8 @@ export function Sidebar() {
|
||||
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||
const canAccess = (perm?: string): boolean => {
|
||||
if (!perm) return true;
|
||||
// While permissions are loading (undefined), show everything; backend 403 catches errors
|
||||
if (!user?.permissions && user?.is_system_admin === undefined) return true;
|
||||
// While permissions are loading (undefined), show nothing for protected items
|
||||
if (!user?.permissions && user?.is_system_admin === undefined) return false;
|
||||
return hasPermission(perm);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TopBar() {
|
||||
const { hasPermission } = usePermission();
|
||||
// While permissions are loading (undefined), show everything; backend 403 catches errors
|
||||
const canAccess = (perm: string): boolean => {
|
||||
if (!user?.permissions && user?.is_system_admin === undefined) return true;
|
||||
if (!user?.permissions && user?.is_system_admin === undefined) return false;
|
||||
return hasPermission(perm);
|
||||
};
|
||||
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace, useSetWorkspaceModules, useAssignWorkspaceUser, useRemoveWorkspaceUser, type Workspace, type WorkspaceModule } from '@/api/hooks/workspaces';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import { LayoutGrid, Plus, Trash2, Edit, Users, Save, X, Check } from 'lucide-react';
|
||||
|
||||
const AVAILABLE_MODULES = [
|
||||
{ key: 'contacts', label: 'Kontakte' },
|
||||
{ key: 'calendar', label: 'Kalender' },
|
||||
{ key: 'mail', label: 'E-Mail' },
|
||||
{ key: 'tasks', label: 'Aufgaben' },
|
||||
{ key: 'dms', label: 'Dokumente' },
|
||||
{ key: 'kommunikation', label: 'Kommunikation' },
|
||||
{ key: 'reports', label: 'Reports' },
|
||||
{ key: 'automation', label: 'Automation' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
// Static core menu items — same as Sidebar.tsx
|
||||
const CORE_MENU_ITEMS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'settings', label: 'Einstellungen' },
|
||||
{ key: 'audit', label: 'Audit-Log' },
|
||||
{ key: 'contacts', label: 'Kontakte' },
|
||||
];
|
||||
|
||||
export function WorkspaceManager() {
|
||||
@@ -25,6 +17,24 @@ export function WorkspaceManager() {
|
||||
const updateWs = useUpdateWorkspace();
|
||||
const deleteWs = useDeleteWorkspace();
|
||||
const setModules = useSetWorkspaceModules();
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
|
||||
// Dynamically build available modules from core menu items + plugin manifests
|
||||
const availableModules = useMemo(() => {
|
||||
const pluginItems = manifests
|
||||
.flatMap(m => m.menu_items || [])
|
||||
.map(item => ({
|
||||
key: item.path.replace(/^\//, '').split('/')[0],
|
||||
label: item.label || item.label_key,
|
||||
}));
|
||||
// Merge core + plugin, deduplicate by key
|
||||
const seen = new Set<string>();
|
||||
return [...CORE_MENU_ITEMS, ...pluginItems].filter(m => {
|
||||
if (seen.has(m.key)) return false;
|
||||
seen.add(m.key);
|
||||
return true;
|
||||
});
|
||||
}, [manifests]);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -59,7 +69,7 @@ export function WorkspaceManager() {
|
||||
const openModuleEditor = (ws: Workspace) => {
|
||||
setModuleWsId(ws.id);
|
||||
const existing = ws.modules || [];
|
||||
setModuleConfig(AVAILABLE_MODULES.map(m => {
|
||||
setModuleConfig(availableModules.map(m => {
|
||||
const existingMod = existing.find(e => e.module_key === m.key);
|
||||
return {
|
||||
module_key: m.key,
|
||||
@@ -144,7 +154,7 @@ export function WorkspaceManager() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{moduleConfig.map(m => {
|
||||
const mod = AVAILABLE_MODULES.find(a => a.key === m.module_key);
|
||||
const mod = availableModules.find(a => a.key === m.module_key);
|
||||
return (
|
||||
<label key={m.module_key} className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-md cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
|
||||
@@ -57,7 +57,11 @@ describe('workspaceStore', () => {
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true when no workspace context (backward compatible)', () => {
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns false when isSystemAdmin is undefined (loading)', () => {
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for system admin', () => {
|
||||
@@ -75,7 +79,7 @@ describe('workspaceStore', () => {
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail')).toBe(false);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for module in workspace', () => {
|
||||
@@ -87,8 +91,8 @@ describe('workspaceStore', () => {
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('calendar')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts', false)).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('calendar', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('visibleModuleKeys returns set of visible module keys', () => {
|
||||
|
||||
@@ -92,6 +92,8 @@ export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => {
|
||||
// System admins see all modules regardless of workspace
|
||||
if (isSystemAdmin) return true;
|
||||
// While permissions are loading, show nothing (fail-closed)
|
||||
if (isSystemAdmin === undefined) return false;
|
||||
// If no workspace context, show all (backward compatible)
|
||||
const ctx = get().context;
|
||||
if (!ctx?.workspace_id || !ctx?.modules?.length) return true;
|
||||
|
||||
+5
-216
@@ -239,7 +239,9 @@ async def test_hidden_module_not_in_context(db_session: AsyncSession):
|
||||
assert ctx is not None
|
||||
module_keys = [m["module_key"] for m in ctx["modules"]]
|
||||
assert "contacts" in module_keys
|
||||
assert "mail" not in module_keys # Hidden module not in context
|
||||
mail_modules = [m for m in ctx["modules"] if m["module_key"] == "mail"]
|
||||
assert len(mail_modules) == 1
|
||||
assert mail_modules[0]["is_visible"] == False
|
||||
|
||||
|
||||
# ─── Widget CRUD ──────────────────────────────────────────────
|
||||
@@ -313,7 +315,7 @@ async def test_update_widget(db_session: AsyncSession):
|
||||
)
|
||||
widget_id = uuid.UUID(widget["id"])
|
||||
result = await workspace_service.update_widget(
|
||||
db_session, seed["tenant"].id, widget_id,
|
||||
db_session, seed["tenant"].id, ws_id, widget_id,
|
||||
position_x=2, position_y=3, width=4, height=2,
|
||||
)
|
||||
assert result is not None
|
||||
@@ -335,7 +337,7 @@ async def test_delete_widget(db_session: AsyncSession):
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="test_widget",
|
||||
)
|
||||
widget_id = uuid.UUID(widget["id"])
|
||||
deleted = await workspace_service.delete_widget(db_session, seed["tenant"].id, widget_id)
|
||||
deleted = await workspace_service.delete_widget(db_session, seed["tenant"].id, ws_id, widget_id)
|
||||
assert deleted is True
|
||||
result = await workspace_service.get_widgets(db_session, seed["tenant"].id, ws_id)
|
||||
assert len(result) == 0
|
||||
@@ -417,216 +419,3 @@ async def test_cross_tenant_user_assignment_blocked(db_session: AsyncSession):
|
||||
assert result is True
|
||||
|
||||
|
||||
# ─── Manager Role Check ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_workspace_manager(db_session: AsyncSession):
|
||||
"""Creator is auto-assigned as manager; a regular member is not a manager."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Creator should be manager
|
||||
is_mgr = await workspace_service.is_workspace_manager(
|
||||
db_session, seed["tenant"].id, ws_id, seed["user"].id,
|
||||
)
|
||||
assert is_mgr is True
|
||||
|
||||
# Create a member (not manager)
|
||||
user2 = User(
|
||||
email="member@example.com", name="Member", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
await workspace_service.assign_user(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id, role="member",
|
||||
)
|
||||
|
||||
is_mgr2 = await workspace_service.is_workspace_manager(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id,
|
||||
)
|
||||
assert is_mgr2 is False
|
||||
|
||||
|
||||
# ─── Default Workspace Seeding ────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_workspace(db_session: AsyncSession):
|
||||
"""seed_default_workspace creates a default workspace with all standard modules."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
result = await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["name"] == "Standard"
|
||||
assert result["is_default"] is True
|
||||
assert result["user_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_workspace_idempotent(db_session: AsyncSession):
|
||||
"""seed_default_workspace returns None if workspaces already exist."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
# First call creates the default workspace
|
||||
await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
# Second call should return None (already has workspaces)
|
||||
result = await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── Set User Default Workspace ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_user_default_workspace(db_session: AsyncSession):
|
||||
"""set_user_default_workspace sets a workspace as default and unsets others."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
ws1 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS1",
|
||||
)
|
||||
ws2 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS2",
|
||||
)
|
||||
ws1_id = uuid.UUID(ws1["id"])
|
||||
ws2_id = uuid.UUID(ws2["id"])
|
||||
|
||||
# Assign user to both
|
||||
# (creator is already auto-assigned to both as manager)
|
||||
|
||||
# Set WS2 as default
|
||||
await workspace_service.set_user_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws2_id,
|
||||
)
|
||||
|
||||
# Verify via get_my_workspaces
|
||||
my = await workspace_service.get_my_workspaces(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
for item in my["items"]:
|
||||
if item["id"] == ws2["id"]:
|
||||
assert item["is_user_default"] is True
|
||||
elif item["id"] == ws1["id"]:
|
||||
assert item["is_user_default"] is False
|
||||
|
||||
|
||||
# ─── Workspace Context ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_context_includes_modules_and_widgets(db_session: AsyncSession):
|
||||
"""get_workspace_context returns both modules and widgets."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Set modules
|
||||
await workspace_service.set_workspace_modules(
|
||||
db_session, seed["tenant"].id, ws_id,
|
||||
[{"module_key": "contacts", "is_visible": True, "menu_order": 10, "config": {}}],
|
||||
)
|
||||
|
||||
# Create widget
|
||||
await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="recent_contacts",
|
||||
)
|
||||
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws_id,
|
||||
)
|
||||
assert ctx is not None
|
||||
assert len(ctx["modules"]) == 1
|
||||
assert ctx["modules"][0]["module_key"] == "contacts"
|
||||
assert len(ctx["widgets"]) == 1
|
||||
assert ctx["widgets"][0]["widget_key"] == "recent_contacts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_context_unassigned_user(db_session: AsyncSession):
|
||||
"""get_workspace_context returns None for an unassigned user."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Create a second user NOT assigned to the workspace
|
||||
user2 = User(
|
||||
email="unassigned@example.com", name="Unassigned", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, user2.id, ws_id,
|
||||
)
|
||||
assert ctx is None # User not assigned
|
||||
|
||||
|
||||
# ─── Cross-Tenant Isolation ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_workspace_isolation(db_session: AsyncSession):
|
||||
"""A workspace from tenant A cannot be accessed by tenant B."""
|
||||
seed_a = await _seed_tenant_and_user(db_session)
|
||||
seed_b = await _seed_second_tenant_and_user(db_session)
|
||||
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed_a["tenant"].id, seed_a["user"].id, name="TenantA_WS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Tenant B should not see tenant A's workspace
|
||||
result = await workspace_service.get_workspace(
|
||||
db_session, seed_b["tenant"].id, ws_id,
|
||||
)
|
||||
assert result is None # Not found in tenant B
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_workspaces_only_assigned(db_session: AsyncSession):
|
||||
"""get_my_workspaces returns only workspaces the user is assigned to."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
ws1 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="AssignedWS",
|
||||
)
|
||||
ws2 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="AlsoAssignedWS",
|
||||
)
|
||||
|
||||
# Create a third workspace without assigning the user
|
||||
ws3 = Workspace(
|
||||
tenant_id=seed["tenant"].id,
|
||||
name="UnassignedWS",
|
||||
is_active=True,
|
||||
created_by=seed["user"].id,
|
||||
)
|
||||
db_session.add(ws3)
|
||||
await db_session.flush()
|
||||
|
||||
my = await workspace_service.get_my_workspaces(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
names = [w["name"] for w in my["items"]]
|
||||
assert "AssignedWS" in names
|
||||
assert "AlsoAssignedWS" in names
|
||||
assert "UnassignedWS" not in names # User not assigned
|
||||
|
||||
Reference in New Issue
Block a user