feat: harden shared platform contracts
This commit is contained in:
43
docs/COMPATIBILITY_INVENTORY.md
Normal file
43
docs/COMPATIBILITY_INVENTORY.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
559
src/govoplan_core/core/concurrency.py
Normal file
559
src/govoplan_core/core/concurrency.py
Normal 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",
|
||||
]
|
||||
30
src/govoplan_core/core/mail.py
Normal file
30
src/govoplan_core/core/mail.py
Normal 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]:
|
||||
...
|
||||
@@ -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):
|
||||
|
||||
@@ -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, ...] = ()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
85
tests/test_mail_delivery_worker.py
Normal file
85
tests/test_mail_delivery_worker.py
Normal 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()
|
||||
175
tests/test_optimistic_concurrency.py
Normal file
175
tests/test_optimistic_concurrency.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
35
tests/test_security_redaction.py
Normal file
35
tests/test_security_redaction.py
Normal 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()
|
||||
@@ -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");
|
||||
|
||||
376
webui/src/api/concurrency.ts
Normal file
376
webui/src/api/concurrency.ts
Normal 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);
|
||||
}
|
||||
203
webui/src/components/ConcurrencyConflictDialog.tsx
Normal file
203
webui/src/components/ConcurrencyConflictDialog.tsx
Normal 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;
|
||||
@@ -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<string | undefined | false>) {
|
||||
@@ -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 = (
|
||||
<div
|
||||
className={joinClasses("dialog-backdrop", backdropClassName)}
|
||||
style={backdropStyle}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
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({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
return portal && typeof document !== "undefined"
|
||||
? createPortal(dialog, document.body)
|
||||
: dialog;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
export type PageScrollViewportProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
@@ -7,17 +7,22 @@ export type PageScrollViewportProps = Omit<
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function PageScrollViewport({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: PageScrollViewportProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const PageScrollViewport = forwardRef<HTMLDivElement, PageScrollViewportProps>(
|
||||
function PageScrollViewport({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default PageScrollViewport;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user