feat: harden shared platform contracts

This commit is contained in:
2026-07-30 14:26:36 +02:00
parent 6970bf7457
commit 9b88ae388b
24 changed files with 2034 additions and 57 deletions

View File

@@ -0,0 +1,43 @@
# GovOPlaN Compatibility Inventory
This inventory classifies compatibility paths covered by
`COMPATIBILITY_POLICY.md`. It is intentionally limited to behavior that changes
accepted data, imports, permissions, or migration state. Operational fallbacks
such as Redis degradation and language fallback are not compatibility paths.
## Database Bridges
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.db.migrations.reconcile_legacy_create_all_schema` | Reconciles databases created before Alembic ownership was recorded. | At least one major release after runtime aliases are removed. | Review after `1.0`; keep release-baseline tests. |
| Migration table/column aliases in `govoplan_core.db.migrations` | Detect and reconcile pre-split table ownership and migration tracks. | All tagged `0.1.x` upgrade origins plus one major release cycle. | Remove only after the corresponding baseline leaves support. |
| Access and module migration backfills for legacy permission names | Converts persisted role assignments without dropping authority. | Same as the database upgrade origin that contains the old role. | Keep migrations immutable; remove only runtime expansion at `0.2`. |
## Portable-Schema Readers
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.mail.config.normalize_split_transport_credentials` | Reads pre-split SMTP/IMAP credentials and emits the split representation. | Current and previous two configuration schema versions. | Version-gate once Mail writes an explicit current schema version; reject inputs older than the two-version window. |
| `ImapServerConfig.discard_legacy_enabled` | Reads the former nested IMAP `enabled` field without writing it. | Current and previous two configuration schema versions. | Remove with the oldest accepted Mail configuration schema. |
| `govoplan_core.core.configuration_packages` readers | Reads explicitly versioned configuration-package manifests. | Current and previous two schema versions. | Retire individual readers as their version leaves the window. |
## Runtime And API Aliases
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.security.scope_aliases.LEGACY_SCOPE_ALIASES` | Expands pre-granular permission names. | Tagged `0.1.x` runtime/API window. | Remove at `0.2` after role backfills and migration notes are verified. |
| `govoplan_core.security.module_permissions.LEGACY_TO_MODULE_SCOPES` | Maps pre-module-split scopes to canonical owning-module scopes. | Tagged `0.1.x` runtime/API window. | Remove at `0.2`; keep database migration evidence for one major cycle. |
| `govoplan_core.privacy.retention` | Stable import facade delegating policy-owned behavior through a capability. | Tagged `0.1.x` import window. | Remove at `0.2` after all in-tree callers use the policy contract and release notes name the replacement. |
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
## Removed Paths
| Path | Reason | Removed |
| --- | --- | --- |
| `govoplan_core.core.module_installer._run_restart_command_legacy` | Private wrapper had no callers and never represented a persisted or published contract. | Current development line |
| Retired `govoplan_core.api.admin` and pre-split core model imports | In-tree callers and module packages use their owning modules; regression tests prohibit reintroduction. | Before `0.1.10` |
Every new compatibility path must be added here with its classification,
diagnostic, test owner, and planned removal release.

View File

@@ -277,6 +277,23 @@ unsafe methods.
This avoids retransmitting unchanged snapshots. It does not identify which row This avoids retransmitting unchanged snapshots. It does not identify which row
changed inside a collection. changed inside a collection.
### Mutation Preconditions
Weak response ETags are cache validators only. Mutable aggregates expose a
separate positive, monotonic revision and an opaque strong ETag generated by
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
strong ETag in `If-Match`; capability and worker calls carry the equivalent
typed `expected_revision`.
Core's compare-and-set primitive advances the revision in the same transaction
as the domain mutation. A missing HTTP precondition is `428 Precondition
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
bounded resource and revision metadata rather than the complete current
object. Modules may opt into the conservative three-way merge helper, but must
declare protected workflow, delivery, ownership, lock, evidence, signature,
and cryptographic paths that can never be merged automatically.
### Delta Collections ### Delta Collections
Collection endpoints that can expose row-level changes should use the shared Collection endpoints that can expose row-level changes should use the shared

View File

