# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Unit tests for folder RBAC two-user matrix (ownership & scope visibility).""" import uuid import pytest from unittest.mock import Mock, patch, MagicMock from typing import Dict, Any, List, Optional from modules.datamodels.datamodelFiles import FileFolder, FileItem from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.interfaces.interfaceDbManagement import ComponentObjects, FileNotFoundError _MANDATE_ID = "mandate-test-1" _FEATURE_INSTANCE_ID = "fi-test-1" _USER_A = "user-a-id" _USER_B = "user-b-id" # ── Fakes & helpers ────────────────────────────────────────────────────────── class _FakeDb: """In-memory database mock.""" def __init__(self): self._tables: Dict[str, Dict[str, Dict[str, Any]]] = {} self.connection = MagicMock() def getRecordset(self, modelClass, recordFilter=None): tableName = modelClass.__name__ records = list(self._tables.get(tableName, {}).values()) if not recordFilter: return records return [ r for r in records if all(r.get(k) == v for k, v in recordFilter.items()) ] def recordCreate(self, modelClass, data): tableName = modelClass.__name__ self._tables.setdefault(tableName, {}) rec = data.model_dump() if hasattr(data, "model_dump") else dict(data) rec.setdefault("id", str(uuid.uuid4())) self._tables[tableName][rec["id"]] = rec return rec def recordModify(self, modelClass, recordId, updates): tbl = self._tables.get(modelClass.__name__, {}) if recordId in tbl: tbl[recordId].update(updates) return True return False def recordDelete(self, modelClass, recordId): tbl = self._tables.get(modelClass.__name__, {}) if recordId in tbl: del tbl[recordId] return True return False def updateContext(self, userId): pass def _ensure_connection(self): pass def _ensureTableExists(self, modelClass): return True def seed(self, modelClass, record: Dict[str, Any]): tableName = modelClass.__name__ self._tables.setdefault(tableName, {}) self._tables[tableName][record["id"]] = dict(record) def _makeUser(userId, username="testuser"): return User(id=userId, username=username, language="en") def _makeRbac( createLevel=AccessLevel.ALL, readLevel=AccessLevel.ALL, updateLevel=AccessLevel.MY, deleteLevel=AccessLevel.MY, ): """Default: regular user can read all, but write only own records.""" rbac = Mock() perms = UserPermissions( view=True, read=readLevel, create=createLevel, update=updateLevel, delete=deleteLevel, ) rbac.getUserPermissions.return_value = perms return rbac def _buildComponent(userId, fakeDb, rbac=None): with patch.object(ComponentObjects, "__init__", lambda self: None): comp = ComponentObjects() comp.db = fakeDb comp.currentUser = _makeUser(userId) comp.userId = userId comp.mandateId = _MANDATE_ID comp.featureInstanceId = _FEATURE_INSTANCE_ID comp.rbac = rbac or _makeRbac() comp.userLanguage = "en" return comp def _makeFolder( folderId=None, name="Folder", parentId=None, userId=_USER_A, scope="personal", neutralize=False, ): return { "id": folderId or str(uuid.uuid4()), "name": name, "parentId": parentId, "mandateId": _MANDATE_ID, "featureInstanceId": _FEATURE_INSTANCE_ID, "scope": scope, "neutralize": neutralize, "sysCreatedBy": userId, "sysCreatedAt": 1700000000.0, "sysModifiedAt": 1700000000.0, "sysModifiedBy": None, } def _makeFile(fileId=None, folderId=None, userId=_USER_A, scope="personal"): return { "id": fileId or str(uuid.uuid4()), "fileName": "test.txt", "mimeType": "text/plain", "fileHash": "abc123", "fileSize": 100, "folderId": folderId, "mandateId": _MANDATE_ID, "featureInstanceId": _FEATURE_INSTANCE_ID, "scope": scope, "neutralize": False, "sysCreatedBy": userId, "sysCreatedAt": 1700000000.0, "sysModifiedAt": 1700000000.0, "sysModifiedBy": None, "tags": None, "description": None, "status": None, } def _scopeAwareMock(fakeDb): """Side-effect for getRecordsetWithRBAC that simulates scope-based visibility. Visibility rules: - Owner (sysCreatedBy == currentUser.id) always sees the record - scope='global' -> visible to everyone - scope='mandate' -> visible when mandateId matches - scope='featureInstance' -> visible when featureInstanceId matches - scope='personal' -> owner only (already covered above) """ def _fn(connector, modelClass, currentUser, recordFilter=None, **kwargs): requestMandateId = kwargs.get("mandateId", _MANDATE_ID) requestFiId = kwargs.get("featureInstanceId", _FEATURE_INSTANCE_ID) allRecords = fakeDb.getRecordset(modelClass, recordFilter=recordFilter) visible = [] for rec in allRecords: if rec.get("sysCreatedBy") == currentUser.id: visible.append(rec) continue scope = rec.get("scope", "personal") if scope == "global": visible.append(rec) elif scope == "mandate" and rec.get("mandateId") == requestMandateId: visible.append(rec) elif scope == "featureInstance" and rec.get("featureInstanceId") == requestFiId: visible.append(rec) return visible return _fn # ── Test class ─────────────────────────────────────────────────────────────── @patch("modules.interfaces.interfaceDbManagement.getRecordsetWithRBAC") class TestFolderRbac: """Two-user matrix: ownership, scope visibility, and write-access guards.""" # ── 1. Ownership visibility ─────────────────────────────────────────── def testUserAFolderInOwnTreeNotInUserBOwnTree(self, mockRbacGet): """User A's personal folder appears in A's own tree, not in B's.""" fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", name="A-Folder", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compA = _buildComponent(_USER_A, fakeDb) ownA = compA.getOwnFolderTree() assert any(f["id"] == "fa-1" for f in ownA) compB = _buildComponent(_USER_B, fakeDb) ownB = compB.getOwnFolderTree() assert not any(f["id"] == "fa-1" for f in ownB) # ── 2. Scope change -> shared visibility ────────────────────────────── def testScopeChangeToMandateMakesVisibleToUserB(self, mockRbacGet): """Changing scope from personal to mandate makes the folder appear in User B's shared tree.""" fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="personal", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) sharedBefore = compB.getSharedFolderTree() assert not any(f["id"] == "fa-1" for f in sharedBefore) fakeDb.recordModify(FileFolder, "fa-1", {"scope": "mandate"}) sharedAfter = compB.getSharedFolderTree() assert any(f["id"] == "fa-1" for f in sharedAfter) # ── 3-7. Non-owner cannot mutate ────────────────────────────────────── def testUserBCannotRenameFolderOfUserA(self, mockRbacGet): fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="mandate", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) with pytest.raises(PermissionError): compB.renameFolder("fa-1", "Hijacked") def testUserBCannotMoveFolderOfUserA(self, mockRbacGet): fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="mandate", userId=_USER_A)) fakeDb.seed(FileFolder, _makeFolder(folderId="fb-1", scope="mandate", userId=_USER_B)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) with pytest.raises(PermissionError): compB.moveFolder("fa-1", "fb-1") def testUserBCannotDeleteFolderOfUserA(self, mockRbacGet): fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="mandate", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) with pytest.raises(PermissionError): compB.deleteFolderCascade("fa-1") def testUserBCannotPatchScopeOnFolderOfUserA(self, mockRbacGet): fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="mandate", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) with pytest.raises(PermissionError): compB.patchFolderScope("fa-1", "personal") def testUserBCannotPatchNeutralizeOnFolderOfUserA(self, mockRbacGet): fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder(folderId="fa-1", scope="mandate", userId=_USER_A)) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) with pytest.raises(PermissionError): compB.patchFolderNeutralize("fa-1", True) # ── 8. contextOrphan ────────────────────────────────────────────────── def testContextOrphanWhenParentFolderNotShared(self, mockRbacGet): """User A's parent folder is personal, child folder is mandate. User B sees only the child, flagged as contextOrphan.""" fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder( folderId="parent-f", name="Private Parent", userId=_USER_A, scope="personal", )) fakeDb.seed(FileFolder, _makeFolder( folderId="child-f", name="Shared Child", userId=_USER_A, parentId="parent-f", scope="mandate", )) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) shared = compB.getSharedFolderTree() assert len(shared) == 1 assert shared[0]["id"] == "child-f" assert shared[0]["contextOrphan"] is True # ── 9. Shared folder children visible ───────────────────────────────── def testSharedFolderMakesChildrenVisible(self, mockRbacGet): """When User A shares a folder tree (scope=mandate), all child folders become visible in User B's shared tree.""" fakeDb = _FakeDb() fakeDb.seed(FileFolder, _makeFolder( folderId="root-f", name="Root", userId=_USER_A, scope="mandate", )) fakeDb.seed(FileFolder, _makeFolder( folderId="child1-f", name="Child 1", userId=_USER_A, parentId="root-f", scope="mandate", )) fakeDb.seed(FileFolder, _makeFolder( folderId="child2-f", name="Child 2", userId=_USER_A, parentId="root-f", scope="mandate", )) fakeDb.seed(FileFolder, _makeFolder( folderId="grandchild-f", name="Grandchild", userId=_USER_A, parentId="child1-f", scope="mandate", )) mockRbacGet.side_effect = _scopeAwareMock(fakeDb) compB = _buildComponent(_USER_B, fakeDb) shared = compB.getSharedFolderTree() sharedIds = {f["id"] for f in shared} assert sharedIds == {"root-f", "child1-f", "child2-f", "grandchild-f"} byId = {f["id"]: f for f in shared} assert byId["root-f"]["contextOrphan"] is False assert byId["child1-f"]["contextOrphan"] is False assert byId["child2-f"]["contextOrphan"] is False assert byId["grandchild-f"]["contextOrphan"] is False