gateway/tests/unit/interfaces/test_folderRbac.py

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