@@ -20,6 +20,7 @@ from govoplan_core.core.events import (
publish_platform_event, publish_platform_event,
) )
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
@@ -51,6 +52,8 @@ celery.conf.update(
"govoplan.campaigns.append_sent": {"queue": "append_sent"}, "govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"}, "govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"}, "govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
"govoplan.mail.purge_outbox": {"queue": "mail"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"}, "govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"}, "govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"}, "govoplan.dataflow.purge_runs": {"queue": "dataflow"},
@@ -69,6 +72,16 @@ celery.conf.update(
"schedule": 60.0, "schedule": 60.0,
"args": (None, 100), "args": (None, 100),
}, },
"mail-outbox-every-five-seconds": {
"task": "govoplan.mail.dispatch_outbox",
"schedule": 5.0,
"args": (None, 25),
},
"mail-outbox-retention-daily": {
"task": "govoplan.mail.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (250,),
},
"dataflow-triggers-every-minute": { "dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers", "task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0, "schedule": 60.0,
@@ -151,6 +164,16 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability return capability
def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX)
if not isinstance(capability, MailDeliveryOutboxProvider):
raise RuntimeError("Mail delivery outbox capability is invalid")
return capability
def _dataflow_trigger_dispatcher( def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None, registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None: ) -> DataflowTriggerDispatcher | None:
@@ -265,6 +288,51 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
return result return result
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
def dispatch_mail_outbox(
self,
tenant_id: str | None = None,
limit: int = 25,
):
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {
"selected": 0,
"accepted": 0,
"partially_refused": 0,
"retrying": 0,
"failed": 0,
"outcome_unknown": 0,
"command_ids": [],
}
return dict(
provider.dispatch_due(
session,
tenant_id=tenant_id,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
def purge_mail_outbox(self, limit: int = 250):
"""Minimize expired Mail payloads while retaining delivery evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {"purged": 0}
return dict(provider.purge_expired(session, limit=limit))
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0) @celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50): def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database.""" """Drain durable Calendar operations; retry timing lives in the database."""

View File

@@ -0,0 +1,559 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
from sqlalchemy.orm import Session
class ConcurrencyError(RuntimeError):
"""Base class for mutation precondition and compare-and-set failures."""
class MissingPreconditionError(ConcurrencyError):
def __init__(self, *, resource_type: str, resource_id: str) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__("A strong If-Match precondition is required for this mutation.")
def as_dict(self) -> dict[str, Any]:
return {
"code": "precondition_required",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"retryable": True,
}
class RevisionConflictError(ConcurrencyError):
def __init__(
self,
*,
resource_type: str,
resource_id: str,
current_revision: int,
submitted_base_revision: int,
refresh_path: str | None = None,
conflicts: Sequence["MergeConflict"] = (),
merge_candidate: Any = None,
current_etag: str | None = None,
) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
self.current_revision = int(current_revision)
self.submitted_base_revision = int(submitted_base_revision)
self.refresh_path = refresh_path
self.conflicts = tuple(conflicts)
self.merge_candidate = merge_candidate
self.current_etag = current_etag
super().__init__(
f"{resource_type} {resource_id} changed from revision "
f"{submitted_base_revision} to {current_revision}"
)
@property
def safe_merge_available(self) -> bool:
return self.merge_candidate is not None and not self.conflicts
def as_dict(
self,
*,
include_values: bool = False,
include_merge_candidate: bool = False,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": "revision_conflict",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"current_revision": self.current_revision,
"submitted_base_revision": self.submitted_base_revision,
"retryable": True,
"safe_merge_available": self.safe_merge_available,
"conflicts": [
conflict.as_dict(include_values=include_values)
for conflict in self.conflicts[:100]
],
}
if self.refresh_path:
result["refresh_path"] = self.refresh_path
if self.current_etag:
result["current_etag"] = self.current_etag
if include_merge_candidate and self.safe_merge_available:
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
return result
@dataclass(frozen=True, slots=True)
class MergeConflict:
path: str
kind: str
base_value: Any = None
local_value: Any = None
current_value: Any = None
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
"path": self.path or "/",
"kind": self.kind,
}
if include_values:
result.update(
{
"base_value": _bounded_json_value(self.base_value),
"local_value": _bounded_json_value(self.local_value),
"current_value": _bounded_json_value(self.current_value),
}
)
return result
@dataclass(slots=True)
class ThreeWayMergeResult:
value: Any
conflicts: list[MergeConflict] = field(default_factory=list)
applied_paths: list[str] = field(default_factory=list)
@property
def merged(self) -> bool:
return not self.conflicts
ProtectedPath = str | Callable[[str], bool]
def strong_resource_etag(
resource_type: str,
resource_id: str,
revision: int,
) -> str:
"""Return an opaque strong ETag for one mutable aggregate revision."""
normalized_revision = int(revision)
if normalized_revision < 1:
raise ValueError("Resource revisions must be positive integers")
digest = hashlib.sha256(
"\x00".join(
(
"govoplan-strong-revision-v1",
str(resource_type),
str(resource_id),
str(normalized_revision),
)
).encode("utf-8")
).hexdigest()
return f'"sha256-{digest}"'
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
"""Apply strong comparison semantics to an If-Match header."""
if not header_value:
return False
for raw_candidate in header_value.split(","):
candidate = raw_candidate.strip()
if candidate == "*":
return True
if candidate.startswith("W/"):
continue
if candidate == expected_etag:
return True
return False
def assert_revision_precondition(
if_match: str | None,
*,
resource_type: str,
resource_id: str,
submitted_base_revision: int,
) -> None:
if not if_match:
raise MissingPreconditionError(
resource_type=resource_type,
resource_id=resource_id,
)
submitted_etag = strong_resource_etag(
resource_type,
resource_id,
submitted_base_revision,
)
if not if_match_matches(if_match, submitted_etag):
raise ConcurrencyError(
"If-Match does not identify the submitted base revision."
)
def claim_revision(
session: Session,
*,
model: type[Any],
filters: Iterable[Any],
revision_attribute: str,
expected_revision: int,
resource_type: str,
resource_id: str,
refresh_path: str | None = None,
) -> int:
"""Atomically claim the next revision in the caller's transaction."""
revision_column = getattr(model, revision_attribute)
expected = int(expected_revision)
normalized_filters = tuple(filters)
query = session.query(model).filter(*normalized_filters)
updated = query.filter(revision_column == expected).update(
{revision_column: revision_column + 1},
synchronize_session=False,
)
if updated == 1:
session.flush()
return expected + 1
current = (
session.query(revision_column)
.filter(*normalized_filters)
.scalar()
)
if current is None:
raise LookupError(f"{resource_type} {resource_id} was not found")
raise RevisionConflictError(
resource_type=resource_type,
resource_id=resource_id,
current_revision=int(current),
submitted_base_revision=expected,
refresh_path=refresh_path,
current_etag=strong_resource_etag(
resource_type,
resource_id,
int(current),
),
)
def three_way_merge(
base: Any,
local: Any,
current: Any,
*,
protected_paths: Sequence[ProtectedPath] = (),
stable_id_fields: Sequence[str] = ("id",),
) -> ThreeWayMergeResult:
"""Conservatively merge local changes onto a concurrently changed value."""
return _merge_value(
copy.deepcopy(base),
copy.deepcopy(local),
copy.deepcopy(current),
path="",
protected_paths=tuple(protected_paths),
stable_id_fields=tuple(stable_id_fields),
)
def _merge_value(
base: Any,
local: Any,
current: Any,
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
local_changed = local != base
current_changed = current != base
if not local_changed:
return ThreeWayMergeResult(value=current)
if local == current:
return ThreeWayMergeResult(value=current)
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, local, current)
if not current_changed:
return ThreeWayMergeResult(
value=local,
applied_paths=[path or "/"],
)
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
return _merge_mapping(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
return _merge_list(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
return _conflict(path, "same_path_changed", base, local, current)
def _merge_mapping(
base: Mapping[str, Any],
local: Mapping[str, Any],
current: Mapping[str, Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
missing = object()
result: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
for key in keys:
child_path = _join_path(path, str(key))
base_value = base.get(key, missing)
local_value = local.get(key, missing)
current_value = current.get(key, missing)
merged = _merge_presence(
base_value,
local_value,
current_value,
missing=missing,
path=child_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result[str(key)] = merged.value
return ThreeWayMergeResult(
value=result,
conflicts=conflicts,
applied_paths=applied_paths,
)
def _merge_presence(
base: Any,
local: Any,
current: Any,
*,
missing: object,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
if local is missing and current is missing:
return ThreeWayMergeResult(value=missing)
if base is missing:
if local is missing:
return ThreeWayMergeResult(value=current)
if current is missing:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", None, local, None)
return ThreeWayMergeResult(value=local, applied_paths=[path])
if local == current:
return ThreeWayMergeResult(value=current)
return _conflict(path, "concurrent_add", None, local, current)
if local is missing:
if current == base:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, None, current)
return ThreeWayMergeResult(value=missing, applied_paths=[path])
return _conflict(path, "delete_vs_edit", base, None, current)
if current is missing:
if local == base:
return ThreeWayMergeResult(value=missing)
return _conflict(path, "edit_vs_delete", base, local, None)
return _merge_value(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
def _merge_list(
base: list[Any],
local: list[Any],
current: list[Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
identity_field = _stable_identity_field(
(base, local, current),
stable_id_fields,
)
if identity_field is None:
return _conflict(path, "unkeyed_collection", base, local, current)
base_by_id = {str(item[identity_field]): item for item in base}
local_by_id = {str(item[identity_field]): item for item in local}
current_by_id = {str(item[identity_field]): item for item in current}
base_order = list(base_by_id)
local_order = list(local_by_id)
current_order = list(current_by_id)
local_reordered = _common_order(local_order, base_order) != _common_order(
base_order,
local_order,
)
current_reordered = _common_order(current_order, base_order) != _common_order(
base_order,
current_order,
)
if local_reordered and current_reordered and local_order != current_order:
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
result_by_id: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
missing = object()
for identity in identities:
item_path = _join_path(path, f"{identity_field}={identity}")
merged = _merge_presence(
base_by_id.get(identity, missing),
local_by_id.get(identity, missing),
current_by_id.get(identity, missing),
missing=missing,
path=item_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result_by_id[identity] = merged.value
order_source = local_order if local_reordered and not current_reordered else current_order
merged_order = [identity for identity in order_source if identity in result_by_id]
for identity in identities:
if identity in result_by_id and identity not in merged_order:
merged_order.append(identity)
return ThreeWayMergeResult(
value=[result_by_id[identity] for identity in merged_order],
conflicts=conflicts,
applied_paths=applied_paths,
)
def _stable_identity_field(
values: Sequence[list[Any]],
candidates: Sequence[str],
) -> str | None:
all_items = [item for value in values for item in value]
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
return None
for candidate in candidates:
valid = True
for value in values:
identities = [
str(item.get(candidate, "")).strip()
for item in value
if isinstance(item, Mapping)
]
if any(not identity for identity in identities) or len(identities) != len(
set(identities)
):
valid = False
break
if valid:
return candidate
return None
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
right_set = set(right)
return [item for item in left if item in right_set]
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
normalized = path or "/"
for protected in protected_paths:
if callable(protected):
if protected(normalized):
return True
continue
prefix = protected.rstrip("/") or "/"
if normalized == prefix or normalized.startswith(f"{prefix}/"):
return True
return False
def _join_path(parent: str, segment: str) -> str:
escaped = segment.replace("~", "~0").replace("/", "~1")
return f"{parent}/{escaped}" if parent else f"/{escaped}"
def _conflict(
path: str,
kind: str,
base: Any,
local: Any,
current: Any,
) -> ThreeWayMergeResult:
return ThreeWayMergeResult(
value=current,
conflicts=[
MergeConflict(
path=path or "/",
kind=kind,
base_value=base,
local_value=local,
current_value=current,
)
],
)
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
if depth >= 4:
return {"summary": type(value).__name__}
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return value[:500]
if isinstance(value, Mapping):
result = {
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
for key, item in list(value.items())[:20]
}
if len(value) > 20:
result["_truncated_items"] = len(value) - 20
return result
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
result = [
_bounded_json_value(item, depth=depth + 1)
for item in list(value)[:20]
]
if len(value) > 20:
result.append({"_truncated_items": len(value) - 20})
return result
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return {"summary": type(value).__name__}
__all__ = [
"ConcurrencyError",
"MergeConflict",
"MissingPreconditionError",
"RevisionConflictError",
"ThreeWayMergeResult",
"assert_revision_precondition",
"claim_revision",
"if_match_matches",
"strong_resource_etag",
"three_way_merge",
]

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 25,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
limit: int = 250,
) -> Mapping[str, object]:
...

View File

@@ -3328,12 +3328,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
return payload return payload
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
if not command:
return None
return _run_restart_command(command)
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]: def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
raw_plan = record.get("plan") raw_plan = record.get("plan")
if not isinstance(raw_plan, list): if not isinstance(raw_plan, list):

View File

@@ -215,6 +215,17 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"] DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"] DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"] DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationSourceKind = Literal[
"manifest",
"route",
"capability",
"policy",
"configuration_package",
"wiki",
"repository",
]
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -296,6 +307,40 @@ class DocumentationContext:
data: Mapping[str, Any] = field(default_factory=dict) data: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationDecision:
key: str
state: DocumentationConfigurationState
source: str | None = None
reason: str | None = None
DocumentationConfigurationResolver = Callable[
[DocumentationContext, tuple[str, ...]],
Mapping[str, DocumentationConfigurationDecision],
]
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationProviderRegistration:
keys: tuple[str, ...]
resolve: DocumentationConfigurationResolver
@dataclass(frozen=True, slots=True)
class DocumentationSourceDefinition:
id: str
kind: DocumentationSourceKind
label: str
documentation_types: tuple[DocumentationType, ...] = ("admin",)
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
state: DocumentationSourceState = "configured"
provenance: Mapping[str, Any] = field(default_factory=dict)
inspection: Mapping[str, Any] = field(default_factory=dict)
link: DocumentationLink | None = None
configuration_key: str | None = None
class ResourceAclProvider(Protocol): class ResourceAclProvider(Protocol):
resource_type: str resource_type: str
@@ -372,3 +417,8 @@ class ModuleManifest:
on_deactivate: LifecycleHook | None = None on_deactivate: LifecycleHook | None = None
documentation: tuple[DocumentationTopic, ...] = () documentation: tuple[DocumentationTopic, ...] = ()
documentation_providers: tuple[DocumentationProvider, ...] = () documentation_providers: tuple[DocumentationProvider, ...] = ()
documentation_configuration_providers: tuple[
DocumentationConfigurationProviderRegistration,
...,
] = ()
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()

View File

@@ -16,6 +16,7 @@ CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"] PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"] PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -140,6 +141,7 @@ class PostboxMessageRef:
subject: str subject: str
body_text: str | None body_text: str | None
status: str status: str
availability: PostboxMessageAvailability
classification: str classification: str
sender_label: str | None sender_label: str | None
delivered_at: datetime delivered_at: datetime
@@ -159,6 +161,29 @@ class PostboxMessageRef:
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryReceiptSummaryRef:
delivery_id: str
message_id: str
postbox_id: str
delivery_status: str
accepted_at: datetime
current_holder_count: int = 0
currently_readable: bool = False
message_count: int = 1
routed_message_count: int = 0
readable_message_count: int = 0
read_receipt_count: int = 0
acknowledged_receipt_count: int = 0
withdrawn_message_count: int = 0
expired_message_count: int = 0
first_read_at: datetime | None = None
last_read_at: datetime | None = None
first_acknowledged_at: datetime | None = None
last_acknowledged_at: datetime | None = None
route_status_counts: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest: class PostboxDeliveryRequest:
tenant_id: str tenant_id: str
@@ -317,6 +342,16 @@ class PostboxEvidenceProvider(Protocol):
) -> PostboxMessageRef: ) -> PostboxMessageRef:
... ...
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
producer_module: str,
delivery_ids: Sequence[str],
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
...
@runtime_checkable @runtime_checkable
class PostboxRoutingProvider(Protocol): class PostboxRoutingProvider(Protocol):

View File

@@ -564,6 +564,43 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
raise RegistryError( raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}" f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
) )
_validate_documentation_extensions(manifest)
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider must declare keys"
)
for raw_key in registration.keys:
key = raw_key.strip()
if not key:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider declares an empty key"
)
if key in provider_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation configuration key {key!r}"
)
provider_keys.add(key)
source_ids: set[str] = set()
for source in manifest.documentation_sources:
if not _INTERFACE_NAME_RE.match(source.id):
raise RegistryError(
f"Module {manifest.id!r} documentation source id must be namespaced: {source.id!r}"
)
if source.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation source {source.id!r}"
)
source_ids.add(source.id)
if not source.label.strip():
raise RegistryError(
f"Module {manifest.id!r} documentation source {source.id!r} must have a label"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None: def _validate_manifest_identity(manifest: ModuleManifest) -> None:

View File

@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> o
return result return result
if isinstance(value, list): if isinstance(value, list):
return [redact_secret_values(item, placeholder=placeholder) for item in value] return [redact_secret_values(item, placeholder=placeholder) for item in value]
if isinstance(value, tuple):
return tuple(redact_secret_values(item, placeholder=placeholder) for item in value)
return value return value
def contains_plain_secret(value: object) -> bool: def contains_plain_secret(value: object) -> bool:
if not isinstance(value, Mapping): if isinstance(value, Mapping):
return False for key, item in value.items():
for key, item in value.items(): normalized_key = str(key).strip().casefold().replace("-", "_")
normalized_key = str(key).strip().casefold().replace("-", "_") if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}: continue
continue if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels. return True
return True if contains_plain_secret(item):
if isinstance(item, Mapping) and contains_plain_secret(item): return True
return True elif isinstance(value, (list, tuple)):
return any(contains_plain_secret(item) for item in value)
return False return False

