215 lines
8 KiB
Python
215 lines
8 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Integration tests for ``AppObjects.updateMandate``.
|
|
|
|
Covers acceptance criteria from
|
|
``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``:
|
|
|
|
- AC#3 -> non-PlatformAdmin update silently drops protected ``name``;
|
|
label-only updates still succeed.
|
|
- AC#4 -> PlatformAdmin update with invalid name format rejected (ValueError → 400).
|
|
- AC#4b -> PlatformAdmin update with empty label rejected.
|
|
- AC#4c -> PlatformAdmin update with name colliding on another row rejected.
|
|
- Idempotent name update (same value) accepted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from modules.datamodels.datamodelUam import Mandate
|
|
from modules.interfaces.interfaceDbApp import AppObjects
|
|
|
|
|
|
class _FakeDb:
|
|
"""Minimal connector: getRecordset(Mandate) + recordModify(Mandate, id, data)."""
|
|
|
|
def __init__(self, rows: List[Dict[str, Any]]):
|
|
self.rows: List[Dict[str, Any]] = [dict(r) for r in rows]
|
|
self.modifyCalls: List[Dict[str, Any]] = []
|
|
|
|
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
|
|
if model is not Mandate:
|
|
return []
|
|
if not recordFilter:
|
|
return [dict(r) for r in self.rows]
|
|
out = []
|
|
for r in self.rows:
|
|
if all(r.get(k) == v for k, v in recordFilter.items()):
|
|
out.append(dict(r))
|
|
return out
|
|
|
|
def recordModify(self, model, recordId: str, payload):
|
|
if hasattr(payload, "model_dump"):
|
|
data = payload.model_dump()
|
|
elif isinstance(payload, dict):
|
|
data = dict(payload)
|
|
else:
|
|
data = {}
|
|
self.modifyCalls.append({"id": str(recordId), "data": dict(data)})
|
|
for r in self.rows:
|
|
if str(r.get("id")) == str(recordId):
|
|
r.update(data)
|
|
return r
|
|
return None
|
|
|
|
|
|
def _buildInterface(db: _FakeDb, *, isPlatformAdmin: bool, isSysAdmin: bool = False) -> AppObjects:
|
|
iface = AppObjects.__new__(AppObjects)
|
|
iface.db = db
|
|
iface.currentUser = Mock(
|
|
id="user-x",
|
|
isPlatformAdmin=isPlatformAdmin,
|
|
isSysAdmin=isSysAdmin,
|
|
)
|
|
iface.userId = "user-x"
|
|
iface.mandateId = None
|
|
iface.featureInstanceId = None
|
|
iface.rbac = Mock()
|
|
return iface
|
|
|
|
|
|
def _row(mid: str = "m1", name: str = "alpha", label: str = "Alpha", **extra) -> Dict[str, Any]:
|
|
base = {
|
|
"id": mid,
|
|
"name": name,
|
|
"label": label,
|
|
"enabled": True,
|
|
"isSystem": False,
|
|
}
|
|
base.update(extra)
|
|
return base
|
|
|
|
|
|
def _stubGetMandateAndRbac(iface: AppObjects, row: Dict[str, Any]):
|
|
"""Wire ``getMandate`` to read from the FakeDb so post-update reads reflect changes."""
|
|
db = iface.db
|
|
|
|
def _readMandate(mandateId: str):
|
|
for r in db.rows:
|
|
if str(r.get("id")) == str(mandateId):
|
|
return Mandate(**r)
|
|
return None
|
|
|
|
iface.getMandate = Mock(side_effect=_readMandate)
|
|
return patch.object(iface, "checkRbacPermission", return_value=True)
|
|
|
|
|
|
class TestUpdateMandateRbacOnName:
|
|
def test_mandateAdminCannotChangeName(self):
|
|
"""Non-platform admin: ``name`` is a protected field, silently dropped.
|
|
|
|
Status quo: route layer also enforces this via ``_MANDATE_ADMIN_EDITABLE_FIELDS``,
|
|
but the interface itself MUST also defend so that direct calls don't bypass.
|
|
"""
|
|
row = _row(mid="m1", name="original-slug", label="Original")
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=False)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"name": "hacked-slug", "label": "New Label"})
|
|
assert updated.name == "original-slug", "MandateAdmin must NOT modify name"
|
|
assert updated.label == "New Label"
|
|
|
|
def test_platformAdminCanChangeName(self):
|
|
row = _row(mid="m1", name="old-slug", label="Old")
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"name": "new-slug"})
|
|
assert updated.name == "new-slug"
|
|
|
|
def test_sysAdminCanChangeName(self):
|
|
row = _row(mid="m1", name="old-slug", label="Old")
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=False, isSysAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"name": "syscall-slug"})
|
|
assert updated.name == "syscall-slug"
|
|
|
|
|
|
class TestUpdateMandateNameValidation:
|
|
def test_invalidNameRejected(self):
|
|
row = _row()
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
with pytest.raises(ValueError) as excInfo:
|
|
iface.updateMandate("m1", {"name": "ABC Müller!"})
|
|
assert "Kurzzeichen" in str(excInfo.value) or "Failed to update" in str(excInfo.value)
|
|
|
|
def test_uppercaseNameRejected(self):
|
|
row = _row()
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
with pytest.raises(ValueError):
|
|
iface.updateMandate("m1", {"name": "ALPHA"})
|
|
|
|
def test_leadingHyphenRejected(self):
|
|
row = _row()
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
with pytest.raises(ValueError):
|
|
iface.updateMandate("m1", {"name": "-leading"})
|
|
|
|
def test_idempotentSameNameAccepted(self):
|
|
row = _row(mid="m1", name="alpha", label="Alpha")
|
|
db = _FakeDb([row, _row(mid="m2", name="beta", label="Beta")])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"name": "alpha"})
|
|
assert updated.name == "alpha"
|
|
|
|
def test_collisionWithOtherMandateRejected(self):
|
|
rows = [
|
|
_row(mid="m1", name="alpha", label="Alpha"),
|
|
_row(mid="m2", name="beta", label="Beta"),
|
|
]
|
|
db = _FakeDb(rows)
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, rows[0]):
|
|
with pytest.raises(ValueError) as excInfo:
|
|
iface.updateMandate("m1", {"name": "beta"})
|
|
assert "already in use" in str(excInfo.value)
|
|
|
|
|
|
class TestUpdateMandateLabelValidation:
|
|
def test_emptyLabelRejected(self):
|
|
row = _row()
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
with pytest.raises(ValueError) as excInfo:
|
|
iface.updateMandate("m1", {"label": " "})
|
|
assert "label" in str(excInfo.value).lower()
|
|
|
|
def test_labelTrimmed(self):
|
|
row = _row()
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"label": " Trimmed Name "})
|
|
assert updated.label == "Trimmed Name"
|
|
|
|
|
|
class TestUpdateMandateProtectedFields:
|
|
def test_idCannotBeChanged(self):
|
|
row = _row(mid="m1", name="alpha", label="Alpha")
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"id": "spoofed", "label": "New"})
|
|
assert str(updated.id) == "m1", "id field must remain immutable"
|
|
|
|
def test_isSystemRequiresSysAdmin(self):
|
|
row = _row(mid="m1", name="alpha", label="Alpha", isSystem=False)
|
|
db = _FakeDb([row])
|
|
iface = _buildInterface(db, isPlatformAdmin=True, isSysAdmin=False)
|
|
with _stubGetMandateAndRbac(iface, row):
|
|
updated = iface.updateMandate("m1", {"isSystem": True, "label": "New"})
|
|
assert updated.isSystem is False, "PlatformAdmin alone must NOT escalate isSystem"
|