gateway/tests/integration/mandates/test_updateMandate.py

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"