View File

@@ -132,6 +132,24 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(response.status_code, 201, response.text) self.assertEqual(response.status_code, 201, response.text)
return str(response.json()["id"]) return str(response.json()["id"])
def _campaign_version_precondition(
self,
headers: dict[str, str],
campaign_id: str,
version_id: str,
) -> tuple[dict[str, str], int]:
response = self.client.get(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
)
self.assertEqual(response.status_code, 200, response.text)
etag = response.headers.get("etag")
self.assertIsNotNone(etag)
return (
{**headers, "If-Match": str(etag)},
int(response.json()["edit_revision"]),
)
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None: def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login() headers, _ = self._login()
payload = { payload = {
@@ -950,6 +968,33 @@ class ApiSmokeTests(unittest.TestCase):
user_always = {item["id"]: item for item in user_docs_payload["layers"]["always"]["documentation"]} user_always = {item["id"]: item for item in user_docs_payload["layers"]["always"]["documentation"]}
self.assertEqual(user_always["docs.configured-system-documentation"]["translation_locale"], "de") self.assertEqual(user_always["docs.configured-system-documentation"]["translation_locale"], "de")
sources = self.client.get("/api/v1/docs/sources", headers=headers)
self.assertEqual(sources.status_code, 200, sources.text)
source_payload = sources.json()
self.assertEqual(source_payload["total"], len(source_payload["items"]))
manifest_source = next(
item
for item in source_payload["items"]
if item["id"] == "docs.manifest"
)
self.assertEqual(manifest_source["kind"], "manifest")
self.assertNotIn("inspection", manifest_source)
inspected_source = self.client.get(
manifest_source["inspection_url"],
headers=headers,
)
self.assertEqual(inspected_source.status_code, 200, inspected_source.text)
self.assertEqual(
inspected_source.json()["inspection"]["module_id"],
"docs",
)
unknown_source = self.client.get(
"/api/v1/docs/sources/docs.unknown",
headers=headers,
)
self.assertEqual(unknown_source.status_code, 404, unknown_source.text)
platform_status = self.client.get("/api/v1/platform/status", headers=headers) platform_status = self.client.get("/api/v1/platform/status", headers=headers)
self.assertEqual(platform_status.status_code, 200, platform_status.text) self.assertEqual(platform_status.status_code, 200, platform_status.text)
i18n_status = platform_status.json()["i18n"] i18n_status = platform_status.json()["i18n"]
@@ -2747,7 +2792,10 @@ class ApiSmokeTests(unittest.TestCase):
archive.writestr("reports/february.txt", "February report") archive.writestr("reports/february.txt", "February report")
payload.seek(0) payload.seek(0)
with patch("govoplan_files.backend.router._read_limited_upload", side_effect=AssertionError("ZIP upload should not be fully buffered")): with patch(
"govoplan_files.backend.routes.uploads._read_limited_upload",
side_effect=AssertionError("ZIP upload should not be fully buffered"),
):
response = self.client.post( response = self.client.post(
"/api/v1/files/upload-zip", "/api/v1/files/upload-zip",
headers=headers, headers=headers,
@@ -2890,10 +2938,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertTrue(full_payload["full"]) self.assertTrue(full_payload["full"])
self.assertEqual(full_payload["current_version"]["id"], version_id) self.assertEqual(full_payload["current_version"]["id"], version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
autosaved = self.client.post( autosaved = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave",
headers=headers, headers=mutation_headers,
json={"current_step": "fields"}, json={
"current_step": "fields",
"base_revision": base_revision,
},
) )
self.assertEqual(autosaved.status_code, 200, autosaved.text) self.assertEqual(autosaved.status_code, 200, autosaved.text)
self.assertEqual(autosaved.json()["current_step"], "fields") self.assertEqual(autosaved.json()["current_step"], "fields")
@@ -3038,10 +3094,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(temporary.json()["user_lock_state"], "temporary") self.assertEqual(temporary.json()["user_lock_state"], "temporary")
self.assertTrue(temporary.json()["user_locked_at"]) self.assertTrue(temporary.json()["user_locked_at"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
blocked_update = self.client.put( blocked_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers=mutation_headers,
json={"current_step": "fields"}, json={
"current_step": "fields",
"base_revision": base_revision,
},
) )
self.assertEqual(blocked_update.status_code, 409, blocked_update.text) self.assertEqual(blocked_update.status_code, 409, blocked_update.text)
@@ -3052,10 +3116,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(unlocked.status_code, 200, unlocked.text) self.assertEqual(unlocked.status_code, 200, unlocked.text)
self.assertIsNone(unlocked.json()["user_lock_state"]) self.assertIsNone(unlocked.json()["user_lock_state"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
updated = self.client.put( updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers=mutation_headers,
json={"current_step": "fields"}, json={
"current_step": "fields",
"base_revision": base_revision,
},
) )
self.assertEqual(updated.status_code, 200, updated.text) self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["current_step"], "fields") self.assertEqual(updated.json()["current_step"], "fields")
@@ -3116,18 +3188,34 @@ class ApiSmokeTests(unittest.TestCase):
self.assertNotEqual(second_version_id, first_version_id) self.assertNotEqual(second_version_id, first_version_id)
self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id) self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
first_version_id,
)
historical_update = self.client.put( historical_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}",
headers=headers, headers=mutation_headers,
json={"current_step": "fields"}, json={
"current_step": "fields",
"base_revision": base_revision,
},
) )
self.assertEqual(historical_update.status_code, 409, historical_update.text) self.assertEqual(historical_update.status_code, 409, historical_update.text)
self.assertIn("Historical campaign versions are read-only", historical_update.text) self.assertIn("Historical campaign versions are read-only", historical_update.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
second_version_id,
)
second_update = self.client.put( second_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers, headers=mutation_headers,
json={"current_step": "fields"}, json={
"current_step": "fields",
"base_revision": base_revision,
},
) )
self.assertEqual(second_update.status_code, 200, second_update.text) self.assertEqual(second_update.status_code, 200, second_update.text)
@@ -4613,8 +4701,14 @@ class ApiSmokeTests(unittest.TestCase):
) )
updated = self.client.put( updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers, headers={
json={"campaign_json": campaign_json}, **headers,
"If-Match": str(version_response.headers["etag"]),
},
json={
"campaign_json": campaign_json,
"base_revision": version_response.json()["edit_revision"],
},
) )
self.assertEqual(updated.status_code, 200, updated.text) self.assertEqual(updated.status_code, 200, updated.text)
validated = self.client.post( validated = self.client.post(
@@ -5883,10 +5977,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(allowed_write.status_code, 200, allowed_write.text) self.assertEqual(allowed_write.status_code, 200, allowed_write.text)
changed_config = version_detail.json()["raw_json"] changed_config = version_detail.json()["raw_json"]
changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org" changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org"
mutation_headers, base_revision = self._campaign_version_precondition(
reader_headers,
campaign_id,
version_id,
)
denied_recipient_edit = self.client.put( denied_recipient_edit = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=reader_headers, headers=mutation_headers,
json={"campaign_json": changed_config}, json={
"campaign_json": changed_config,
"base_revision": base_revision,
},
) )
self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text) self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text)
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403) self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403)
@@ -6030,18 +6132,29 @@ class ApiSmokeTests(unittest.TestCase):
} }
blocked_inline = self.client.put( blocked_inline = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers={**headers, "If-Match": str(detail.headers["etag"])},
json={"campaign_json": inline_json}, json={
"campaign_json": inline_json,
"base_revision": detail.json()["edit_revision"],
},
) )
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text) self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
self.assertIn("may only reference", blocked_inline.json()["detail"]) self.assertIn("may only reference", blocked_inline.json()["detail"])
reusable_json = detail.json()["raw_json"] reusable_json = detail.json()["raw_json"]
reusable_json["server"] = {"mail_profile_id": tenant_profile_id} reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_reusable = self.client.put( allowed_reusable = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers=mutation_headers,
json={"campaign_json": reusable_json}, json={
"campaign_json": reusable_json,
"base_revision": base_revision,
},
) )
self.assertEqual(allowed_reusable.status_code, 200, allowed_reusable.text) self.assertEqual(allowed_reusable.status_code, 200, allowed_reusable.text)
@@ -6080,8 +6193,11 @@ class ApiSmokeTests(unittest.TestCase):
raw_json["server"] = {"mail_profile_id": profile_id} raw_json["server"] = {"mail_profile_id": profile_id}
denied_update = self.client.put( denied_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers={**headers, "If-Match": str(detail.headers["etag"])},
json={"campaign_json": raw_json}, json={
"campaign_json": raw_json,
"base_revision": detail.json()["edit_revision"],
},
) )
self.assertEqual(denied_update.status_code, 422, denied_update.text) self.assertEqual(denied_update.status_code, 422, denied_update.text)
@@ -6091,10 +6207,18 @@ class ApiSmokeTests(unittest.TestCase):
json={"policy": {"allowed_profile_ids": [profile_id]}}, json={"policy": {"allowed_profile_ids": [profile_id]}},
) )
self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text) self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_update = self.client.put( allowed_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers, headers=mutation_headers,
json={"campaign_json": raw_json}, json={
"campaign_json": raw_json,
"base_revision": base_revision,
},
) )
self.assertEqual(allowed_update.status_code, 200, allowed_update.text) self.assertEqual(allowed_update.status_code, 200, allowed_update.text)

