327 lines
12 KiB
Python
327 lines
12 KiB
Python
# 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
|