diff --git a/docs/COMPATIBILITY_INVENTORY.md b/docs/COMPATIBILITY_INVENTORY.md new file mode 100644 index 0000000..fd695de --- /dev/null +++ b/docs/COMPATIBILITY_INVENTORY.md @@ -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. diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 2487957..6ddeae2 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -277,6 +277,23 @@ unsafe methods. This avoids retransmitting unchanged snapshots. It does not identify which row 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 Collection endpoints that can expose row-level changes should use the shared diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py index de17d83..05b8aa0 100644 --- a/src/govoplan_core/celery_app.py +++ b/src/govoplan_core/celery_app.py @@ -20,6 +20,7 @@ from govoplan_core.core.events import ( publish_platform_event, ) 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.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider from govoplan_core.core.postbox import ( @@ -51,6 +52,8 @@ celery.conf.update( "govoplan.campaigns.append_sent": {"queue": "append_sent"}, "govoplan.notifications.deliver": {"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.dataflow.dispatch_runs": {"queue": "dataflow"}, "govoplan.dataflow.purge_runs": {"queue": "dataflow"}, @@ -69,6 +72,16 @@ celery.conf.update( "schedule": 60.0, "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": { "task": "govoplan.dataflow.dispatch_triggers", "schedule": 60.0, @@ -151,6 +164,16 @@ def _calendar_outbox() -> CalendarOutboxProvider | None: 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( registry: PlatformRegistry | None = None, ) -> DataflowTriggerDispatcher | None: @@ -265,6 +288,51 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int 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) def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50): """Drain durable Calendar operations; retry timing lives in the database.""" diff --git a/src/govoplan_core/core/concurrency.py b/src/govoplan_core/core/concurrency.py new file mode 100644 index 0000000..ba819d7 --- /dev/null +++ b/src/govoplan_core/core/concurrency.py @@ -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", +] diff --git a/src/govoplan_core/core/mail.py b/src/govoplan_core/core/mail.py new file mode 100644 index 0000000..a3d7a2f --- /dev/null +++ b/src/govoplan_core/core/mail.py @@ -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]: + ... diff --git a/src/govoplan_core/core/module_installer.py b/src/govoplan_core/core/module_installer.py index 12a44e8..21b93fb 100644 --- a/src/govoplan_core/core/module_installer.py +++ b/src/govoplan_core/core/module_installer.py @@ -3328,12 +3328,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv 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, ...]: raw_plan = record.get("plan") if not isinstance(raw_plan, list): diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 08aa890..6408d9b 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -215,6 +215,17 @@ class ModuleContext: DocumentationLayer = Literal["always", "configured", "available", "evidence"] DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"] 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) @@ -296,6 +307,40 @@ class DocumentationContext: 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): resource_type: str @@ -372,3 +417,8 @@ class ModuleManifest: on_deactivate: LifecycleHook | None = None documentation: tuple[DocumentationTopic, ...] = () documentation_providers: tuple[DocumentationProvider, ...] = () + documentation_configuration_providers: tuple[ + DocumentationConfigurationProviderRegistration, + ..., + ] = () + documentation_sources: tuple[DocumentationSourceDefinition, ...] = () diff --git a/src/govoplan_core/core/postbox.py b/src/govoplan_core/core/postbox.py index c930745..b306b4c 100644 --- a/src/govoplan_core/core/postbox.py +++ b/src/govoplan_core/core/postbox.py @@ -16,6 +16,7 @@ CAPABILITY_POSTBOX_ROUTING = "postbox.routing" PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"] PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"] +PostboxMessageAvailability = Literal["available", "withdrawn", "expired"] @dataclass(frozen=True, slots=True) @@ -140,6 +141,7 @@ class PostboxMessageRef: subject: str body_text: str | None status: str + availability: PostboxMessageAvailability classification: str sender_label: str | None delivered_at: datetime @@ -159,6 +161,29 @@ class PostboxMessageRef: 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) class PostboxDeliveryRequest: tenant_id: str @@ -317,6 +342,16 @@ class PostboxEvidenceProvider(Protocol): ) -> PostboxMessageRef: ... + def delivery_receipt_summaries( + self, + session: object, + *, + tenant_id: str, + producer_module: str, + delivery_ids: Sequence[str], + ) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]: + ... + @runtime_checkable class PostboxRoutingProvider(Protocol): diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 9050bb3..9885c18 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -564,6 +564,43 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: raise RegistryError( 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: diff --git a/src/govoplan_core/security/redaction.py b/src/govoplan_core/security/redaction.py index 1325a71..21385dc 100644 --- a/src/govoplan_core/security/redaction.py +++ b/src/govoplan_core/security/redaction.py @@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "") -> o return result if isinstance(value, list): 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 def contains_plain_secret(value: object) -> bool: - if not isinstance(value, Mapping): - return False - for key, item in value.items(): - normalized_key = str(key).strip().casefold().replace("-", "_") - if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}: - continue - if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels. - return True - if isinstance(item, Mapping) and contains_plain_secret(item): - return True + if isinstance(value, Mapping): + for key, item in value.items(): + normalized_key = str(key).strip().casefold().replace("-", "_") + if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}: + continue + if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels. + return True + if contains_plain_secret(item): + return True + elif isinstance(value, (list, tuple)): + return any(contains_plain_secret(item) for item in value) return False diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 97d79ce..532b95b 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -132,6 +132,24 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(response.status_code, 201, response.text) 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: headers, _ = self._login() payload = { @@ -950,6 +968,33 @@ class ApiSmokeTests(unittest.TestCase): 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") + 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) self.assertEqual(platform_status.status_code, 200, platform_status.text) i18n_status = platform_status.json()["i18n"] @@ -2747,7 +2792,10 @@ class ApiSmokeTests(unittest.TestCase): archive.writestr("reports/february.txt", "February report") 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( "/api/v1/files/upload-zip", headers=headers, @@ -2890,10 +2938,18 @@ class ApiSmokeTests(unittest.TestCase): self.assertTrue(full_payload["full"]) 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( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave", - headers=headers, - json={"current_step": "fields"}, + headers=mutation_headers, + json={ + "current_step": "fields", + "base_revision": base_revision, + }, ) self.assertEqual(autosaved.status_code, 200, autosaved.text) self.assertEqual(autosaved.json()["current_step"], "fields") @@ -3038,10 +3094,18 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(temporary.json()["user_lock_state"], "temporary") 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( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"current_step": "fields"}, + headers=mutation_headers, + json={ + "current_step": "fields", + "base_revision": base_revision, + }, ) 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.assertIsNone(unlocked.json()["user_lock_state"]) + mutation_headers, base_revision = self._campaign_version_precondition( + headers, + campaign_id, + version_id, + ) updated = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"current_step": "fields"}, + headers=mutation_headers, + json={ + "current_step": "fields", + "base_revision": base_revision, + }, ) self.assertEqual(updated.status_code, 200, updated.text) self.assertEqual(updated.json()["current_step"], "fields") @@ -3116,18 +3188,34 @@ class ApiSmokeTests(unittest.TestCase): self.assertNotEqual(second_version_id, first_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( f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}", - headers=headers, - json={"current_step": "fields"}, + headers=mutation_headers, + json={ + "current_step": "fields", + "base_revision": base_revision, + }, ) self.assertEqual(historical_update.status_code, 409, 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( f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", - headers=headers, - json={"current_step": "fields"}, + headers=mutation_headers, + json={ + "current_step": "fields", + "base_revision": base_revision, + }, ) self.assertEqual(second_update.status_code, 200, second_update.text) @@ -4613,8 +4701,14 @@ class ApiSmokeTests(unittest.TestCase): ) updated = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", - headers=headers, - json={"campaign_json": campaign_json}, + headers={ + **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) validated = self.client.post( @@ -5883,10 +5977,18 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(allowed_write.status_code, 200, allowed_write.text) changed_config = version_detail.json()["raw_json"] 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( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=reader_headers, - json={"campaign_json": changed_config}, + headers=mutation_headers, + json={ + "campaign_json": changed_config, + "base_revision": base_revision, + }, ) 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) @@ -6030,18 +6132,29 @@ class ApiSmokeTests(unittest.TestCase): } blocked_inline = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"campaign_json": inline_json}, + headers={**headers, "If-Match": str(detail.headers["etag"])}, + json={ + "campaign_json": inline_json, + "base_revision": detail.json()["edit_revision"], + }, ) self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text) self.assertIn("may only reference", blocked_inline.json()["detail"]) reusable_json = detail.json()["raw_json"] 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( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"campaign_json": reusable_json}, + headers=mutation_headers, + json={ + "campaign_json": reusable_json, + "base_revision": base_revision, + }, ) 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} denied_update = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"campaign_json": raw_json}, + headers={**headers, "If-Match": str(detail.headers["etag"])}, + json={ + "campaign_json": raw_json, + "base_revision": detail.json()["edit_revision"], + }, ) 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]}}, ) 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( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", - headers=headers, - json={"campaign_json": raw_json}, + headers=mutation_headers, + json={ + "campaign_json": raw_json, + "base_revision": base_revision, + }, ) self.assertEqual(allowed_update.status_code, 200, allowed_update.text) diff --git a/tests/test_core_events.py b/tests/test_core_events.py index b6f7f2c..8f2a3d0 100644 --- a/tests/test_core_events.py +++ b/tests/test_core_events.py @@ -250,6 +250,38 @@ class CoreEventTests(unittest.TestCase): self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"]) 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: with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex( RuntimeError, diff --git a/tests/test_documentation_topic_contract.py b/tests/test_documentation_topic_contract.py index 4c8656e..f9a7af7 100644 --- a/tests/test_documentation_topic_contract.py +++ b/tests/test_documentation_topic_contract.py @@ -3,7 +3,10 @@ from __future__ import annotations import unittest from govoplan_core.core.modules import ( + DocumentationConfigurationDecision, + DocumentationConfigurationProviderRegistration, DocumentationCondition, + DocumentationSourceDefinition, DocumentationTopic, ModuleManifest, user_workflow_scope_condition_issues, @@ -67,6 +70,52 @@ class DocumentationTopicContractTests(unittest.TestCase): self.assertEqual(user_workflow_scope_condition_issues(user_reference), ()) 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: registry = PlatformRegistry() diff --git a/tests/test_mail_delivery_worker.py b/tests/test_mail_delivery_worker.py new file mode 100644 index 0000000..399fd16 --- /dev/null +++ b/tests/test_mail_delivery_worker.py @@ -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() diff --git a/tests/test_optimistic_concurrency.py b/tests/test_optimistic_concurrency.py new file mode 100644 index 0000000..4f1b8c3 --- /dev/null +++ b/tests/test_optimistic_concurrency.py @@ -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() diff --git a/tests/test_postbox_contract.py b/tests/test_postbox_contract.py index 1f46b29..d55e45a 100644 --- a/tests/test_postbox_contract.py +++ b/tests/test_postbox_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations import unittest +from datetime import datetime, timezone from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.postbox import ( @@ -11,6 +12,7 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_MESSAGES, PostboxAccessProvider, PostboxDeliveryProvider, + PostboxDeliveryReceiptSummaryRef, PostboxDirectoryProvider, PostboxEvidenceProvider, PostboxMessagesProvider, @@ -69,6 +71,17 @@ class _PostboxProvider: del session, tenant_id, message_id, attachment 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): 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, PostboxDeliveryProvider) 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.register( diff --git a/tests/test_security_redaction.py b/tests/test_security_redaction.py new file mode 100644 index 0000000..8281cd6 --- /dev/null +++ b/tests/test_security_redaction.py @@ -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["request"]["Authorization"]) # type: ignore[index] + self.assertEqual("", redacted["request"]["headers"][0]["x-api-key"]) # type: ignore[index] + self.assertEqual("", redacted["records"][0]["clientSecret"]) # type: ignore[index] + self.assertEqual("", 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() diff --git a/webui/scripts/test-dialog-focus-structure.mjs b/webui/scripts/test-dialog-focus-structure.mjs index 745f490..c8db659 100644 --- a/webui/scripts/test-dialog-focus-structure.mjs +++ b/webui/scripts/test-dialog-focus-structure.mjs @@ -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("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback"); 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(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"); diff --git a/webui/src/api/concurrency.ts b/webui/src/api/concurrency.ts new file mode 100644 index 0000000..eb763fa --- /dev/null +++ b/webui/src/api/concurrency.ts @@ -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 = { + 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( + base: T, + local: T, + current: T, + options: { + protectedPaths?: readonly string[]; + stableIdFields?: readonly string[]; + } = {} +): ThreeWayMergeResult { + return mergeValue( + cloneValue(base), + cloneValue(local), + cloneValue(current), + "", + options.protectedPaths ?? [], + options.stableIdFields ?? ["id"] + ) as ThreeWayMergeResult; +} + +export function applyConflictChoices( + merge: ThreeWayMergeResult, + choices: Readonly> +): 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, + local: Record, + current: Record, + path: string, + protectedPaths: readonly string[], + stableIdFields: readonly string[] +): ThreeWayMergeResult> { + const value: Record = {}; + 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 { + 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)[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(); + 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)[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( + path: string, + kind: string, + baseValue: unknown, + localValue: unknown, + currentValue: T +): ThreeWayMergeResult { + return result(currentValue, [{ + path: path || "/", + kind, + baseValue, + localValue, + currentValue + }]); +} + +function result( + value: T, + conflicts: MergeConflict[] = [], + appliedPaths: string[] = [] +): ThreeWayMergeResult { + 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function cloneValue(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); +} diff --git a/webui/src/components/ConcurrencyConflictDialog.tsx b/webui/src/components/ConcurrencyConflictDialog.tsx new file mode 100644 index 0000000..56dcd67 --- /dev/null +++ b/webui/src/components/ConcurrencyConflictDialog.tsx @@ -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 = + | { action: "apply"; value: T; resolvedPaths: string[] } + | { action: "reload" } + | { action: "cancel" }; + +export type ConcurrencyResolutionRequest = { + resourceLabel: string; + merge: ThreeWayMergeResult; +}; + +type Resolver = ( + request: ConcurrencyResolutionRequest +) => Promise>; + +type PendingResolution = { + request: ConcurrencyResolutionRequest; + resolve: (resolution: ConcurrencyResolution) => void; +}; + +const ConcurrencyResolverContext = createContext(null); + +export function ConcurrencyConflictProvider({ children }: { children: ReactNode }) { + const [pending, setPending] = useState(null); + const pendingRef = useRef(null); + + const requestResolution = useCallback((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 ( + + {children} + + + ); +} + +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>({}); + 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 ( + finish("cancel")} + footer={ + <> + + + + + } + > +

+ {request?.resourceLabel ?? "This item"} changed after you loaded it. + Review each overlapping change before saving. +

+
+ {(request?.merge.conflicts ?? []).map((conflict) => ( +
+
+ {conflict.path} + {conflictKindLabel(conflict.kind)} +
+
+ + +
+ setChoices((current) => ({ + ...current, + [conflict.path]: value as ConflictChoice + }))} + /> +
+ ))} +
+
+ ); +} + +function ConflictValue({ label, value }: { label: string; value: unknown }) { + return ( +
+ {label} +
{previewValue(value)}
+
+ ); +} + +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; diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx index a377e2d..c2ade25 100644 --- a/webui/src/components/Dialog.tsx +++ b/webui/src/components/Dialog.tsx @@ -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 { shouldCloseDialogOnBackdrop } from "./dialogInteractions"; import { @@ -26,6 +27,9 @@ export type DialogProps = { titleClassName?: string; bodyClassName?: string; footerClassName?: string; + portal?: boolean; + panelStyle?: CSSProperties; + backdropStyle?: CSSProperties; }; function joinClasses(...classes: Array) { @@ -49,7 +53,10 @@ export default function Dialog({ headerClassName = "", titleClassName = "", bodyClassName = "", - footerClassName = "" + footerClassName = "", + portal = false, + panelStyle, + backdropStyle }: DialogProps) { const titleId = useId(); const canClose = Boolean(onClose) && !closeDisabled; @@ -107,9 +114,10 @@ export default function Dialog({ if (!open) return null; - return ( + const dialog = (
{ if ( @@ -122,6 +130,7 @@ export default function Dialog({ ref={panelRef} tabIndex={-1} className={joinClasses("dialog-panel", className)} + style={panelStyle} role={role} aria-modal="true" data-dialog-stack-state="topmost" @@ -147,4 +156,8 @@ export default function Dialog({
); + + return portal && typeof document !== "undefined" + ? createPortal(dialog, document.body) + : dialog; } diff --git a/webui/src/components/PageScrollViewport.tsx b/webui/src/components/PageScrollViewport.tsx index 8d242ef..435be60 100644 --- a/webui/src/components/PageScrollViewport.tsx +++ b/webui/src/components/PageScrollViewport.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes, ReactNode } from "react"; +import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; export type PageScrollViewportProps = Omit< HTMLAttributes, @@ -7,17 +7,22 @@ export type PageScrollViewportProps = Omit< children: ReactNode; }; -export default function PageScrollViewport({ - children, - className = "", - ...props -}: PageScrollViewportProps) { - return ( -
- {children} -
- ); -} +const PageScrollViewport = forwardRef( + function PageScrollViewport({ + children, + className = "", + ...props + }, ref) { + return ( +
+ {children} +
+ ); + } +); + +export default PageScrollViewport; diff --git a/webui/src/index.ts b/webui/src/index.ts index 0ca7014..871fac4 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -1,6 +1,7 @@ export * from "./types"; export * from "./api/client"; +export * from "./api/concurrency"; export * from "./api/auth"; export * from "./api/platform"; export * from "./api/notificationSummary"; @@ -55,6 +56,15 @@ export { default as Button } from "./components/Button"; export type { ButtonProps } from "./components/Button"; export { default as Card } from "./components/Card"; 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 type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree"; export { default as ColorPickerField } from "./components/ColorPickerField"; diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index 6069904..c871f52 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -132,6 +132,14 @@ .step-intro h2 { margin: 0; color: var(--text-strong); } .step-intro p { margin-top: 6px; color: var(--muted); } .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.success { background: var(--success-bg); color: var(--success-text); } .alert.info { background: var(--info-bg); color: var(--info-text); } @@ -149,6 +157,7 @@ .section-sidebar { display: none; } .wizard-card { grid-template-columns: 1fr; } .metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; } + .concurrency-conflict-values { grid-template-columns: 1fr; } }