View File

@@ -250,6 +250,38 @@ class CoreEventTests(unittest.TestCase):
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"]) self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"]) self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_slow_request_log_excludes_query_headers_and_cookies(self) -> None:
router = APIRouter()
@router.get("/slow")
def slow() -> dict[str, bool]:
return {"ok": True}
with patch("govoplan_core.server.fastapi._slow_request_threshold_ms", return_value=0.0001):
app = create_govoplan_app(
title="request log redaction test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with self.assertLogs("govoplan.request", level="WARNING") as captured:
with TestClient(app) as client:
response = client.get(
"/slow?token=query-secret",
headers={
"Authorization": "Bearer header-secret",
"Cookie": "govoplan_session=cookie-secret",
},
)
self.assertEqual(200, response.status_code, response.text)
rendered = "\n".join(captured.output)
self.assertIn("path=/slow", rendered)
self.assertNotIn("query-secret", rendered)
self.assertNotIn("header-secret", rendered)
self.assertNotIn("cookie-secret", rendered)
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None: def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex( with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError, RuntimeError,

View File

@@ -3,7 +3,10 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration,
DocumentationCondition, DocumentationCondition,
DocumentationSourceDefinition,
DocumentationTopic, DocumentationTopic,
ModuleManifest, ModuleManifest,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
@@ -67,6 +70,52 @@ class DocumentationTopicContractTests(unittest.TestCase):
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ()) self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
registry_for(scoped, admin_workflow, user_reference).validate() registry_for(scoped, admin_workflow, user_reference).validate()
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
resolver = lambda _context, keys: { # noqa: E731
key: DocumentationConfigurationDecision(key=key, state="enabled")
for key in keys
}
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
documentation_sources=(
DocumentationSourceDefinition(
id="example.handbook",
kind="repository",
label="Example handbook",
),
),
))
registry.validate()
duplicate = PlatformRegistry()
duplicate.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
))
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
duplicate.validate()
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry: def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
registry = PlatformRegistry() registry = PlatformRegistry()

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.celery_app import (
celery,
dispatch_mail_outbox,
purge_mail_outbox,
)
class _Provider:
def dispatch_due(self, session, **kwargs):
return {"session": session, **kwargs}
def purge_expired(self, session, **kwargs):
return {"session": session, **kwargs}
class MailDeliveryWorkerTests(unittest.TestCase):
def test_dispatch_uses_optional_provider_boundary(self) -> None:
session = object()
@contextmanager
def session_scope():
yield session
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.db.session.get_database", return_value=database),
patch(
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
):
result = dispatch_mail_outbox.run("tenant-1", 7)
self.assertIs(result["session"], session)
self.assertEqual(result["tenant_id"], "tenant-1")
self.assertEqual(result["limit"], 7)
def test_purge_uses_optional_provider_boundary(self) -> None:
session = object()
@contextmanager
def session_scope():
yield session
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.db.session.get_database", return_value=database),
patch(
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
):
result = purge_mail_outbox.run(19)
self.assertIs(result["session"], session)
self.assertEqual(result["limit"], 19)
def test_worker_routes_and_schedules_are_declared(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.mail.dispatch_outbox"],
{"queue": "mail"},
)
self.assertEqual(
celery.conf.task_routes["govoplan.mail.purge_outbox"],
{"queue": "mail"},
)
self.assertEqual(
celery.conf.beat_schedule["mail-outbox-every-five-seconds"]["task"],
"govoplan.mail.dispatch_outbox",
)
self.assertEqual(
celery.conf.beat_schedule["mail-outbox-retention-daily"]["task"],
"govoplan.mail.purge_outbox",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import unittest
from sqlalchemy import Integer, String, create_engine
from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.core.concurrency import (
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
claim_revision,
if_match_matches,
strong_resource_etag,
three_way_merge,
)
from govoplan_core.db.base import Base
class _RevisionFixture(Base):
__tablename__ = "test_revision_fixture"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
edit_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
value: Mapped[str] = mapped_column(String(100), nullable=False)
class OptimisticConcurrencyTests(unittest.TestCase):
def test_strong_etags_and_if_match_use_strong_comparison(self) -> None:
etag = strong_resource_etag("campaign_version", "version-1", 3)
self.assertTrue(if_match_matches(etag, etag))
self.assertTrue(if_match_matches(f'"other", {etag}', etag))
self.assertFalse(if_match_matches(f"W/{etag}", etag))
self.assertNotEqual(
etag,
strong_resource_etag("campaign_version", "version-1", 4),
)
with self.assertRaises(MissingPreconditionError):
assert_revision_precondition(
None,
resource_type="campaign_version",
resource_id="version-1",
submitted_base_revision=3,
)
def test_compare_and_set_allows_only_one_writer_for_a_revision(self) -> None:
engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
_RevisionFixture.__table__.create(engine)
session_factory = sessionmaker(bind=engine, class_=Session)
with session_factory() as session:
session.add(_RevisionFixture(id="resource-1", value="base"))
session.commit()
next_revision = claim_revision(
session,
model=_RevisionFixture,
filters=(_RevisionFixture.id == "resource-1",),
revision_attribute="edit_revision",
expected_revision=1,
resource_type="fixture",
resource_id="resource-1",
)
session.commit()
self.assertEqual(next_revision, 2)
with self.assertRaises(RevisionConflictError) as raised:
claim_revision(
session,
model=_RevisionFixture,
filters=(_RevisionFixture.id == "resource-1",),
revision_attribute="edit_revision",
expected_revision=1,
resource_type="fixture",
resource_id="resource-1",
)
self.assertEqual(raised.exception.current_revision, 2)
engine.dispose()
def test_disjoint_map_and_stable_id_collection_changes_merge(self) -> None:
base = {
"campaign": {"name": "Original", "description": "Base"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": True},
{"id": "two", "name": "Two", "active": True},
]
},
}
local = {
**base,
"campaign": {"name": "Local", "description": "Base"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": False},
{"id": "two", "name": "Two", "active": True},
]
},
}
current = {
**base,
"campaign": {"name": "Original", "description": "Remote"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": True},
{"id": "two", "name": "Remote Two", "active": True},
]
},
}
result = three_way_merge(base, local, current)
self.assertTrue(result.merged)
self.assertEqual(result.value["campaign"], {"name": "Local", "description": "Remote"})
self.assertEqual(result.value["entries"]["inline"][0]["active"], False)
self.assertEqual(result.value["entries"]["inline"][1]["name"], "Remote Two")
def test_same_path_unkeyed_collection_and_protected_edits_conflict(self) -> None:
same_path = three_way_merge(
{"name": "Base"},
{"name": "Local"},
{"name": "Remote"},
)
self.assertEqual(same_path.conflicts[0].kind, "same_path_changed")
unkeyed = three_way_merge(
{"values": ["a"]},
{"values": ["a", "local"]},
{"values": ["a", "remote"]},
)
self.assertEqual(unkeyed.conflicts[0].kind, "unkeyed_collection")
protected = three_way_merge(
{"workflow_state": "draft", "name": "Base"},
{"workflow_state": "ready", "name": "Base"},
{"workflow_state": "draft", "name": "Remote"},
protected_paths=("/workflow_state",),
)
self.assertEqual(protected.conflicts[0].kind, "protected_path")
def test_delete_versus_edit_and_conflicting_reorder_remain_explicit(self) -> None:
base = [{"id": "one", "value": "1"}, {"id": "two", "value": "2"}]
delete_vs_edit = three_way_merge(
base,
[{"id": "two", "value": "2"}],
[{"id": "one", "value": "remote"}, {"id": "two", "value": "2"}],
)
self.assertEqual(delete_vs_edit.conflicts[0].kind, "delete_vs_edit")
reordered = three_way_merge(
[
{"id": "one"},
{"id": "two"},
{"id": "three"},
],
[
{"id": "two"},
{"id": "one"},
{"id": "three"},
],
[
{"id": "one"},
{"id": "three"},
{"id": "two"},
],
)
self.assertEqual(reordered.conflicts[0].kind, "collection_reorder")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
@@ -11,6 +12,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_MESSAGES,
PostboxAccessProvider, PostboxAccessProvider,
PostboxDeliveryProvider, PostboxDeliveryProvider,
PostboxDeliveryReceiptSummaryRef,
PostboxDirectoryProvider, PostboxDirectoryProvider,
PostboxEvidenceProvider, PostboxEvidenceProvider,
PostboxMessagesProvider, PostboxMessagesProvider,
@@ -69,6 +71,17 @@ class _PostboxProvider:
del session, tenant_id, message_id, attachment del session, tenant_id, message_id, attachment
return None return None
def delivery_receipt_summaries(
self,
session,
*,
tenant_id,
producer_module,
delivery_ids,
):
del session, tenant_id, producer_module, delivery_ids
return {}
class PostboxContractTests(unittest.TestCase): class PostboxContractTests(unittest.TestCase):
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None: def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
@@ -78,6 +91,16 @@ class PostboxContractTests(unittest.TestCase):
self.assertIsInstance(provider, PostboxMessagesProvider) self.assertIsInstance(provider, PostboxMessagesProvider)
self.assertIsInstance(provider, PostboxDeliveryProvider) self.assertIsInstance(provider, PostboxDeliveryProvider)
self.assertIsInstance(provider, PostboxEvidenceProvider) self.assertIsInstance(provider, PostboxEvidenceProvider)
self.assertEqual(
"delivery-1",
PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=datetime.now(timezone.utc),
).delivery_id,
)
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register( registry.register(

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import unittest
from govoplan_core.security.redaction import contains_plain_secret, redact_secret_values
class SecurityRedactionTests(unittest.TestCase):
def test_structured_payload_redaction_covers_nested_container_shapes(self) -> None:
payload = {
"request": {
"Authorization": "Bearer must-not-leak",
"headers": [{"x-api-key": "must-not-leak"}],
},
"records": (
{"clientSecret": "must-not-leak", "status": "ok"},
{"credential_ref": "credential-1"},
),
}
redacted = redact_secret_values(payload)
self.assertEqual("<redacted>", redacted["request"]["Authorization"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["request"]["headers"][0]["x-api-key"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["records"][0]["clientSecret"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["records"][1]["credential_ref"]) # type: ignore[index]
self.assertNotIn("must-not-leak", repr(redacted))
def test_plain_secret_detection_descends_into_lists_and_tuples(self) -> None:
self.assertTrue(contains_plain_secret([{"metadata": ({"private-key": "secret"},)}]))
self.assertFalse(contains_plain_secret([{"credential_ref": "credential-1"}]))
if __name__ == "__main__":
unittest.main()

View File

@@ -10,6 +10,8 @@ const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary"); assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback"); assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack"); assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
assert(source.includes("createPortal(dialog, document.body)"), "Dialog supports opt-in body portals without replacing the shared stack");
assert(source.includes("panelStyle"), "Dialog accepts bounded custom panel positioning");
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners"); assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener"); assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog"); assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");

View File

@@ -0,0 +1,376 @@
import { isApiError } from "./client";
export type RevisionConflictDetail = {
code: "revision_conflict";
resource: {
type: string;
id: string;
};
current_revision: number;
submitted_base_revision: number;
retryable: boolean;
refresh_path?: string;
current_etag?: string;
};
export type MergeConflict = {
path: string;
kind: string;
baseValue: unknown;
localValue: unknown;
currentValue: unknown;
};
export type ThreeWayMergeResult<T = unknown> = {
value: T;
conflicts: MergeConflict[];
appliedPaths: string[];
};
export type ConflictChoice = "current" | "local";
type MissingValue = { readonly missing: true };
const MISSING: MissingValue = Object.freeze({ missing: true });
export function revisionConflictFromError(error: unknown): RevisionConflictDetail | null {
if (!isApiError(error, 409, 412)) return null;
try {
const parsed = JSON.parse(error.body) as { detail?: unknown };
const detail = asRecord(parsed.detail);
const resource = asRecord(detail.resource);
if (
detail.code !== "revision_conflict"
|| typeof resource.type !== "string"
|| typeof resource.id !== "string"
|| !Number.isInteger(detail.current_revision)
|| !Number.isInteger(detail.submitted_base_revision)
) return null;
return {
code: "revision_conflict",
resource: {
type: resource.type,
id: resource.id
},
current_revision: Number(detail.current_revision),
submitted_base_revision: Number(detail.submitted_base_revision),
retryable: detail.retryable !== false,
refresh_path: typeof detail.refresh_path === "string" ? detail.refresh_path : undefined,
current_etag: typeof detail.current_etag === "string" ? detail.current_etag : undefined
};
} catch {
return null;
}
}
export function threeWayMerge<T>(
base: T,
local: T,
current: T,
options: {
protectedPaths?: readonly string[];
stableIdFields?: readonly string[];
} = {}
): ThreeWayMergeResult<T> {
return mergeValue(
cloneValue(base),
cloneValue(local),
cloneValue(current),
"",
options.protectedPaths ?? [],
options.stableIdFields ?? ["id"]
) as ThreeWayMergeResult<T>;
}
export function applyConflictChoices<T>(
merge: ThreeWayMergeResult<T>,
choices: Readonly<Record<string, ConflictChoice>>
): T {
let result: unknown = cloneValue(merge.value);
for (const conflict of merge.conflicts) {
if ((choices[conflict.path] ?? "current") !== "local") continue;
result = writePath(result, conflict.path, cloneValue(conflict.localValue));
}
return result as T;
}
function mergeValue(
base: unknown,
local: unknown,
current: unknown,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult {
const localChanged = !deepEqual(local, base);
const currentChanged = !deepEqual(current, base);
if (!localChanged) return result(current);
if (deepEqual(local, current)) return result(current);
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", base, local, current);
}
if (!currentChanged) return result(local, [], [path || "/"]);
if (isRecord(base) && isRecord(local) && isRecord(current)) {
return mergeRecord(base, local, current, path, protectedPaths, stableIdFields);
}
if (Array.isArray(base) && Array.isArray(local) && Array.isArray(current)) {
return mergeList(base, local, current, path, protectedPaths, stableIdFields);
}
return conflictResult(path, "same_path_changed", base, local, current);
}
function mergeRecord(
base: Record<string, unknown>,
local: Record<string, unknown>,
current: Record<string, unknown>,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult<Record<string, unknown>> {
const value: Record<string, unknown> = {};
const conflicts: MergeConflict[] = [];
const appliedPaths: string[] = [];
const keys = [...new Set([...Object.keys(current), ...Object.keys(local), ...Object.keys(base)])];
for (const key of keys) {
const childPath = joinPath(path, key);
const merged = mergePresence(
hasOwn(base, key) ? base[key] : MISSING,
hasOwn(local, key) ? local[key] : MISSING,
hasOwn(current, key) ? current[key] : MISSING,
childPath,
protectedPaths,
stableIdFields
);
conflicts.push(...merged.conflicts);
appliedPaths.push(...merged.appliedPaths);
if (!isMissing(merged.value)) value[key] = merged.value;
}
return result(value, conflicts, appliedPaths);
}
function mergePresence(
base: unknown | MissingValue,
local: unknown | MissingValue,
current: unknown | MissingValue,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult {
if (isMissing(local) && isMissing(current)) return result(MISSING);
if (isMissing(base)) {
if (isMissing(local)) return result(current);
if (isMissing(current)) {
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", undefined, local, undefined);
}
return result(local, [], [path]);
}
if (deepEqual(local, current)) return result(current);
return conflictResult(path, "concurrent_add", undefined, local, current);
}
if (isMissing(local)) {
if (deepEqual(current, base)) {
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", base, undefined, current);
}
return result(MISSING, [], [path]);
}
return conflictResult(path, "delete_vs_edit", base, undefined, current);
}
if (isMissing(current)) {
if (deepEqual(local, base)) return result(MISSING);
return conflictResult(path, "edit_vs_delete", base, local, undefined);
}
return mergeValue(base, local, current, path, protectedPaths, stableIdFields);
}
function mergeList(
base: unknown[],
local: unknown[],
current: unknown[],
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult<unknown[]> {
const identityField = stableIdentityField([base, local, current], stableIdFields);
if (!identityField) {
return conflictResult(path, "unkeyed_collection", base, local, current);
}
const byId = (items: unknown[]) => new Map(
items.map((item) => [String((item as Record<string, unknown>)[identityField]), item])
);
const baseById = byId(base);
const localById = byId(local);
const currentById = byId(current);
const baseOrder = [...baseById.keys()];
const localOrder = [...localById.keys()];
const currentOrder = [...currentById.keys()];
const localReordered = !deepEqual(commonOrder(localOrder, baseOrder), commonOrder(baseOrder, localOrder));
const currentReordered = !deepEqual(commonOrder(currentOrder, baseOrder), commonOrder(baseOrder, currentOrder));
if (localReordered && currentReordered && !deepEqual(localOrder, currentOrder)) {
return conflictResult(path, "collection_reorder", baseOrder, localOrder, currentOrder);
}
const mergedById = new Map<string, unknown>();
const conflicts: MergeConflict[] = [];
const appliedPaths: string[] = [];
const identities = [...new Set([...currentOrder, ...localOrder, ...baseOrder])];
for (const identity of identities) {
const merged = mergePresence(
baseById.has(identity) ? baseById.get(identity) : MISSING,
localById.has(identity) ? localById.get(identity) : MISSING,
currentById.has(identity) ? currentById.get(identity) : MISSING,
joinPath(path, `${identityField}=${identity}`),
protectedPaths,
stableIdFields
);
conflicts.push(...merged.conflicts);
appliedPaths.push(...merged.appliedPaths);
if (!isMissing(merged.value)) mergedById.set(identity, merged.value);
}
const orderSource = localReordered && !currentReordered ? localOrder : currentOrder;
const mergedOrder = orderSource.filter((identity) => mergedById.has(identity));
for (const identity of identities) {
if (mergedById.has(identity) && !mergedOrder.includes(identity)) mergedOrder.push(identity);
}
return result(
mergedOrder.map((identity) => mergedById.get(identity)),
conflicts,
appliedPaths
);
}
function writePath(root: unknown, path: string, value: unknown): unknown {
if (!path || path === "/") return value;
const segments = path.slice(1).split("/").map(decodePointerSegment);
const cloned = cloneValue(root);
let target: unknown = cloned;
for (let index = 0; index < segments.length - 1; index += 1) {
target = descend(target, segments[index]);
}
const finalSegment = segments[segments.length - 1];
if (Array.isArray(target)) {
const itemIndex = arraySegmentIndex(target, finalSegment);
if (itemIndex < 0) return cloned;
if (value === undefined) target.splice(itemIndex, 1);
else target[itemIndex] = value;
} else if (isRecord(target)) {
if (value === undefined) delete target[finalSegment];
else target[finalSegment] = value;
}
return cloned;
}
function descend(target: unknown, segment: string): unknown {
if (Array.isArray(target)) {
const index = arraySegmentIndex(target, segment);
return index >= 0 ? target[index] : undefined;
}
return isRecord(target) ? target[segment] : undefined;
}
function arraySegmentIndex(items: unknown[], segment: string): number {
const separator = segment.indexOf("=");
if (separator > 0) {
const key = segment.slice(0, separator);
const value = segment.slice(separator + 1);
return items.findIndex((item) => isRecord(item) && String(item[key]) === value);
}
const index = Number(segment);
return Number.isInteger(index) ? index : -1;
}
function stableIdentityField(values: unknown[][], candidates: readonly string[]): string | null {
const allItems = values.flat();
if (allItems.length === 0 || !allItems.every(isRecord)) return null;
for (const candidate of candidates) {
const valid = values.every((items) => {
const identities = items.map((item) => String((item as Record<string, unknown>)[candidate] ?? "").trim());
return identities.every(Boolean) && new Set(identities).size === identities.length;
});
if (valid) return candidate;
}
return null;
}
function commonOrder(left: string[], right: string[]): string[] {
const rightSet = new Set(right);
return left.filter((item) => rightSet.has(item));
}
function isProtected(path: string, protectedPaths: readonly string[]): boolean {
const normalized = path || "/";
return protectedPaths.some((rawPrefix) => {
const prefix = rawPrefix.replace(/\/+$/, "") || "/";
return normalized === prefix || normalized.startsWith(`${prefix}/`);
});
}
function conflictResult<T>(
path: string,
kind: string,
baseValue: unknown,
localValue: unknown,
currentValue: T
): ThreeWayMergeResult<T> {
return result(currentValue, [{
path: path || "/",
kind,
baseValue,
localValue,
currentValue
}]);
}
function result<T>(
value: T,
conflicts: MergeConflict[] = [],
appliedPaths: string[] = []
): ThreeWayMergeResult<T> {
return { value, conflicts, appliedPaths };
}
function joinPath(parent: string, segment: string): string {
const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
return parent ? `${parent}/${escaped}` : `/${escaped}`;
}
function decodePointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
function isMissing(value: unknown): value is MissingValue {
return value === MISSING;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function cloneValue<T>(value: T): T {
if (value === undefined || value === null || typeof value !== "object") return value;
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function deepEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right)) {
return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
}
if (isRecord(left) && isRecord(right)) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length
&& leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
}
return false;
}
function hasOwn(value: object, key: PropertyKey): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}

