# 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"