feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
+31
View File
@@ -5271,6 +5271,36 @@ class ApiSmokeTests(unittest.TestCase):
visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id)
self.assertIsNotNone(visible_key["revoked_at"])
def test_navigation_separator_layout_survives_system_tenant_and_personal_saves(self) -> None:
headers, _ = self._login()
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
layout = {"contract_version": "1", "order": ["files.navigation.files", "separator:mail", "mail.navigation.mail"], "hidden": [], "locked": ["mail.navigation.mail"], "separators": [{"id": "separator:mail", "label": "Nachrichten"}]}
saved = self.client.patch("/api/v1/admin/system/settings", headers=headers, json={
**{key: system[key] for key in ("default_locale", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys")}, "navigation": layout,
})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(layout, saved.json()["navigation"])
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
tenant_layout = {**layout, "locked": []}
saved = self.client.patch("/api/v1/admin/tenant/settings", headers=headers, json={"default_locale": tenant["default_locale"], "navigation": tenant_layout})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(tenant_layout, saved.json()["navigation"])
personal_layout = {**tenant_layout, "order": ["mail.navigation.mail", "separator:personal", "files.navigation.files"], "hidden": ["mail.navigation.mail"], "separators": [{"id": "separator:personal", "label": "Meine Arbeit"}]}
saved = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": personal_layout}})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(personal_layout, saved.json()["user"]["ui_preferences"]["navigation"])
loaded = self.client.get("/api/v1/auth/profile", headers=headers)
self.assertEqual(personal_layout, loaded.json()["user"]["ui_preferences"]["navigation"])
from govoplan_core.core.navigation import navigation_preferences_from_mapping, resolve_navigation_preferences
# HTTP persistence is checked above; scope projection stays independently deterministic.
resolved = resolve_navigation_preferences(("files.navigation.files", "mail.navigation.mail"), system=navigation_preferences_from_mapping(layout), tenant=navigation_preferences_from_mapping(tenant_layout), user=navigation_preferences_from_mapping(personal_layout))
self.assertTrue(resolved["mail.navigation.mail"].visible)
self.assertTrue(resolved["mail.navigation.mail"].locked)
self.assertEqual("Meine Arbeit", resolved["files.navigation.files"].section.label)
reset = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": None}})
self.assertEqual(200, reset.status_code, reset.text)
self.assertIsNone(reset.json()["user"]["ui_preferences"]["navigation"])
def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None:
headers, _ = self._login()
@@ -6860,6 +6890,7 @@ class ApiSmokeTests(unittest.TestCase):
"order": ["files.navigation.files", "mail.navigation.mail"],
"hidden": ["mail.navigation.mail"],
"locked": [],
"separators": None,
},
},
)
+41
View File
@@ -4,6 +4,7 @@ import unittest
from govoplan_core.core.navigation import (
NavigationPreferences,
NavigationSeparator,
navigation_preferences_from_settings,
resolve_navigation_preferences,
update_navigation_preferences,
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
class NavigationPreferenceTests(unittest.TestCase):
def test_separator_order_and_labels_round_trip_across_scopes(self) -> None:
separator = NavigationSeparator("separator:work", "Arbeit")
preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,))
stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences))
self.assertEqual(preferences, stored)
resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored)
self.assertIsNone(resolved["dashboard"].section)
self.assertEqual(separator, resolved["files"].section)
self.assertEqual(separator, resolved["mail"].section)
self.assertTrue(resolved["dashboard"].custom_layout)
self.assertEqual("system", resolved["files"].layout_source)
self.assertNotIn(separator.id, resolved) # Never an authorized destination.
def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None:
system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",))
user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),))
resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user)
self.assertEqual("user", resolved["files"].layout_source)
self.assertEqual("Persönlich", resolved["files"].section.label)
self.assertTrue(resolved["files"].visible)
self.assertIsNone(resolved["mail"].section)
flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=()))
self.assertTrue(flat["files"].custom_layout)
self.assertIsNone(flat["files"].section)
self.assertTrue(flat["files"].locked)
def test_legacy_preferences_inherit_separator_layout(self) -> None:
separator = NavigationSeparator("separator:work", "Work")
resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",)))
self.assertEqual(separator, resolved["files"].section)
self.assertEqual("system", resolved["files"].layout_source)
self.assertFalse(resolved["mail"].visible)
def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None:
from pydantic import ValidationError
from govoplan_core.api.v1.schemas import NavigationPreferencesPayload
for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}):
with self.subTest(separator=separator), self.assertRaises(ValidationError):
NavigationPreferencesPayload.model_validate({"separators": [separator]})
def test_user_order_overrides_tenant_and_system_order(self) -> None:
resolved = resolve_navigation_preferences(
("dashboard", "files", "mail", "campaign"),
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from alembic import command
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session
from govoplan_core.core.ownership import OwnershipTransfer
from govoplan_core.db.migrations import alembic_config
class OwnershipHistoryMigrationTests(unittest.TestCase):
def test_upgrade_existing_databases_without_rewriting_ownership(self) -> None:
for track in ("release", "dev"):
for legacy in (True, False):
with self.subTest(track=track, legacy=legacy):
self._verify_upgrade(track, legacy=legacy)
def _verify_upgrade(self, track: str, *, legacy: bool) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-ownership-upgrade-") as directory:
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
config = alembic_config(database_url=url, enabled_modules=(), migration_track=track)
command.upgrade(config, "b47e6f809a13")
engine = create_engine(url)
try:
evidence = [{"sequence": 1, "action": "requested"}]
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OwnershipTransfer(
id="transfer-1", tenant_id="tenant-1", resource_module="campaigns",
resource_type="campaign", resource_id="campaign-1", kind="owner_initiated",
status="awaiting_target_acceptance", current_owner_type="user",
current_owner_id="owner-1", target_owner_type="user", target_owner_id="owner-2",
initiated_by_type="user", initiated_by_id="owner-1", reason="Existing request",
approvals=[{"actor_id": "owner-1"}], decisions=evidence,
idempotency_key="request-1", canonical_request_hash="a" * 64,
expires_at=now + timedelta(days=7), revision=3, metadata_={"retained": True},
created_at=now, updated_at=now,
))
session.commit()
with engine.begin() as connection:
if legacy:
connection.execute(text("ALTER TABLE core_ownership_transfers DROP COLUMN decisions"))
before = dict(connection.execute(text(
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
)).mappings().one())
command.upgrade(config, "c58a2d7e9f10")
command.upgrade(config, "c58a2d7e9f10")
with engine.connect() as connection:
columns = {column["name"]: column for column in inspect(connection).get_columns(
"core_ownership_transfers"
)}
self.assertFalse(columns["decisions"]["nullable"])
after = dict(connection.execute(text(
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
)).mappings().one())
self.assertEqual({key: after[key] for key in before}, before)
with Session(engine) as session:
transfer = session.scalars(select(OwnershipTransfer)).one()
self.assertEqual(transfer.decisions, [] if legacy else evidence)
self.assertEqual(transfer.approvals, [{"actor_id": "owner-1"}])
transfer.decisions = [*transfer.decisions, {"action": "accepted"}]
session.commit()
command.downgrade(config, "b47e6f809a13")
command.upgrade(config, "c58a2d7e9f10")
with Session(engine) as session:
self.assertEqual(session.get(OwnershipTransfer, "transfer-1").decisions[-1], {
"action": "accepted"
})
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()