View File

@@ -0,0 +1,203 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import {
applyConflictChoices,
type ConflictChoice,
type ThreeWayMergeResult
} from "../api/concurrency";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
import Dialog from "./Dialog";
import SegmentedControl from "./SegmentedControl";
export type ConcurrencyResolution<T = unknown> =
| { action: "apply"; value: T; resolvedPaths: string[] }
| { action: "reload" }
| { action: "cancel" };
export type ConcurrencyResolutionRequest<T = unknown> = {
resourceLabel: string;
merge: ThreeWayMergeResult<T>;
};
type Resolver = <T>(
request: ConcurrencyResolutionRequest<T>
) => Promise<ConcurrencyResolution<T>>;
type PendingResolution = {
request: ConcurrencyResolutionRequest;
resolve: (resolution: ConcurrencyResolution) => void;
};
const ConcurrencyResolverContext = createContext<Resolver | null>(null);
export function ConcurrencyConflictProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingResolution | null>(null);
const pendingRef = useRef<PendingResolution | null>(null);
const requestResolution = useCallback<Resolver>((request) => {
pendingRef.current?.resolve({ action: "cancel" });
return new Promise((resolve) => {
const next: PendingResolution = {
request: request as ConcurrencyResolutionRequest,
resolve: resolve as (resolution: ConcurrencyResolution) => void
};
pendingRef.current = next;
setPending(next);
});
}, []);
const finish = useCallback((resolution: ConcurrencyResolution) => {
const active = pendingRef.current;
pendingRef.current = null;
setPending(null);
active?.resolve(resolution);
}, []);
return (
<ConcurrencyResolverContext.Provider value={requestResolution}>
{children}
<ConcurrencyConflictDialog
request={pending?.request ?? null}
onResolve={finish}
/>
</ConcurrencyResolverContext.Provider>
);
}
export function useConcurrencyConflictResolver(): Resolver {
const resolver = useContext(ConcurrencyResolverContext);
if (!resolver) {
throw new Error(
"useConcurrencyConflictResolver must be used inside ConcurrencyConflictProvider"
);
}
return resolver;
}
function ConcurrencyConflictDialog({
request,
onResolve
}: {
request: ConcurrencyResolutionRequest | null;
onResolve: (resolution: ConcurrencyResolution) => void;
}) {
const { translateText } = usePlatformLanguage();
const [choices, setChoices] = useState<Record<string, ConflictChoice>>({});
const requestKey = request
? request.merge.conflicts.map((conflict) => `${conflict.path}:${conflict.kind}`).join("|")
: "";
const effectiveChoices = useMemo(
() => Object.fromEntries(
(request?.merge.conflicts ?? []).map((conflict) => [
conflict.path,
choices[conflict.path] ?? "current"
])
),
[choices, request, requestKey]
);
function applyResolution() {
if (!request) return;
onResolve({
action: "apply",
value: applyConflictChoices(request.merge, effectiveChoices),
resolvedPaths: request.merge.conflicts.map((conflict) => conflict.path)
});
setChoices({});
}
function finish(action: "reload" | "cancel") {
onResolve({ action });
setChoices({});
}
return (
<Dialog
open={Boolean(request)}
title="Concurrent changes"
role="alertdialog"
className="concurrency-conflict-dialog"
footerClassName="button-row compact-actions"
onClose={() => finish("cancel")}
footer={
<>
<Button type="button" onClick={() => finish("cancel")}>
{translateText("i18n:govoplan-core.cancel.77dfd213")}
</Button>
<Button type="button" onClick={() => finish("reload")}>
{translateText("i18n:govoplan-core.reload.cce71553")}
</Button>
<Button type="button" variant="primary" onClick={applyResolution}>
Apply resolution
</Button>
</>
}
>
<p>
{request?.resourceLabel ?? "This item"} changed after you loaded it.
Review each overlapping change before saving.
</p>
<div className="concurrency-conflict-list">
{(request?.merge.conflicts ?? []).map((conflict) => (
<section className="concurrency-conflict-item" key={`${requestKey}:${conflict.path}`}>
<div className="concurrency-conflict-heading">
<code>{conflict.path}</code>
<span>{conflictKindLabel(conflict.kind)}</span>
</div>
<div className="concurrency-conflict-values">
<ConflictValue label="Current" value={conflict.currentValue} />
<ConflictValue label="Your change" value={conflict.localValue} />
</div>
<SegmentedControl
value={effectiveChoices[conflict.path] ?? "current"}
options={[
{ id: "current", label: "Keep current" },
{ id: "local", label: "Use my change" }
]}
onChange={(value) => setChoices((current) => ({
...current,
[conflict.path]: value as ConflictChoice
}))}
/>
</section>
))}
</div>
</Dialog>
);
}
function ConflictValue({ label, value }: { label: string; value: unknown }) {
return (
<div>
<strong>{label}</strong>
<pre>{previewValue(value)}</pre>
</div>
);
}
function previewValue(value: unknown): string {
if (value === undefined) return "(removed)";
if (typeof value === "string") return value.slice(0, 500);
try {
const serialized = JSON.stringify(value, null, 2);
return serialized.length > 1000
? `${serialized.slice(0, 1000)}\n...`
: serialized;
} catch {
return String(value);
}
}
function conflictKindLabel(kind: string): string {
return kind.replace(/_/g, " ");
}
export default ConcurrencyConflictDialog;

View File

@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, type ReactNode } from "react"; import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions"; import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
import { import {
@@ -26,6 +27,9 @@ export type DialogProps = {
titleClassName?: string; titleClassName?: string;
bodyClassName?: string; bodyClassName?: string;
footerClassName?: string; footerClassName?: string;
portal?: boolean;
panelStyle?: CSSProperties;
backdropStyle?: CSSProperties;
}; };
function joinClasses(...classes: Array<string | undefined | false>) { function joinClasses(...classes: Array<string | undefined | false>) {
@@ -49,7 +53,10 @@ export default function Dialog({
headerClassName = "", headerClassName = "",
titleClassName = "", titleClassName = "",
bodyClassName = "", bodyClassName = "",
footerClassName = "" footerClassName = "",
portal = false,
panelStyle,
backdropStyle
}: DialogProps) { }: DialogProps) {
const titleId = useId(); const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled; const canClose = Boolean(onClose) && !closeDisabled;
@@ -107,9 +114,10 @@ export default function Dialog({
if (!open) return null; if (!open) return null;
return ( const dialog = (
<div <div
className={joinClasses("dialog-backdrop", backdropClassName)} className={joinClasses("dialog-backdrop", backdropClassName)}
style={backdropStyle}
role="presentation" role="presentation"
onMouseDown={(event) => { onMouseDown={(event) => {
if ( if (
@@ -122,6 +130,7 @@ export default function Dialog({
ref={panelRef} ref={panelRef}
tabIndex={-1} tabIndex={-1}
className={joinClasses("dialog-panel", className)} className={joinClasses("dialog-panel", className)}
style={panelStyle}
role={role} role={role}
aria-modal="true" aria-modal="true"
data-dialog-stack-state="topmost" data-dialog-stack-state="topmost"
@@ -147,4 +156,8 @@ export default function Dialog({
</section> </section>
</div> </div>
); );
return portal && typeof document !== "undefined"
? createPortal(dialog, document.body)
: dialog;
} }

View File

@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from "react"; import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
export type PageScrollViewportProps = Omit< export type PageScrollViewportProps = Omit<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
@@ -7,17 +7,22 @@ export type PageScrollViewportProps = Omit<
children: ReactNode; children: ReactNode;
}; };
export default function PageScrollViewport({ const PageScrollViewport = forwardRef<HTMLDivElement, PageScrollViewportProps>(
children, function PageScrollViewport({
className = "", children,
...props className = "",
}: PageScrollViewportProps) { ...props
return ( }, ref) {
<div return (
{...props} <div
className={["page-scroll-viewport", className].filter(Boolean).join(" ")} {...props}
> ref={ref}
{children} className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
</div> >
); {children}
} </div>
);
}
);
export default PageScrollViewport;

View File

@@ -1,6 +1,7 @@
export * from "./types"; export * from "./types";
export * from "./api/client"; export * from "./api/client";
export * from "./api/concurrency";
export * from "./api/auth"; export * from "./api/auth";
export * from "./api/platform"; export * from "./api/platform";
export * from "./api/notificationSummary"; export * from "./api/notificationSummary";
@@ -55,6 +56,15 @@ export { default as Button } from "./components/Button";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card"; export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog"; export { default as ConfirmDialog } from "./components/ConfirmDialog";
export {
default as ConcurrencyConflictDialog,
ConcurrencyConflictProvider,
useConcurrencyConflictResolver
} from "./components/ConcurrencyConflictDialog";
export type {
ConcurrencyResolution,
ConcurrencyResolutionRequest
} from "./components/ConcurrencyConflictDialog";
export { default as ConnectionTree } from "./components/ConnectionTree"; export { default as ConnectionTree } from "./components/ConnectionTree";
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree"; export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
export { default as ColorPickerField } from "./components/ColorPickerField"; export { default as ColorPickerField } from "./components/ColorPickerField";

View File

@@ -132,6 +132,14 @@
.step-intro h2 { margin: 0; color: var(--text-strong); } .step-intro h2 { margin: 0; color: var(--text-strong); }
.step-intro p { margin-top: 6px; color: var(--muted); } .step-intro p { margin-top: 6px; color: var(--muted); }
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; } .button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
.concurrency-conflict-dialog { width: min(860px, calc(100vw - 32px)); }
.concurrency-conflict-list { display: grid; gap: 12px; max-height: min(58vh, 620px); overflow: auto; }
.concurrency-conflict-item { border: var(--border-line); border-radius: var(--radius-sm); padding: 12px; }
.concurrency-conflict-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 10px; }
.concurrency-conflict-heading span { color: var(--muted); font-size: 12px; text-transform: capitalize; }
.concurrency-conflict-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.concurrency-conflict-values strong { display: block; margin-bottom: 4px; font-size: 12px; }
.concurrency-conflict-values pre { min-height: 54px; max-height: 160px; margin: 0; padding: 8px; overflow: auto; background: var(--panel-soft); border: var(--border-line); white-space: pre-wrap; overflow-wrap: anywhere; }
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; } .alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.alert.success { background: var(--success-bg); color: var(--success-text); } .alert.success { background: var(--success-bg); color: var(--success-text); }
.alert.info { background: var(--info-bg); color: var(--info-text); } .alert.info { background: var(--info-bg); color: var(--info-text); }
@@ -149,6 +157,7 @@
.section-sidebar { display: none; } .section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; } .wizard-card { grid-template-columns: 1fr; }
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; } .metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
.concurrency-conflict-values { grid-template-columns: 1fr; }
} }