Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 | ||
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b |
@@ -0,0 +1,21 @@
|
||||
"""Development-track wrapper for the ownership history repair."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = Path(__file__).resolve().parents[1] / "versions" / "c58a2d7e9f10_ownership_decision_history.py"
|
||||
_spec = spec_from_file_location("govoplan_ownership_decision_history_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||
_module = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
|
||||
revision = _module.revision
|
||||
down_revision = _module.down_revision
|
||||
branch_labels = _module.branch_labels
|
||||
depends_on = _module.depends_on
|
||||
upgrade = _module.upgrade
|
||||
downgrade = _module.downgrade
|
||||
@@ -0,0 +1,37 @@
|
||||
"""repair decision history on previously upgraded ownership tables
|
||||
|
||||
Revision ID: c58a2d7e9f10
|
||||
Revises: b47e6f809a13
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c58a2d7e9f10"
|
||||
down_revision = "b47e6f809a13"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The original ownership migration gained this column after some databases
|
||||
# had already applied it. create_all/checkfirst cannot upgrade those tables.
|
||||
# Fresh installations already have it; never replace their audit evidence.
|
||||
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns(
|
||||
"core_ownership_transfers"
|
||||
)}
|
||||
if "decisions" not in columns:
|
||||
op.add_column(
|
||||
"core_ownership_transfers",
|
||||
sa.Column("decisions", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Older installations and fresh installations at the preceding revision
|
||||
# differ. Keep the additive column and any subsequently recorded evidence.
|
||||
pass
|
||||
@@ -142,6 +142,7 @@ system:tenants:read
|
||||
system:tenants:create
|
||||
system:tenants:update
|
||||
system:tenants:suspend
|
||||
system:tenants:erase
|
||||
|
||||
system:accounts:read
|
||||
system:accounts:create
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared API client cache and authority boundaries
|
||||
|
||||
All optional WebUI modules use the Core API client. Its bounded in-memory caches
|
||||
are an optimization, never an authorization mechanism. The backend must check
|
||||
the current principal, tenant, and permissions even for conditional GETs.
|
||||
|
||||
- Identical simultaneous safe requests can share one network request. Requests
|
||||
with caller-owned cancellation are independent.
|
||||
- Responses allowing reuse have at most a 750 ms recent-response window.
|
||||
`no-store` and `Vary: *` responses are not retained. `no-cache` and zero-age
|
||||
responses require a server check; permitted ETags retain conditional GET
|
||||
support without bypassing authorization. This follows the relevant
|
||||
[HTTP cache-control semantics](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2).
|
||||
- Explicit `cache: "no-store"`, `"reload"`, or `"no-cache"` reads bypass older
|
||||
response data and supersede older requests for that resource. Reload is not a
|
||||
mutation. Owning read helpers must pass these options through pagination.
|
||||
- Writes invalidate caches before execution and again on settlement, including
|
||||
failures whose server outcome may be uncertain. Reads started before or
|
||||
during the write cannot seed reusable data after it finishes.
|
||||
- The shell calls `clearApiReadCache()` before explicit auth updates and when
|
||||
refreshing authoritative session data. API-settings changes, clearing the
|
||||
token, authentication expiry, and changes to the paired session/CSRF cookie
|
||||
also invalidate both stored and in-flight reuse. Cookie observation also
|
||||
covers sign-in/out in another tab; it does not read the HttpOnly session token.
|
||||
- Interactive sign-in and sign-out clear a previously saved automation key.
|
||||
Explicit key-based connection settings still select the key's identity;
|
||||
profile-only updates preserve settings identity to avoid reload loops.
|
||||
- Expired responses from superseded reads or downloads do not trigger a login
|
||||
prompt in a newer session.
|
||||
- Every completion (including 304) must still own its cache slot and generation
|
||||
before storing anything. An old caller may receive its own result, so feature
|
||||
components must continue guarding displayed state against obsolete requests.
|
||||
|
||||
Regression coverage: `npm run test:api-client-cache` uses the real client and
|
||||
isolated network fixtures. No live API or account data is involved.
|
||||
@@ -157,6 +157,16 @@ The initial implementation includes provider-neutral orchestration helpers:
|
||||
- `apply_configuration_package(...)`
|
||||
- `export_configuration_package(...)`
|
||||
|
||||
Portable fragments may bind deployment-specific operator input without placing
|
||||
that value in the signed reusable definition. A payload value of
|
||||
`{"$data": "requirement_key"}` references a key declared in the manifest's
|
||||
`data_requirements`. Preflight fails before invoking the owning provider when a
|
||||
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
|
||||
the reference in memory and passes only the resolved fragment to the provider.
|
||||
This mechanism is for deployment bindings and wording, not plaintext secrets:
|
||||
credential-envelope or environment references remain the normal portable
|
||||
boundary.
|
||||
|
||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||
fragments and applies them idempotently. Mail and Files also register providers
|
||||
@@ -190,6 +200,19 @@ Feature providers remain responsible for their own semantics:
|
||||
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||
parser with different validation or secret-handling rules.
|
||||
|
||||
Core also defines the inverse, read-only dependency-inventory contract used
|
||||
before the installer changes one of those infrastructure capabilities. An
|
||||
enabled module registers
|
||||
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
|
||||
references to its persisted configuration or data, a lifecycle state, scope,
|
||||
numeric metrics, and a required operator action. Providers must not return
|
||||
secrets or use this read to migrate state. The Core collector validates provider
|
||||
identity and capability coverage, orders records deterministically, and marks
|
||||
the complete inventory failed when any provider raises or violates the
|
||||
contract. Ops is the authorized projection boundary; the installer remains the
|
||||
consumer and must match installation id, freshness, completion and impacted
|
||||
capability coverage before apply.
|
||||
|
||||
The admin wizard backend starts with these routes:
|
||||
|
||||
- `GET /api/v1/admin/configuration-packages/catalog`
|
||||
@@ -212,6 +235,14 @@ The admin wizard backend starts with these routes:
|
||||
10. Store import provenance, package version, supplied non-secret metadata, and
|
||||
audit events.
|
||||
|
||||
Provider applies may commit independently. Core therefore stops at the first
|
||||
apply or health blocker and reports an explicit rollback state. A blocked
|
||||
preflight or a no-op needs no recovery; a successful multi-provider mutation
|
||||
retains the reviewed pre-apply database snapshot as its generic rollback path;
|
||||
a later-provider failure is reported as a partial apply that requires snapshot
|
||||
recovery or an explicitly supported module-owned compensation. The generic
|
||||
wizard never claims atomic cross-module undo.
|
||||
|
||||
The wizard should display everything necessary and nothing unnecessary. Generic
|
||||
sections should cover package trust, dependency plan, required data, conflicts,
|
||||
review, and result. Module-specific fields should appear only when the selected
|
||||
@@ -262,6 +293,11 @@ Exported packages should record provenance: source GovOPlaN version, module
|
||||
versions, exporter identity, timestamp, selected scope, redactions, and
|
||||
validation status.
|
||||
|
||||
The orchestrator emits this provenance independently of provider payloads and
|
||||
lists secret requirement keys as redacted without serializing their supplied
|
||||
values. Providers still own the deeper rule that credentials, tokens, and
|
||||
decrypted envelope contents must never appear in exported fragments.
|
||||
|
||||
## Catalogs And Trust
|
||||
|
||||
Configuration catalogs should follow the existing module package catalog model:
|
||||
|
||||
@@ -58,6 +58,17 @@ than adding custom `F1` listeners:
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
- `PasswordField` passes its owner context and module through reveal/generate
|
||||
actions and the shared generator dialog. Credential consumers must supply an
|
||||
exact owner context; the generic component does not own credential policy.
|
||||
|
||||
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||
obvious cases conservatively; components may declare `data-help-risk`
|
||||
explicitly or mark a reviewed ordinary control with
|
||||
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||
unresolved high-risk debt.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# DataGrid Sizing Contract
|
||||
|
||||
Auto-height grids reserve no empty vertical scrollbar gutter. The table fills
|
||||
its card to the right edge; an actual constrained vertical scrollbar still
|
||||
occupies its normal space. `Card bodyLayout="table"` provides an explicit
|
||||
zero-inset surface, including with loading wrappers and padded notices. The
|
||||
Organizations/IDM browser fixtures assert row geometry, not just outer shells.
|
||||
|
||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||
its container has a measurable width. The same contract is used on initial
|
||||
layout, container resize, persisted-layout restore, and pointer resize.
|
||||
layout, container resize, persisted-layout restore, and pointer/keyboard resize.
|
||||
|
||||
## Column Declarations
|
||||
|
||||
@@ -17,6 +23,54 @@ layout, container resize, persisted-layout restore, and pointer resize.
|
||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||
that is necessary to keep the table flush with its container.
|
||||
- `columnType: "actions"` marks a custom action/control column. Canonical
|
||||
`TableActionGroup` content is recognized automatically, even in existing
|
||||
column declarations. Use `sticky: "end"` for the normal row-action surface.
|
||||
|
||||
## Action Visibility and Constrained Containers
|
||||
|
||||
Action tracks reserve the width of actual buttons, disabled-action wrappers,
|
||||
reserved empty-state slots, gaps, and cell padding. A historic `width: 72`
|
||||
preference therefore cannot clip a four-button action group. Ordinary data text
|
||||
does not participate in this content measurement; long field values do not
|
||||
silently widen all tracks. Changes to the rendered action set are remeasured.
|
||||
|
||||
`TableActionButton` remains a compact 36 px control, including the Add action
|
||||
in an empty grid. Never stretch it with a last-column `.btn { width: 100% }`
|
||||
rule. Its shared maximum width and fixed flex basis protect against broad
|
||||
consumer button rules, which can otherwise feed stretched widths back into
|
||||
action-track measurement and consume the data area.
|
||||
|
||||
When the full group needs more than half the scroll viewport, its measured
|
||||
minimum is capped at half the viewport and the group wraps. Explicit hard
|
||||
minima remain authoritative. Custom action groups should use wrapping-capable
|
||||
flex layouts and semantic groups, preferably composing `TableActionGroup`.
|
||||
|
||||
The grid's physical width matches its pixel tracks, including horizontal
|
||||
overflow, so right-sticky actions remain inside the correct scroll bounds.
|
||||
If explicitly wide or persisted sticky tracks would obscure the readable data
|
||||
area, horizontal stickiness is released until space returns. No columns or
|
||||
actions are hidden: the labelled scroll region is focusable and supports native
|
||||
keyboard scrolling. Vertical header stickiness remains available.
|
||||
|
||||
## Resizing Controls
|
||||
|
||||
Drag a resize handle with a mouse, pen, or touch pointer. Pointer capture keeps
|
||||
the drag active when it leaves the handle. Escape or pointer cancellation
|
||||
restores the layout before that drag; releasing the pointer commits it. Losing
|
||||
window focus ends a drag without leaving the table stuck in resizing mode.
|
||||
|
||||
Each handle is a focusable vertical separator exposing its current and allowed
|
||||
widths. Left/Right changes its width by 10 px; Shift+Left/Right uses 40 px. Enter
|
||||
or a double-click resets that column's explicit override to the declared sizing
|
||||
rules. Other columns retain their preferences, so cover/compensation constraints
|
||||
still apply. These operations only change personal browser layout, never rows.
|
||||
|
||||
Deutsch: Spalten lassen sich mit Maus, Stift oder Touch ziehen. Escape verwirft
|
||||
den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Breite
|
||||
um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche
|
||||
Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen;
|
||||
breite Tabellen bleiben horizontal scrollbar.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
@@ -39,7 +93,7 @@ the column remains stopped until the pointer crosses the same boundary again.
|
||||
Only the pixel layout resulting from an explicit user resize is persisted,
|
||||
together with the container width at which the user selected it.
|
||||
Persisted widths are keyed by a signature containing column IDs, declared
|
||||
widths and bounds, resize affordances, sticky placement, initial fit, and resize
|
||||
widths and bounds, sort/filter/resize affordances, column type, sticky placement, initial fit, and resize
|
||||
behavior. A changed signature discards the old override and recomputes the
|
||||
declared layout.
|
||||
|
||||
@@ -51,7 +105,10 @@ contracts, persisted tracks may shrink toward their hard minima. The layout
|
||||
retains only the amount of horizontal overflow deliberately created by the
|
||||
user; an exact-cover layout therefore remains exact-cover at narrower widths.
|
||||
Legacy snapshots from the former hard-pixel persistence contract are discarded
|
||||
once and recomputed from the declared column layout.
|
||||
once and recomputed from the declared column layout. The current `v3` signature
|
||||
also discards old snapshots that predate action and header-control minima;
|
||||
sort/filter preferences remain intact. Measured action widths are not included
|
||||
in the signature, so changing rows does not erase user sizing intent.
|
||||
|
||||
## Regression Matrix
|
||||
|
||||
@@ -72,3 +129,11 @@ once and recomputed from the declared column layout.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
|
||||
`webui/conformance/tests/data-grid-layout.spec.ts` exercises the real rendered
|
||||
grid with deliberately undersized action preferences, constrained containers,
|
||||
horizontal scrolling, changing/empty action sets, keyboard and pointer resizing,
|
||||
Escape cancellation, remount persistence, responsive contraction and restoration,
|
||||
free/content mode, and constrained compensation. Run with
|
||||
`npm run test:conformance -- data-grid-layout.spec.ts`; its isolated test server
|
||||
is stopped automatically afterwards.
|
||||
|
||||
@@ -74,6 +74,19 @@ Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
backup/restore hooks for the module installer.
|
||||
|
||||
#### Ownership-history upgrade repair
|
||||
|
||||
Core revision `c58a2d7e9f10` repairs existing ownership-transfer tables that
|
||||
predate the `decisions` column. Such installations can otherwise return HTTP
|
||||
500 from `/api/v1/ownership/transfers`, including Campaign Settings. Apply the
|
||||
normal forward migrations after taking a backup; do not stamp a revision or
|
||||
recreate the table. The repair is available on both migration tracks, adds only
|
||||
the missing non-null JSON column, and initializes old rows with an empty list.
|
||||
It preserves owners, approvals, transfer states, revisions, timestamps, and any
|
||||
existing decision history. Historical decisions are not reconstructed or
|
||||
invented. Downgrading this repair retains the additive column and its evidence.
|
||||
Verify that ownership-transfer listing and Campaign Settings load after upgrade.
|
||||
|
||||
### PostgreSQL Production Target
|
||||
|
||||
PostgreSQL is the primary development and production target. SQLite remains
|
||||
@@ -408,6 +421,27 @@ To stop PostgreSQL and Redis when the launcher exits:
|
||||
GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh
|
||||
```
|
||||
|
||||
## Development WebUI Dependency Caches
|
||||
|
||||
The application and browser-conformance harness share installed JavaScript
|
||||
packages but must not share Vite's optimized-dependency cache. The application
|
||||
uses `webui/node_modules/.vite/govoplan-app`; the conformance harness uses
|
||||
`webui/node_modules/.vite/govoplan-conformance`. Keep these explicit sibling
|
||||
directories when adding development or test configurations. Setting a different
|
||||
Vite `root` alone does not isolate this cache.
|
||||
|
||||
A shared cache can make otherwise healthy Workflow, Dataflow or deferred editors
|
||||
show “The resource could not be loaded.” The browser then reports an asset such
|
||||
as `@xyflow_react.js` with HTTP 504 `Outdated Optimize Dep`, while the corresponding
|
||||
API still returns HTTP 200. This is not a missing workflow permission or a reason
|
||||
to rerun a pipeline. Preserve unsaved work, let the existing development server
|
||||
reload the corrected configuration (or restart that WebUI server), then reload
|
||||
the browser. Do not clear application data, change grants or restart delivery
|
||||
workers to repair a frontend dependency cache.
|
||||
|
||||
Run `npm run test:vite-cache-isolation` in `govoplan-core/webui` to verify the real
|
||||
resolved Vite configurations without starting servers or overwriting caches.
|
||||
|
||||
## Module Install/Uninstall Operations
|
||||
|
||||
Use Admin > System > Modules for planning. The running API server validates and
|
||||
|
||||
@@ -18,6 +18,7 @@ operator, and roadmap pages.
|
||||
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
|
||||
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
|
||||
| Ticket routing and Case escalation | `TICKET_INTEGRATION_CONTRACTS.md` | Optional fail-open routing, replay-safe Case handoff, authorization, evidence, and ownership boundaries. |
|
||||
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
|
||||
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
|
||||
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
|
||||
|
||||
@@ -17,7 +17,7 @@ domain modules own their compositions.
|
||||
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
|
||||
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
|
||||
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders guarded Reload in the right-aligned group immediately before Create/primary actions; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
|
||||
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
|
||||
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
|
||||
@@ -27,9 +27,46 @@ domain modules own their compositions.
|
||||
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
|
||||
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
|
||||
| Measured operation feedback | `LoadingFrame` over existing content | Use `indicator="none"` with native measured progress for long-running operations; `progress={null}` means unknown, never a synthetic percentage. Keep dialog content inert and close controls disabled until success or error. Existing consumers retain their loading indicator. | Files archive inspection/extraction, layout primitive tests, managed-archive browser conformance |
|
||||
|
||||
## Boundary
|
||||
|
||||
Side-rail customization uses the shared `NavigationPreferenceEditor` for system,
|
||||
tenant, personal, and View layouts. Modules and labelled separators share one
|
||||
ordered list, with pointer drag-and-drop, keyboard reordering, and explicit
|
||||
add/remove actions. Consumers retain persistence and dirty-state ownership;
|
||||
mounting the editor does not create a draft change. See
|
||||
`NAVIGATION_LAYOUT_CONTRACT.md` for inheritance, locked items, optional-module
|
||||
preservation, and collapsed-rail grouping.
|
||||
|
||||
Action columns use `TableActionGroup` or declare `columnType: "actions"` when
|
||||
their composition differs. DataGrid owns measured action minima, initial column
|
||||
allocation, persistent resizing, and local horizontal scrolling; consumers must
|
||||
not compensate with clipped overflow or copied fixed widths. See
|
||||
`DATAGRID_SIZING_CONTRACT.md`. Dialog forms use `DialogForm` and `FormGrid` inside
|
||||
the shared size-bounded dialog. Do not add a content minimum wider than the
|
||||
panel's padded interior. Genuinely wide content, such as a table, owns its own
|
||||
local scroller instead of making the entire dialog scroll horizontally.
|
||||
|
||||
In `FormGrid` and `FormLayout`, direct `FormField` and `ToggleSwitch` items
|
||||
align their controls at the row's lower edge. A single control inside a
|
||||
`GridItem` follows the same rule. Labels may wrap without shifting adjacent
|
||||
switches up into the label row. Do not add per-module top margins or empty
|
||||
labels; single-column layouts must not retain a phantom label spacer.
|
||||
|
||||
Credential editors resolve public reference labels when opened. A failed
|
||||
save displays its error inside the dialog and keeps the entered draft for an
|
||||
explicit retry. While a write is pending, repeated submission, edits, and
|
||||
dialog dismissal are disabled; no configured secret is read back from storage.
|
||||
|
||||
The shared rich-text editor emits content changes only for actual document
|
||||
edits. Mounting, read-only changes, loading a saved value, and switching between
|
||||
visual and source inspection must preserve the controlled HTML without marking
|
||||
the owning page dirty. This is especially important for legacy Campaign HTML:
|
||||
merely visiting Template must not normalize it or require a save on leaving.
|
||||
The WYSIWYG lifecycle browser conformance covers both visual and legacy-source
|
||||
initial content, as well as genuine typing.
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
|
||||
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Structured Documentation Localization
|
||||
|
||||
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||
separate `structured_translation_version="1"` contract and provide a complete
|
||||
same-shape value for each translated metadata key in
|
||||
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||
outcome, result and verification; reference fields; limitations, constraints,
|
||||
consequences and consequence classes; and the other rendered explanation
|
||||
fields declared by Core.
|
||||
|
||||
The registry rejects an unversioned translation, an unsupported contract
|
||||
version, missing structured keys, changed object keys or list lengths, empty
|
||||
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||
permission scopes, and other technical leaves therefore remain structurally
|
||||
bound to the source metadata. The Docs module overlays only a validated locale
|
||||
at response time and reports the selected structured locale separately from the
|
||||
title/body locale. Missing structured translations fall back to source content
|
||||
and remain visible in public coverage until the owning module adopts the
|
||||
contract.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
@@ -54,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The same inventory classifies controls whose labels, identities, component
|
||||
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||
retention. These controls require an exact context rather than relying only on
|
||||
page fallback. Reviewed false positives carry
|
||||
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||
resolved. Password fields and their generator dialog propagate the owning
|
||||
field's context so shared credential controls never invent a Core-owned topic.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
@@ -71,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
The same conformance suite mounts the production Forms Runtime self-service and
|
||||
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
|
||||
and mobile runs traverse native controls by keyboard, inspect accessible names
|
||||
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
|
||||
retain independent per-field assisted provenance. Physical assistive-technology
|
||||
spot checks remain release evidence rather than being represented as browser
|
||||
automation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
@@ -88,6 +128,8 @@ The check must report:
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
- no invalid high-risk help annotation or regression above the recorded
|
||||
exact-context debt ceiling.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- provider-neutral tenant-erasure preview, step, idempotency, and
|
||||
reconciliation contracts in `govoplan_core.core.tenant_erasure`
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
- command/event envelope contract
|
||||
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
|
||||
single-tenant path so it invokes every registered provider for the target
|
||||
tenant, independent of ordinary list-page projections.
|
||||
|
||||
Governed populated-tenant erasure is separate from ordinary delete vetoes.
|
||||
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
|
||||
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
|
||||
backup dispositions, ordered destructive warnings, idempotent step execution,
|
||||
and reconciliation. The collector fails closed when a provider is invalid or
|
||||
fails. A module with nonzero tenant summary counts and no erasure capability is
|
||||
reported as unsupported and blocks execution; modules with neither contract
|
||||
are explicitly projected as outside tenant-persistence scope. Provider
|
||||
evidence contains counts and stable references only and must never contain
|
||||
secrets or erased subject data.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
@@ -1030,6 +1043,49 @@ Any future exception is extraction debt and must be temporary, documented in the
|
||||
script with a reason, and removed when a capability/API/event contract replaces
|
||||
it.
|
||||
|
||||
## Product Surface Contributions
|
||||
|
||||
`FrontendModule.product_surfaces` is the versioned product-composition contract
|
||||
for stable identities that may have one or more technical owners. A contribution
|
||||
declares contract version 1, a product identity, common label/icon/description,
|
||||
stable entry path, owner route and View surfaces, supported task/reader/admin/
|
||||
operator presentations, authorization requirements, capabilities, search
|
||||
sources, help contexts, documentation topics, migration aliases, and standard
|
||||
unavailable/degraded explanations.
|
||||
|
||||
Core validates every reference against the owning manifest. Contributors that
|
||||
share an identity must agree on its common product metadata and entry path;
|
||||
entry and alias paths cannot belong to another product identity. The WebUI
|
||||
composes valid owners by product id, filters them through authorization and the
|
||||
effective View, and resolves the stable entry or migration alias to the first
|
||||
available owner route. It emits `govoplan:product-surface-route-resolved` before
|
||||
the redirect so migration telemetry can observe alias use without making the
|
||||
technical module part of the ordinary label.
|
||||
|
||||
The shell projects every authorized, View-visible owner route with a product
|
||||
contribution into one stable product navigation item. The product label and
|
||||
entry path replace package topology in the primary rail; every contributing
|
||||
owner path still marks that item active. `All available tools` is a collapsed,
|
||||
permission-derived catalogue built independently of the active View, so a
|
||||
focused workflow cannot remove the explicit escape. It may reveal an
|
||||
authorized owner route that a View omitted, but never an unauthorized route.
|
||||
Navigation visibility preferences do not delete catalogue entries, and the
|
||||
original owner routes remain compatible deep links.
|
||||
|
||||
The initial promoted destinations are `work.items` at `/work`,
|
||||
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
|
||||
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
|
||||
and availability language are centralized in Core while Tasks, Calendar,
|
||||
Mail/Postbox, and Files retain route, command, search, help, documentation,
|
||||
authorization, and data ownership.
|
||||
|
||||
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
|
||||
ordinary state explains the attempted outcome, consequence, recovery path and
|
||||
responsible role. Exact module, capability, provider and correlation values may
|
||||
be supplied as a collapsed technical detail; they are not the primary error.
|
||||
The state is presentation only and never grants authority or changes provider
|
||||
health.
|
||||
|
||||
## Boundary Decision Register
|
||||
|
||||
These durable decisions close older exploratory core issues. Implementation
|
||||
@@ -1579,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a
|
||||
controlled deployment option rather than a replacement for release package
|
||||
builds.
|
||||
|
||||
A failed local WebUI package import receives one automatic retry after 250 ms.
|
||||
Descriptor validation still fails closed; it is not bypassed by the retry.
|
||||
If an enabled local module still cannot load, the signed-in shell warns that its
|
||||
screens and integrations may be unavailable and identifies the module. This is
|
||||
a loading failure, not an uninstall. Save other drafts before manually reloading
|
||||
the page; there is no automatic page reload or persistent retry loop. A verified
|
||||
remote fallback that successfully loads clears that module's warning. Packages
|
||||
absent from the optional build graph remain absent, and effective View filtering
|
||||
continues to control which loaded UI capabilities are exposed.
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
Maintenance mode is the required operating state for package install/uninstall
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Shared navigation layout contract
|
||||
|
||||
Core owns `NavigationPreferenceEditor`, ordered layout resolution, and rail
|
||||
rendering. Admin, Tenancy, personal Settings and Views reuse this editor. They
|
||||
own loading, authorization, Save, Reload and dirty-state guards; the editor
|
||||
emits a draft only after a real edit. A drag onto the same position, keyboard
|
||||
pickup/drop without movement, and opening inherited settings do not save or
|
||||
create an override.
|
||||
|
||||
## Stored document and precedence
|
||||
|
||||
The version-1 navigation document retains `order`, `hidden` and `locked` and
|
||||
adds optional `separators`, each containing a stable `separator:`-prefixed ID
|
||||
and an optional plain-text label of at most 120 characters. Separator IDs and
|
||||
module navigation IDs occupy the same `order` list. Separators are presentation
|
||||
metadata and never become routes, modules, permissions or authorized surfaces.
|
||||
|
||||
Omitting `separators` or using null preserves inherited grouping. An explicit
|
||||
empty array removes grouping. Resetting the entire navigation document to null
|
||||
removes that scope's override. Existing order-only documents remain readable;
|
||||
the editor materializes group markers into a draft only when edited. Unknown
|
||||
optional-module order IDs remain stored when currently visible items move, so
|
||||
uninstalling or temporarily disabling a module does not destroy its preference.
|
||||
|
||||
User order and visibility take precedence over tenant and system preferences.
|
||||
System and tenant visibility locks accumulate; lower scopes cannot hide those
|
||||
destinations, but may move them. Views may supply a navigation presentation
|
||||
inside the already authorized and View-filtered destination set. An explicit
|
||||
personal order/layout or visibility preference takes precedence over that
|
||||
presentation, not over authorization or the View's surface restrictions.
|
||||
Views cannot introduce locks. When multiple modules contribute one product
|
||||
entry, it inherits the earliest effective rail position/section and all
|
||||
authorized contributors' locks; this does not change operational route
|
||||
selection. An owner alias in View layout refers to that composed entry.
|
||||
|
||||
## Interaction and reuse
|
||||
|
||||
Drag the handle to move either a module or separator before/after another row.
|
||||
The handle also supports Space to pick up, arrow keys to move, Enter to drop,
|
||||
and Escape to restore the pre-drag draft. Up/down buttons offer the same moves.
|
||||
Add module restores an available hidden entry; Remove only hides navigation,
|
||||
never uninstalls a module or deletes records. Add separator inserts a new
|
||||
optional group label. Remove separator changes grouping only.
|
||||
|
||||
Expanded rails display group labels without divider lines. Collapsed rails
|
||||
replace these labels with horizontal group dividers; empty groups are not
|
||||
rendered. Both modes use the same resolved order.
|
||||
The editor receives product-area metadata to show inherited grouping and uses
|
||||
container-responsive rows rather than a fixed dialog/page width. Its English
|
||||
and German labels load with the editor, not the initial shell bundle.
|
||||
Give the ordered list a full-span `GridItem` when a settings page contains
|
||||
multiple cards; do not squeeze the entire editor into an otherwise half-empty
|
||||
two-column settings grid. Central spacing tokens provide real row padding and
|
||||
separation at both wide and narrow sizes, covered by computed-style assertions.
|
||||
|
||||
## Verification
|
||||
|
||||
Core navigation unit tests and HTTP settings/profile tests cover persistence,
|
||||
separator inheritance, explicit flat layouts, reset and locks. Module-capability
|
||||
tests cover View aliases, composed destinations and personal precedence. The
|
||||
browser conformance suite tests all four editor scopes, pointer/keyboard moves,
|
||||
no-op cleanliness, unavailable-module preservation, collapsed dividers and a
|
||||
German narrow read-only layout. Use the same shared component for future
|
||||
navigation-definition surfaces rather than implementing another sortable list.
|
||||
@@ -69,11 +69,61 @@ page or pane action bar.
|
||||
|
||||
| Page kind | Leading group | Trailing group |
|
||||
| --- | --- | --- |
|
||||
| Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
|
||||
| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
|
||||
| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group |
|
||||
| Overview | Context | Help, Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
|
||||
Reload and Create belong to the same right-aligned group, in that order. A
|
||||
collection-wide toolbar stays above its workspace, not inside the left tree or
|
||||
conditionally inside an editor. Changing selection or opening an editor must
|
||||
not remove it. Permission-blocked creation remains visible with an explanation.
|
||||
On narrow screens the trailing group wraps while retaining right alignment and
|
||||
the same DOM/keyboard order.
|
||||
|
||||
Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by
|
||||
`LoadingFrame`. This removes body padding explicitly, without relying on the
|
||||
number of children or negative margins. Place any meaningful explanation or
|
||||
warning in a padded `ContentSection`; do not add a redundant tagline to every
|
||||
table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments.
|
||||
|
||||
Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the
|
||||
same checkbox body and Select all / Deselect all behavior. `null` means no
|
||||
restriction, `[]` means no matches, and multiple values mean OR within a facet.
|
||||
Apply remote filters before server pagination/limits and discard stale reads.
|
||||
Do not replace this with rows of toggles or implement a second checkbox menu.
|
||||
The dropdown's body portal escapes clipped containers. Inside Core dialogs it
|
||||
joins the existing dialog stack: Tab/Shift+Tab stay in the filter, Space toggles
|
||||
the focused checkbox, and Escape closes only the filter and restores its
|
||||
trigger. Long option labels wrap without widening the popup.
|
||||
|
||||
Keep facet definitions, URL serialization and request cancellation in one owning
|
||||
module adapter when the same search appears on a page and in an overlay. Do not
|
||||
translate an explicit empty selection into an unrestricted backend query. Keep
|
||||
legacy API meanings at the adapter boundary; retain scope and unrelated URL
|
||||
parameters when clearing filters. A query, context, account or tenant change
|
||||
invalidates both initial and cursor requests, including results still visible
|
||||
during a debounce interval.
|
||||
|
||||
Explorer workspaces keep collection commands in a persistent header. Files
|
||||
uses Reload, Create folder, then primary Upload; frequent selected-item actions
|
||||
stay near the list. Group less-common selection and connection operations in
|
||||
labelled domain dialogs using `Dialog`, `FormSection` and shared action bars,
|
||||
with an explicit destructive section. Do not move an overloaded toolbar into
|
||||
another ungrouped row. Mail's read-only workspace has one Reload for its current
|
||||
profile, folder, index and preview; narrower refreshes belong in Mailbox tools.
|
||||
Do not invent a New or Save button on a workspace that owns neither workflow.
|
||||
Reload must not become import, synchronization, delivery or another mutation.
|
||||
Explicit Reload reads must bypass short-lived client response reuse (for
|
||||
example, pass `cache: "no-store"` through the owning read API), including each
|
||||
page of a refreshed listing. Routine navigation may retain normal deduplication.
|
||||
Conformance must observe a fresh request, not just an enabled Reload button.
|
||||
|
||||
Tree icons/disclosure controls expand and collapse; labels select. A module's
|
||||
`ExplorerTree.onOpen` must not toggle expansion. Use occurrence-specific node
|
||||
IDs when the same semantic record appears in multiple branches; selection and
|
||||
ancestor expansion must follow the clicked occurrence, not every copy.
|
||||
|
||||
Reload means re-fetch or re-evaluate the current surface. A page declaring
|
||||
`refreshable` must provide it, and a non-refreshable page must not use Reload as
|
||||
|
||||
@@ -14,6 +14,7 @@ consistent while each module still owns its domain rules.
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -126,6 +127,22 @@ When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Function Assignment Delegation And Escalation
|
||||
|
||||
`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract
|
||||
for request/grant review. In addition to the required holder, authority, and
|
||||
recipient steps, it returns `delegation_allowed`,
|
||||
`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed
|
||||
`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one
|
||||
review step to an exact target function and timeout.
|
||||
|
||||
The decision is a current ceiling, not durable authorization. IDM must recheck
|
||||
the complete assignment-source chain and all recorded decisions before final
|
||||
application. An elapsed timeout creates explicit state and evidence; it must
|
||||
never be interpreted as approval or as permission to silently substitute an
|
||||
approver. Missing providers, malformed rules, invalid chains, or tightened
|
||||
limits fail closed with an explainable reason.
|
||||
|
||||
## Bounded Impact-Subject Providers
|
||||
|
||||
Policy impact previews discover optional subject providers through capability
|
||||
|
||||
@@ -95,6 +95,15 @@ package entry. Keep one committed full-product release lockfile at
|
||||
`webui/package.release.json` in a clean release workspace. Development
|
||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||
|
||||
The default WebUI build discovers every declared, installed module package,
|
||||
including Tasks and its Work page, dashboard widget, and Quick Access tool.
|
||||
Discovery still respects enabled backend modules and user permissions; adding
|
||||
a package never grants access. `GOVOPLAN_WEBUI_MODULE_PACKAGES` selects an
|
||||
explicit smaller build when set, and an explicitly empty value selects
|
||||
core-only. The prebuild interface check compares the default descriptor list
|
||||
with the package manifest so explicit permutation tests cannot conceal a
|
||||
module accidentally omitted from the ordinary build.
|
||||
|
||||
Frontend module permutations are regression-tested through
|
||||
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
||||
committed lockfiles for every possible combination. If a smaller composition
|
||||
|
||||
@@ -54,3 +54,9 @@ but their surrounding controls must still use the shared tokens.
|
||||
custom-override validation/application, and representative
|
||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||
production WebUI build.
|
||||
|
||||
Runtime validation and token application live in the dependency-free
|
||||
`webui/src/components/appearanceOverrides.ts`; both the shell and the shared
|
||||
editor use it. The shell must not import the editor to apply an existing theme:
|
||||
settings controls load with their route, while valid saved colors apply
|
||||
synchronously and invalid documents still fail closed before any token is set.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Ticket Integration Capability Contracts
|
||||
|
||||
Core owns two narrow, optional contracts that let the Tickets module compose
|
||||
with policy and formal-procedure modules without importing either one. Tickets
|
||||
remains the authority for operational ticket identity, lifecycle, assignment,
|
||||
comments, links, and immutable history.
|
||||
|
||||
## Capability Names
|
||||
|
||||
- `tickets.routing` optionally supplies a `TicketRoutingProvider`.
|
||||
- `tickets.case_escalation` optionally supplies a
|
||||
`TicketCaseEscalationProvider`.
|
||||
|
||||
Both contracts are version 1 and are defined in
|
||||
`govoplan_core.core.tickets`. Registry helpers return `None` when a capability
|
||||
is absent or has the wrong shape, so optional-module absence is normal runtime
|
||||
state rather than a startup failure.
|
||||
|
||||
## Routing
|
||||
|
||||
Tickets sends a bounded, tenant-scoped `TicketRoutingRequest` containing the
|
||||
ticket reference, type, priority, title, receive time, optional queue hint, and
|
||||
non-secret attributes. The provider returns its identity and may return a queue
|
||||
reference, timezone-aware service target, human-readable explanation, and
|
||||
bounded metadata.
|
||||
|
||||
The provider is advisory. Tickets snapshots any returned queue and target into
|
||||
its own record and history. An absent provider, a no-match plan, or an absent
|
||||
queue must not prevent ticket intake; authorized staff can route manually.
|
||||
Providers must not persist a second ticket lifecycle.
|
||||
|
||||
## Case Escalation
|
||||
|
||||
Tickets sends a `TicketCaseEscalationCommand` with stable tenant, ticket, and
|
||||
display references, the requested Case type, actor-visible handoff note,
|
||||
timezone-aware occurrence time, and an idempotency key. The provider returns a
|
||||
stable Case identifier, number, bounded application-relative URL, replay flag,
|
||||
and bounded metadata.
|
||||
|
||||
Providers must:
|
||||
|
||||
- recheck tenant and Case-creation authorization;
|
||||
- reject an absent or inactive requested Case type;
|
||||
- make identical retries resolve the same Case;
|
||||
- preserve the Ticket reference in governed Case context; and
|
||||
- return only an application-relative path, never an untrusted external URL.
|
||||
|
||||
Tickets records the result and its own escalation evidence. Cases remains the
|
||||
authority for the formal procedure; Tickets remains the authority for the
|
||||
operational request. Creating a Case does not merge or silently close either
|
||||
lifecycle.
|
||||
|
||||
## Failure And Transaction Semantics
|
||||
|
||||
Capability calls receive the caller's active persistence session so a concrete
|
||||
provider can participate in the same unit of work. Authorization and validation
|
||||
errors fail the requested routing/escalation mutation explicitly. The caller
|
||||
must still apply its own permission checks, tenant boundary, replay protection,
|
||||
and immutable evidence rules.
|
||||
@@ -57,7 +57,7 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
|
||||
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
|
||||
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs |
|
||||
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: refreshable pages provide Reload in the right-aligned trailing group immediately before Create/primary actions; collections keep Create far right; read-only pages do not invent Save. The trailing placement supersedes the earlier leading-Reload rule (2026-09-07, Core #295). | Accepted | Core and all module WebUIs |
|
||||
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
applying `ui-hide-help-hints` at the document root. When shown, the shared
|
||||
marker is a labelled, keyboard-focusable help control and exposes its tooltip
|
||||
on focus as well as pointer hover.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
|
||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
||||
|
||||
## Verification
|
||||
|
||||
Development startup explicitly prebundles the Excel reader's browser/universal
|
||||
entrypoints and the lazy rich-text editor's Tiptap dependencies. These are
|
||||
Core-installed vendor dependencies, not eager optional-module imports. This
|
||||
avoids first-time Campaign/Template navigation triggering a second dependency
|
||||
optimization and page reload. Module descriptors and pages remain lazy, and
|
||||
production bundle budgets remain unchanged. The Core interface-pattern check
|
||||
verifies this include list and keeps optional GovOPlaN modules excluded.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
@@ -69,3 +77,33 @@ npm run test:module-permutations
|
||||
The build gate also catches accidental eager imports: a page pulled into the
|
||||
entry closure consumes the initial budget, while an oversized page or module
|
||||
descriptor consumes the asynchronous chunk budget.
|
||||
|
||||
The startup shell imports appearance validation/application from the pure
|
||||
`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export,
|
||||
and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy
|
||||
Settings route. Importing a runtime helper from a module that also owns editor
|
||||
components can accidentally pull the entire editor into the startup chunk.
|
||||
Public helper exports remain compatible; theme application is still synchronous.
|
||||
The versioned default color document and its deep-clone helper live in
|
||||
`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides
|
||||
does not load editor defaults or construct a draft. The default values and public
|
||||
helper names are unchanged; the theme regression checks independent draft clones
|
||||
as well as synchronous validation, application, and reset.
|
||||
|
||||
`PasswordField` keeps ordinary input and reveal controls synchronous. Its
|
||||
optional `PasswordGeneratorDialog` is imported only after an enabled, editable
|
||||
generator is explicitly opened, not for every sign-in/password field. Loading
|
||||
and failures use the shared resource boundary; the underlying field remains
|
||||
usable. Closing or revoking generation while loading cannot apply a candidate.
|
||||
The secure browser RNG, generation policy, public exports, and explicit
|
||||
"Use password" confirmation remain unchanged. The isolated browser fixture
|
||||
does not import the Core barrel, so it can verify that the generator is not
|
||||
requested before opening it, along with cancel/use and focus restoration.
|
||||
|
||||
Deutsch: Normale Passworteingabe und Sichtbarkeitssteuerung bleiben unmittelbar
|
||||
verfügbar. Der optionale Generator wird erst beim bewussten Öffnen eines
|
||||
aktivierten, bearbeitbaren Felds geladen; Lade- und Fehlerzustände nutzen die
|
||||
gemeinsame Ressourcenanzeige. Ohne "Passwort verwenden" wird kein Kandidat
|
||||
übernommen. Sichere Browser-Zufallszahlen, Richtlinien und öffentliche
|
||||
Schnittstellen bleiben unverändert. Wird die Generierung während des Ladens
|
||||
deaktiviert, öffnet eine verspätete Antwort keinen Dialog.
|
||||
|
||||
@@ -2271,6 +2271,503 @@
|
||||
"release": "0.1.18",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revision": "7c4d9a2e1f30"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revision": "8a6c4e2f1b3d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revision": "8d1f4b7a2c5e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "8d5a2f7c1b4e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revision": "9a4e6c2d8f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revision": "9e2a5c8f1b4d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e6f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e6f9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "a3d7f1c5e9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "a4c5d6e7f809"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revision": "a7c2e9f4b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "a7c4e2f9b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revision": "b3d8e1f4a6c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "b9e2f5a8c3d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "c0f1a2b3c4d5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "c2d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "c58a2d7e9f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "c6f2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "d1a7c3e9f5b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revision": "d3e7a1c5f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "d6a8b0c2e4f6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "d6e8f9a0b1c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "d7a4c1e8f205"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8b4f1a6c9e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "d8f1b4e7a0c3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revision": "e7b9c1d3f5a7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "f3c7a9d2e6b1"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"d8f1b4e7a0c3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"d6e8f9a0b1c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"b9e2f5a8c3d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"f3c7a9d2e6b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"a7c4e2f9b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"c0f1a2b3c4d5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"c58a2d7e9f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"a3d7f1c5e9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"d1a7c3e9f5b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revisions": [
|
||||
"d3e7a1c5f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a2b3c4d5e6f8",
|
||||
"a2b3c4d5e6f9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"d6a8b0c2e4f6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revisions": [
|
||||
"9e2a5c8f1b4d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"c2d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"a4c5d6e7f809"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revisions": [
|
||||
"e7b9c1d3f5a7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8b4f1a6c9e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revisions": [
|
||||
"9a4e6c2d8f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revisions": [
|
||||
"8a6c4e2f1b3d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"d7a4c1e8f205"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revisions": [
|
||||
"7c4d9a2e1f30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revisions": [
|
||||
"b3d8e1f4a6c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revisions": [
|
||||
"8d1f4b7a2c5e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"c6f2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revisions": [
|
||||
"a7c2e9f4b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"8d5a2f7c1b4e"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-09-07T23:29:20Z",
|
||||
"release": "0.1.45",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.27"
|
||||
version = "0.1.45"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class NavigationSeparatorPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255)
|
||||
label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$")
|
||||
|
||||
|
||||
class NavigationPreferencesPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel):
|
||||
order: list[str] = Field(default_factory=list, max_length=256)
|
||||
hidden: list[str] = Field(default_factory=list, max_length=256)
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
|
||||
@@ -780,8 +780,9 @@ def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
|
||||
SMTP failures are persisted but are not retried implicitly. A worker-loss
|
||||
redelivery is safe because the delivery service converts an unfinished
|
||||
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
|
||||
redelivery leaves an active delivery claim unchanged instead of transmitting
|
||||
again. Explicit fenced recovery requires stopped-owner evidence before an
|
||||
abandoned attempt can become ``outcome_unknown`` for reconciliation.
|
||||
"""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
||||
@@ -12,6 +12,20 @@ CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
||||
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
|
||||
|
||||
CampaignWorkAssigneeKind = Literal[
|
||||
"account",
|
||||
"group",
|
||||
"organization_function",
|
||||
]
|
||||
CampaignWorkHandoffStatus = Literal[
|
||||
"open",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -32,6 +46,88 @@ class CampaignPolicyContext:
|
||||
settings: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRequest:
|
||||
"""Typed request used by Workflow to open accountable Campaign work."""
|
||||
|
||||
tenant_id: str
|
||||
idempotency_key: str
|
||||
purpose: str
|
||||
assignee_kind: CampaignWorkAssigneeKind
|
||||
assignee_id: str
|
||||
campaign_id: str | None = None
|
||||
create_external_id: str | None = None
|
||||
create_name: str | None = None
|
||||
create_description: str | None = None
|
||||
expected_campaign_revision: int | None = None
|
||||
due_at: datetime | None = None
|
||||
mirror_to_tasks: bool = True
|
||||
correlation_id: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_step_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, label in (
|
||||
(self.tenant_id, "Campaign hand-off tenant"),
|
||||
(self.idempotency_key, "Campaign hand-off idempotency key"),
|
||||
(self.purpose, "Campaign hand-off purpose"),
|
||||
(self.assignee_id, "Campaign hand-off assignee"),
|
||||
):
|
||||
if not value.strip():
|
||||
raise ValueError(f"{label} is required")
|
||||
references_existing = bool(self.campaign_id and self.campaign_id.strip())
|
||||
creates_new = bool(
|
||||
self.create_external_id
|
||||
and self.create_external_id.strip()
|
||||
and self.create_name
|
||||
and self.create_name.strip()
|
||||
)
|
||||
if references_existing == creates_new:
|
||||
raise ValueError(
|
||||
"Campaign hand-offs must either reference one campaign or "
|
||||
"declare one new campaign."
|
||||
)
|
||||
if self.expected_campaign_revision is not None and (
|
||||
self.expected_campaign_revision < 1
|
||||
):
|
||||
raise ValueError("Expected Campaign revisions start at one")
|
||||
if self.due_at is not None and self.due_at.tzinfo is None:
|
||||
raise ValueError("Campaign hand-off due dates require a timezone")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRef:
|
||||
"""Stable, revision-bearing reference returned to the Workflow instance."""
|
||||
|
||||
tenant_id: str
|
||||
campaign_id: str
|
||||
campaign_version_id: str
|
||||
campaign_revision: int
|
||||
assignment_id: str
|
||||
assignment_revision: int
|
||||
status: CampaignWorkHandoffStatus
|
||||
action_url: str
|
||||
campaign_ref: str
|
||||
assignment_ref: str
|
||||
event_type: str = "campaign.work.changed"
|
||||
replayed: bool = False
|
||||
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffInspection:
|
||||
"""Current authorization and revision check before Workflow continuation."""
|
||||
|
||||
allowed: bool
|
||||
status: CampaignWorkHandoffStatus | None = None
|
||||
assignment_revision: int | None = None
|
||||
action_url: str | None = None
|
||||
assignment_ref: str | None = None
|
||||
reason: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignMailPolicyContextProvider(Protocol):
|
||||
def get_campaign_mail_policy_context(
|
||||
@@ -132,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignWorkOrchestrationProvider(Protocol):
|
||||
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
|
||||
|
||||
def prepare_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
...
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> CampaignWorkHandoffInspection:
|
||||
...
|
||||
|
||||
|
||||
def campaign_work_orchestration_provider(
|
||||
registry: object | None,
|
||||
) -> CampaignWorkOrchestrationProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CampaignWorkOrchestrationProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def _sanitize_value(key: str, value: object) -> object:
|
||||
if key == "campaign_archive_encryption_policy":
|
||||
if not isinstance(value, dict):
|
||||
return "<redacted>"
|
||||
# These are format/channel names, never passwords. Preserve only the
|
||||
# exact public enum lists so rollback history remains useful without
|
||||
# exempting arbitrary password-named fields from secret redaction.
|
||||
allowed_values = {
|
||||
"allowed_password_encryption_methods": frozenset({"aes", "zip_standard"}),
|
||||
"allowed_password_delivery_channels": frozenset({"separate_mail", "sms", "letter", "phone", "in_person"}),
|
||||
}
|
||||
return {
|
||||
name: list(items)
|
||||
if name in allowed_values
|
||||
and isinstance(items, list)
|
||||
and all(isinstance(item, str) and item in allowed_values[name] for item in items)
|
||||
else "<redacted>"
|
||||
for name, items in value.items()
|
||||
}
|
||||
field = classify_configuration_field(key)
|
||||
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
||||
return _redact_secrets(value)
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationRollbackStatus = Literal[
|
||||
"blocked_before_apply",
|
||||
"not_required",
|
||||
"database_restore_required",
|
||||
"partial_apply_requires_recovery",
|
||||
]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
@@ -461,6 +469,21 @@ class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
rollback: "ConfigurationRollbackState | None" = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRollbackState:
|
||||
status: ConfigurationRollbackStatus
|
||||
summary: str
|
||||
recovery_action: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"recovery_action": self.recovery_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -471,11 +494,40 @@ class ConfigurationExportSelection:
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportProvenance:
|
||||
exported_at: str
|
||||
source_core_version: str
|
||||
module_versions: Mapping[str, str]
|
||||
tenant_id: str | None
|
||||
exporter_id: str | None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
redacted_secret_keys: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"exported_at": self.exported_at,
|
||||
"source_core_version": self.source_core_version,
|
||||
"module_versions": dict(self.module_versions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"exporter_id": self.exporter_id,
|
||||
"selection": {
|
||||
"scopes": list(self.scopes),
|
||||
"module_ids": list(self.module_ids),
|
||||
"object_refs": list(self.object_refs),
|
||||
},
|
||||
"redacted_secret_keys": list(self.redacted_secret_keys),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
provenance: ConfigurationExportProvenance | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -508,6 +560,7 @@ def dry_run_configuration_package(
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
declared_data: dict[str, ConfigurationRequiredData] = {}
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
@@ -515,6 +568,7 @@ def dry_run_configuration_package(
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
declared_data[requirement.key] = requirement
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -525,6 +579,25 @@ def dry_run_configuration_package(
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
data_ref_diagnostics = _fragment_data_reference_diagnostics(
|
||||
fragment,
|
||||
declared_data=declared_data,
|
||||
supplied_data=context.supplied_data,
|
||||
)
|
||||
if data_ref_diagnostics:
|
||||
diagnostics.extend(data_ref_diagnostics)
|
||||
plan.append(ConfigurationPlanItem(
|
||||
action="blocked",
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
summary="Fragment needs declared deployment data before provider preflight.",
|
||||
))
|
||||
continue
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
context.supplied_data,
|
||||
)
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
@@ -550,7 +623,7 @@ def dry_run_configuration_package(
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
result = provider.preflight(resolved_fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -605,19 +678,41 @@ def apply_configuration_package(
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(blockers),
|
||||
rollback=ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="No provider changes were attempted because package preflight is blocked.",
|
||||
),
|
||||
)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
stopped_after_blocker = False
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
apply_context.supplied_data,
|
||||
)
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
result = provider.apply(
|
||||
resolved_fragment,
|
||||
apply_context.supplied_data,
|
||||
apply_context,
|
||||
)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
health_diagnostics = provider.health(result, apply_context)
|
||||
diagnostics.extend(health_diagnostics)
|
||||
if any(
|
||||
item.severity == "blocker"
|
||||
for item in (*result.diagnostics, *health_diagnostics)
|
||||
):
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -627,10 +722,36 @@ def apply_configuration_package(
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
changed = bool(created_refs or updated_refs)
|
||||
if stopped_after_blocker and changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="partial_apply_requires_recovery",
|
||||
summary="At least one provider committed changes before a later provider blocked the package.",
|
||||
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
|
||||
)
|
||||
elif stopped_after_blocker:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="The first provider blocked before any configuration reference was created or updated.",
|
||||
)
|
||||
elif changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="database_restore_required",
|
||||
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
|
||||
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
|
||||
)
|
||||
else:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="not_required",
|
||||
summary="All package fragments were no-ops, so no rollback action is required.",
|
||||
)
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
|
||||
@@ -669,10 +790,29 @@ def export_configuration_package(
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
|
||||
provenance = ConfigurationExportProvenance(
|
||||
exported_at=datetime.now(UTC).isoformat(),
|
||||
source_core_version=_installed_core_version(),
|
||||
module_versions={
|
||||
module_id: context.installed_modules[module_id]
|
||||
for module_id in sorted(set(module_ids))
|
||||
if module_id in context.installed_modules
|
||||
},
|
||||
tenant_id=selection.tenant_id,
|
||||
exporter_id=context.operator_user_id,
|
||||
scopes=selection.scopes,
|
||||
module_ids=tuple(module_ids),
|
||||
object_refs=selection.object_refs,
|
||||
redacted_secret_keys=tuple(
|
||||
sorted(item.key for item in deduped_required_data if item.secret)
|
||||
),
|
||||
)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
data_requirements=deduped_required_data,
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
|
||||
return result
|
||||
|
||||
|
||||
def _fragment_data_reference_diagnostics(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
*,
|
||||
declared_data: Mapping[str, ConfigurationRequiredData],
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
references: set[str] = set()
|
||||
invalid = _collect_fragment_data_references(fragment.payload, references)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
object_ref = fragment.fragment_id or fragment.fragment_type
|
||||
if invalid:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_invalid",
|
||||
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
|
||||
))
|
||||
for key in sorted(references - set(declared_data)):
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_undeclared",
|
||||
message=f"Configuration fragment references undeclared operator data {key!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Declare the key in package data_requirements before using it in a fragment.",
|
||||
))
|
||||
for key in sorted(references & set(declared_data)):
|
||||
if key in supplied_data:
|
||||
continue
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_missing",
|
||||
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Provide the value in the generated configuration package form.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
|
||||
invalid = False
|
||||
if isinstance(value, Mapping):
|
||||
if "$data" in value:
|
||||
key = value.get("$data")
|
||||
if len(value) != 1 or not isinstance(key, str) or not key.strip():
|
||||
return True
|
||||
references.add(key.strip())
|
||||
return False
|
||||
for item in value.values():
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for item in value:
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
return invalid
|
||||
|
||||
|
||||
def _resolve_fragment_data_references(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> ConfigurationPackageFragment:
|
||||
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("Resolved configuration fragment payload must remain an object.")
|
||||
return ConfigurationPackageFragment(
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if set(value) == {"$data"}:
|
||||
key = value.get("$data")
|
||||
if not isinstance(key, str) or key not in supplied_data:
|
||||
raise ValueError("Configuration fragment contains an unresolved $data reference.")
|
||||
return supplied_data[key]
|
||||
return {
|
||||
str(key): _resolve_data_reference_value(item, supplied_data)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_resolve_data_reference_value(item, supplied_data) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _installed_core_version() -> str:
|
||||
try:
|
||||
return package_version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "workspace"
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
|
||||
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("system:settings:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="A bounded 0–500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("admin:policies:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
label="Enabled modules",
|
||||
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
rollback_history_required=True,
|
||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_archive_encryption_policy",
|
||||
label="Campaign archive encryption policy",
|
||||
owner_module="policy",
|
||||
scope="system",
|
||||
storage="policy_overrides",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write", "admin:policies:write"),
|
||||
validation_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="campaign_archive_encryption_policy.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="privacy_retention_policy",
|
||||
label="Privacy retention policy",
|
||||
|
||||
@@ -101,6 +101,8 @@ class DatasourceGovernance:
|
||||
transfer_agreement_ref: str | None = None
|
||||
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
approval_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
retention_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
known_limits: tuple[str, ...] = ()
|
||||
correction_procedure_ref: str | None = None
|
||||
affected_refs: tuple[str, ...] = ()
|
||||
@@ -179,6 +181,8 @@ class DatasourceGovernance:
|
||||
),
|
||||
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||
approval_policy=_governance_mapping(source.get("approval_policy")),
|
||||
retention_policy=_governance_mapping(source.get("retention_policy")),
|
||||
known_limits=_governance_texts(source.get("known_limits")),
|
||||
correction_procedure_ref=_optional_governance_text(
|
||||
source.get("correction_procedure_ref")
|
||||
@@ -210,6 +214,8 @@ class DatasourceGovernance:
|
||||
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||
"freshness_policy": dict(self.freshness_policy),
|
||||
"quality_policy": dict(self.quality_policy),
|
||||
"approval_policy": dict(self.approval_policy),
|
||||
"retention_policy": dict(self.retention_policy),
|
||||
"known_limits": list(self.known_limits),
|
||||
"correction_procedure_ref": self.correction_procedure_ref,
|
||||
"affected_refs": list(self.affected_refs),
|
||||
@@ -289,6 +295,8 @@ class DatasourceMaterialization:
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
disposed_at: datetime | None = None
|
||||
disposition: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
@@ -309,6 +317,7 @@ class DatasourceStage:
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
approval: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
|
||||
"infrastructure.dependency_inventory."
|
||||
)
|
||||
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||
CAPABILITY_STATES = frozenset(
|
||||
{
|
||||
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
|
||||
}
|
||||
)
|
||||
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_DEPENDENCY_STATES = frozenset(
|
||||
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
|
||||
)
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependency:
|
||||
"""A non-secret module-owned dependency on deployment infrastructure."""
|
||||
|
||||
capability_id: str
|
||||
module_id: str
|
||||
dependency_type: str
|
||||
dependency_ref: str
|
||||
state: str
|
||||
scope: str
|
||||
summary: str
|
||||
metrics: Mapping[str, int]
|
||||
required_action: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name, value, maximum in (
|
||||
("capability_id", self.capability_id, 120),
|
||||
("module_id", self.module_id, 120),
|
||||
("dependency_type", self.dependency_type, 120),
|
||||
("dependency_ref", self.dependency_ref, 240),
|
||||
("scope", self.scope, 120),
|
||||
("summary", self.summary, 1000),
|
||||
("required_action", self.required_action, 1000),
|
||||
):
|
||||
if (
|
||||
not value.strip()
|
||||
or len(value) > maximum
|
||||
or any(ord(char) < 32 for char in value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency {field_name} is invalid."
|
||||
)
|
||||
if self.state not in _DEPENDENCY_STATES:
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency state is unsupported: {self.state!r}."
|
||||
)
|
||||
if len(self.metrics) > 20 or any(
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or len(key) > 80
|
||||
or any(ord(char) < 32 for char in key)
|
||||
or type(value) is not int
|
||||
or value < 0
|
||||
for key, value in self.metrics.items()
|
||||
):
|
||||
raise ValueError("Infrastructure dependency metrics are invalid.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"capability_id": self.capability_id,
|
||||
"module_id": self.module_id,
|
||||
"dependency_type": self.dependency_type,
|
||||
"dependency_ref": self.dependency_ref,
|
||||
"state": self.state,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"metrics": dict(sorted(self.metrics.items())),
|
||||
"required_action": self.required_action,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InfrastructureDependencyProvider(Protocol):
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyProviderReport:
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
state: str
|
||||
dependency_count: int
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"capability_ids": list(self.capability_ids),
|
||||
"state": self.state,
|
||||
"dependency_count": self.dependency_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyInventory:
|
||||
installation_id: str
|
||||
generated_at: str
|
||||
complete: bool
|
||||
inspected_capability_ids: tuple[str, ...]
|
||||
providers: tuple[InfrastructureDependencyProviderReport, ...]
|
||||
dependencies: tuple[InfrastructureDependency, ...]
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"installation_id": self.installation_id,
|
||||
"generated_at": self.generated_at,
|
||||
"complete": self.complete,
|
||||
"inspected_capability_ids": list(self.inspected_capability_ids),
|
||||
"providers": [item.to_dict() for item in self.providers],
|
||||
"dependencies": [item.to_dict() for item in self.dependencies],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureCapability:
|
||||
id: str
|
||||
@@ -214,6 +331,134 @@ def deployment_capability_status(
|
||||
}
|
||||
|
||||
|
||||
def collect_infrastructure_dependency_inventory(
|
||||
registry: object,
|
||||
*,
|
||||
installation_id: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> InfrastructureDependencyInventory:
|
||||
"""Collect actual module-owned dependencies without importing module internals."""
|
||||
|
||||
normalized_installation_id = installation_id.strip()
|
||||
if not normalized_installation_id or len(normalized_installation_id) > 100:
|
||||
raise ValueError("Infrastructure dependency installation id is invalid.")
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Infrastructure dependency inventory requires a module registry.")
|
||||
|
||||
provider_names = tuple(
|
||||
name
|
||||
for name in capability_names()
|
||||
if isinstance(name, str)
|
||||
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
|
||||
)
|
||||
reports: list[InfrastructureDependencyProviderReport] = []
|
||||
dependencies: list[InfrastructureDependency] = []
|
||||
inspected_capability_ids: set[str] = set()
|
||||
complete = True
|
||||
|
||||
for provider_name in sorted(provider_names):
|
||||
expected_module_id = provider_name.removeprefix(
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
module_id = expected_module_id or "unknown"
|
||||
declared_ids: tuple[str, ...] = ()
|
||||
try:
|
||||
provider = capability(provider_name)
|
||||
if not isinstance(provider, InfrastructureDependencyProvider):
|
||||
raise TypeError("provider does not implement the inventory contract")
|
||||
module_id = provider.module_id.strip()
|
||||
declared_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
item.strip()
|
||||
for item in provider.capability_ids
|
||||
if isinstance(item, str) and item.strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if (
|
||||
module_id != expected_module_id
|
||||
or len(module_id) > 120
|
||||
or any(ord(char) < 32 for char in module_id)
|
||||
or not declared_ids
|
||||
or len(declared_ids) > 30
|
||||
or any(
|
||||
len(item) > 120 or any(ord(char) < 32 for char in item)
|
||||
for item in declared_ids
|
||||
)
|
||||
):
|
||||
raise ValueError("provider identity or capability declaration is invalid")
|
||||
provider_dependencies = tuple(provider.infrastructure_dependencies())
|
||||
if len(provider_dependencies) > 10_000:
|
||||
raise ValueError("provider dependency inventory is too large")
|
||||
seen_refs: set[tuple[str, str, str]] = set()
|
||||
for item in provider_dependencies:
|
||||
if not isinstance(item, InfrastructureDependency):
|
||||
raise TypeError("provider returned an invalid dependency")
|
||||
if item.module_id != module_id or item.capability_id not in declared_ids:
|
||||
raise ValueError("provider returned a dependency outside its declaration")
|
||||
identity = (
|
||||
item.capability_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
)
|
||||
if identity in seen_refs:
|
||||
raise ValueError("provider returned a duplicate dependency")
|
||||
seen_refs.add(identity)
|
||||
if len(dependencies) + len(provider_dependencies) > 10_000:
|
||||
raise ValueError("combined dependency inventory is too large")
|
||||
dependencies.extend(provider_dependencies)
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="complete",
|
||||
dependency_count=len(provider_dependencies),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="error",
|
||||
dependency_count=0,
|
||||
error=(
|
||||
f"{type(exc).__name__}: provider inventory could not be completed"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return InfrastructureDependencyInventory(
|
||||
installation_id=normalized_installation_id,
|
||||
generated_at=timestamp.astimezone(UTC).isoformat(),
|
||||
complete=complete,
|
||||
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
|
||||
providers=tuple(
|
||||
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
|
||||
),
|
||||
dependencies=tuple(
|
||||
sorted(
|
||||
dependencies,
|
||||
key=lambda item: (
|
||||
item.capability_id,
|
||||
item.module_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _capability(value: object) -> InfrastructureCapability:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
|
||||
__all__ = [
|
||||
"CAPABILITY_STATES",
|
||||
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
|
||||
"InfrastructureCapability",
|
||||
"InfrastructureCapabilityReceipt",
|
||||
"InfrastructureCapabilityReceiptError",
|
||||
"InfrastructureDependency",
|
||||
"InfrastructureDependencyInventory",
|
||||
"InfrastructureDependencyProvider",
|
||||
"InfrastructureDependencyProviderReport",
|
||||
"InfrastructurePostInstallTask",
|
||||
"collect_infrastructure_dependency_inventory",
|
||||
"deployment_capability_status",
|
||||
"infrastructure_capability_receipt_from_mapping",
|
||||
"load_infrastructure_capability_receipt",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
@@ -114,6 +115,55 @@ class ProductAreaContribution:
|
||||
order: int = 100
|
||||
|
||||
|
||||
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
|
||||
ProductAvailabilityReason = Literal[
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAvailabilityExplanation:
|
||||
"""Explain a product outcome without making package topology user-facing."""
|
||||
|
||||
reason: ProductAvailabilityReason
|
||||
title: str
|
||||
description: str
|
||||
resolution: str
|
||||
responsible_role: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductSurfaceContribution:
|
||||
"""Bind an owner route to a stable, cross-module product identity."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
entry_path: str
|
||||
route_path: str
|
||||
surface_ids: tuple[str, ...]
|
||||
unavailable: ProductAvailabilityExplanation
|
||||
description: str | None = None
|
||||
degraded: ProductAvailabilityExplanation | None = None
|
||||
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
|
||||
capability_ids: tuple[str, ...] = ()
|
||||
search_source_ids: tuple[str, ...] = ()
|
||||
help_context_ids: tuple[str, ...] = ()
|
||||
documentation_topic_ids: tuple[str, ...] = ()
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
aliases: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuickAccessTool:
|
||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||
@@ -153,6 +203,7 @@ class FrontendModule:
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||
|
||||
|
||||
@@ -289,6 +340,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
|
||||
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
|
||||
{
|
||||
"admin_explanation",
|
||||
"consequence_classes",
|
||||
"consequences",
|
||||
"constraints",
|
||||
"current_configuration",
|
||||
"fields",
|
||||
"limitations",
|
||||
"operational_consequences",
|
||||
"outcome",
|
||||
"prerequisites",
|
||||
"privacy_notes",
|
||||
"purpose",
|
||||
"result",
|
||||
"steps",
|
||||
"user_explanation",
|
||||
"verification",
|
||||
"when_used",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationLink:
|
||||
label: str
|
||||
@@ -324,12 +399,142 @@ class DocumentationTopic:
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
structured_translation_version: str | None = None
|
||||
structured_translations: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
source_module_id: str | None = None
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return structured metadata keys whose values are public prose."""
|
||||
|
||||
return tuple(
|
||||
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
|
||||
)
|
||||
|
||||
|
||||
def localized_documentation_metadata(
|
||||
topic: DocumentationTopic,
|
||||
locale: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Overlay one validated structured translation onto source metadata."""
|
||||
|
||||
localized = dict(topic.metadata)
|
||||
translation = topic.structured_translations.get(locale)
|
||||
if translation:
|
||||
localized.update(translation)
|
||||
return localized
|
||||
|
||||
|
||||
def documentation_structured_translation_issues(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Validate the opt-in, versioned structured-documentation translation."""
|
||||
|
||||
version = topic.structured_translation_version
|
||||
translations = topic.structured_translations
|
||||
if version is None:
|
||||
if translations:
|
||||
return (
|
||||
"structured_translations require structured_translation_version",
|
||||
)
|
||||
return ()
|
||||
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
|
||||
return (
|
||||
"unsupported structured_translation_version "
|
||||
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
|
||||
)
|
||||
|
||||
localizable_keys = set(localizable_documentation_metadata_keys(topic))
|
||||
issues: list[str] = []
|
||||
for locale, translation in translations.items():
|
||||
if not locale.strip():
|
||||
issues.append("structured translation locale must not be empty")
|
||||
continue
|
||||
translated_keys = set(translation)
|
||||
for key in sorted(translated_keys - localizable_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys - translated_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} is missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys & translated_keys):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
topic.metadata[key],
|
||||
translation[key],
|
||||
path=f"{locale}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _structured_translation_shape_issues(
|
||||
source: object,
|
||||
translated: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(source, str):
|
||||
if not isinstance(translated, str) or not translated.strip():
|
||||
return (f"structured translation {path} must be a non-empty string",)
|
||||
return ()
|
||||
if isinstance(source, Mapping):
|
||||
if not isinstance(translated, Mapping):
|
||||
return (f"structured translation {path} must preserve object shape",)
|
||||
issues: list[str] = []
|
||||
source_keys = {str(key) for key in source}
|
||||
translated_keys = {str(key) for key in translated}
|
||||
if source_keys != translated_keys:
|
||||
issues.append(
|
||||
f"structured translation {path} must preserve object keys"
|
||||
)
|
||||
return tuple(issues)
|
||||
for key, value in source.items():
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
value,
|
||||
translated[key],
|
||||
path=f"{path}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if isinstance(source, Sequence) and not isinstance(
|
||||
source, (str, bytes, bytearray)
|
||||
):
|
||||
if not isinstance(translated, Sequence) or isinstance(
|
||||
translated, (str, bytes, bytearray)
|
||||
):
|
||||
return (f"structured translation {path} must preserve list shape",)
|
||||
if len(source) != len(translated):
|
||||
return (f"structured translation {path} must preserve list length",)
|
||||
issues: list[str] = []
|
||||
for index, (source_item, translated_item) in enumerate(
|
||||
zip(source, translated, strict=True)
|
||||
):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
source_item,
|
||||
translated_item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if translated != source:
|
||||
return (
|
||||
f"structured translation {path} must preserve non-text value {source!r}",
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
@@ -533,3 +738,53 @@ class ModuleManifest:
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
|
||||
def with_documentation_structured_translations(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, Any]],
|
||||
) -> ModuleManifest:
|
||||
"""Merge module-owned structured documentation translations by topic id.
|
||||
|
||||
The helper keeps feature prose in its owning module while giving every
|
||||
manifest the same fail-closed merge behavior. Unknown topic ids and
|
||||
incomplete or shape-changing locale maps are rejected immediately.
|
||||
"""
|
||||
|
||||
locale = locale.strip()
|
||||
if not locale:
|
||||
raise ValueError("structured documentation locale must not be empty")
|
||||
|
||||
topics_by_id = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
|
||||
if unknown_topic_ids:
|
||||
raise ValueError(
|
||||
"structured documentation translations reference unknown topic ids: "
|
||||
+ ", ".join(unknown_topic_ids)
|
||||
)
|
||||
|
||||
localized_topics: list[DocumentationTopic] = []
|
||||
for topic in manifest.documentation:
|
||||
translation = translations.get(topic.id)
|
||||
if translation is None:
|
||||
localized_topics.append(topic)
|
||||
continue
|
||||
|
||||
structured_translations = dict(topic.structured_translations)
|
||||
structured_translations[locale] = translation
|
||||
localized_topic = replace(
|
||||
topic,
|
||||
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
structured_translations=structured_translations,
|
||||
)
|
||||
issues = documentation_structured_translation_issues(localized_topic)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"invalid {locale!r} structured documentation translation for "
|
||||
f"{topic.id!r}: {'; '.join(issues)}"
|
||||
)
|
||||
localized_topics.append(localized_topic)
|
||||
|
||||
return replace(manifest, documentation=tuple(localized_topics))
|
||||
|
||||
@@ -8,11 +8,22 @@ NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
|
||||
_MAX_ITEMS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationSeparator:
|
||||
id: str
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"id": self.id, "label": self.label}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationPreferences:
|
||||
order: tuple[str, ...] = ()
|
||||
hidden: tuple[str, ...] = ()
|
||||
locked: tuple[str, ...] = ()
|
||||
# None preserves inherited grouping; an empty tuple explicitly removes it.
|
||||
separators: tuple[NavigationSeparator, ...] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -20,6 +31,7 @@ class NavigationPreferences:
|
||||
"order": list(self.order),
|
||||
"hidden": list(self.hidden),
|
||||
"locked": list(self.locked),
|
||||
**({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +44,9 @@ class EffectiveNavigationItem:
|
||||
order_source: str
|
||||
visibility_source: str
|
||||
lock_source: str | None = None
|
||||
section: NavigationSeparator | None = None
|
||||
custom_layout: bool = False
|
||||
layout_source: str = "module"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -42,6 +57,9 @@ class EffectiveNavigationItem:
|
||||
"navigation_order_source": self.order_source,
|
||||
"navigation_visibility_source": self.visibility_source,
|
||||
"navigation_lock_source": self.lock_source,
|
||||
"navigation_section": self.section.as_dict() if self.section else None,
|
||||
"navigation_custom_layout": self.custom_layout,
|
||||
"navigation_layout_source": self.layout_source,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +81,7 @@ def navigation_preferences_from_mapping(
|
||||
order=_ids(raw.get("order")),
|
||||
hidden=_ids(raw.get("hidden")),
|
||||
locked=_ids(raw.get("locked")),
|
||||
separators=_separators(raw.get("separators")),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +114,9 @@ def resolve_navigation_preferences(
|
||||
visibility = {item_id: True for item_id in ordered}
|
||||
visibility_source = {item_id: "module" for item_id in ordered}
|
||||
locks: dict[str, str] = {}
|
||||
separators: dict[str, NavigationSeparator] = {}
|
||||
custom_layout = False
|
||||
layout_source = "module"
|
||||
|
||||
for source, preferences, may_lock in (
|
||||
("system", system, True),
|
||||
@@ -103,7 +125,13 @@ def resolve_navigation_preferences(
|
||||
):
|
||||
if preferences is None:
|
||||
continue
|
||||
requested_order = [item_id for item_id in preferences.order if item_id in available]
|
||||
if preferences.separators is not None:
|
||||
separators = {item.id: item for item in preferences.separators if item.id not in available}
|
||||
ordered = [item_id for item_id in ordered if item_id in available or item_id in separators]
|
||||
ordered.extend(item_id for item_id in separators if item_id not in ordered)
|
||||
custom_layout = True
|
||||
layout_source = source
|
||||
requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators))
|
||||
if requested_order:
|
||||
requested = set(requested_order)
|
||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
||||
@@ -127,8 +155,13 @@ def resolve_navigation_preferences(
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = source
|
||||
|
||||
return {
|
||||
item_id: EffectiveNavigationItem(
|
||||
result: dict[str, EffectiveNavigationItem] = {}
|
||||
section: NavigationSeparator | None = None
|
||||
for index, item_id in enumerate(ordered):
|
||||
if item_id in separators:
|
||||
section = separators[item_id]
|
||||
continue
|
||||
result[item_id] = EffectiveNavigationItem(
|
||||
id=item_id,
|
||||
order=index,
|
||||
visible=visibility[item_id],
|
||||
@@ -136,9 +169,29 @@ def resolve_navigation_preferences(
|
||||
order_source=order_source[item_id],
|
||||
visibility_source=visibility_source[item_id],
|
||||
lock_source=locks.get(item_id),
|
||||
section=section,
|
||||
custom_layout=custom_layout,
|
||||
layout_source=layout_source,
|
||||
)
|
||||
for index, item_id in enumerate(ordered)
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _separators(value: object) -> tuple[NavigationSeparator, ...] | None:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
items: dict[str, NavigationSeparator] = {}
|
||||
for raw in value[:_MAX_ITEMS]:
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
item_id = _clean_id(raw.get("id"))
|
||||
label = raw.get("label", "")
|
||||
if not item_id.startswith("separator:") or not isinstance(label, str):
|
||||
continue
|
||||
label = label.strip()[:120]
|
||||
if any(ord(character) < 32 for character in label):
|
||||
continue
|
||||
items[item_id] = NavigationSeparator(item_id, label)
|
||||
return tuple(items.values())
|
||||
|
||||
|
||||
def _ids(value: object) -> tuple[str, ...]:
|
||||
@@ -168,6 +221,7 @@ __all__ = [
|
||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||
"NAVIGATION_PREFERENCES_KEY",
|
||||
"NavigationPreferences",
|
||||
"NavigationSeparator",
|
||||
"navigation_preferences_from_mapping",
|
||||
"navigation_preferences_from_settings",
|
||||
"resolve_navigation_preferences",
|
||||
|
||||
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
|
||||
"navigation",
|
||||
"permission",
|
||||
"product_area",
|
||||
"product_surface",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.product_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{surface.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_surface",
|
||||
label=surface.label,
|
||||
path=surface.route_path,
|
||||
required_all=surface.required_all,
|
||||
required_any=surface.required_any,
|
||||
metadata={
|
||||
"contract_version": surface.contract_version,
|
||||
"product_surface_id": surface.id,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(
|
||||
surface.documentation_topic_ids
|
||||
),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable_reason": surface.unavailable.reason,
|
||||
"degraded_reason": (
|
||||
surface.degraded.reason
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
for tool in frontend.quick_access_tools:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
|
||||
@@ -41,10 +41,12 @@ ViewGovernanceAction = Literal[
|
||||
"workflow_activate",
|
||||
]
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentReviewStep = Literal["holder", "authority", "recipient"]
|
||||
FunctionAssignmentGovernanceAction = Literal[
|
||||
"submit",
|
||||
"approve_holder",
|
||||
"approve_authority",
|
||||
"approve_escalation",
|
||||
"accept_recipient",
|
||||
"request_changes",
|
||||
"respond",
|
||||
@@ -419,6 +421,20 @@ class FunctionAssignmentGovernanceRequest:
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentEscalationRule:
|
||||
step: FunctionAssignmentReviewStep
|
||||
target_function_id: str
|
||||
timeout_hours: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"step": self.step,
|
||||
"target_function_id": self.target_function_id,
|
||||
"timeout_hours": self.timeout_hours,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceDecision:
|
||||
allowed: bool
|
||||
@@ -431,6 +447,10 @@ class FunctionAssignmentGovernanceDecision:
|
||||
separation_of_duties: bool = True
|
||||
quorum: int = 1
|
||||
maximum_validity_days: int | None = None
|
||||
delegation_allowed: bool = False
|
||||
maximum_delegation_depth: int = 0
|
||||
maximum_delegated_validity_days: int | None = None
|
||||
escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = ()
|
||||
request_expiry_hours: int = 336
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
@@ -448,12 +468,24 @@ class FunctionAssignmentGovernanceDecision:
|
||||
"separation_of_duties": self.separation_of_duties,
|
||||
"quorum": self.quorum,
|
||||
"maximum_validity_days": self.maximum_validity_days,
|
||||
"delegation_allowed": self.delegation_allowed,
|
||||
"maximum_delegation_depth": self.maximum_delegation_depth,
|
||||
"maximum_delegated_validity_days": (
|
||||
self.maximum_delegated_validity_days
|
||||
),
|
||||
"escalation_rules": [rule.to_dict() for rule in self.escalation_rules],
|
||||
"request_expiry_hours": self.request_expiry_hours,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
def escalation_rule(
|
||||
self,
|
||||
step: FunctionAssignmentReviewStep,
|
||||
) -> FunctionAssignmentEscalationRule | None:
|
||||
return next((rule for rule in self.escalation_rules if rule.step == step), None)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FunctionAssignmentGovernancePolicy(Protocol):
|
||||
|
||||
@@ -16,16 +16,20 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
|
||||
TenantSummaryBatchProvider,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
documentation_structured_translation_issues,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleEntitlementResolver,
|
||||
@@ -89,6 +93,9 @@ _WILDCARD_RE = re.compile(
|
||||
)
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
|
||||
_PRODUCT_SURFACE_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
@@ -962,6 +969,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
for issue in documentation_structured_translation_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
_validate_documentation_extensions(manifest)
|
||||
_validate_architecture_declarations(manifest)
|
||||
_validate_workflow_definition_contributions(manifest)
|
||||
@@ -969,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
|
||||
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
area_definitions: dict[str, tuple[str, str]] = {}
|
||||
surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
|
||||
product_paths: dict[str, str] = {}
|
||||
tool_owners: dict[str, str] = {}
|
||||
concrete_paths = {
|
||||
route.path: manifest.id
|
||||
for manifest in manifests
|
||||
if manifest.frontend is not None
|
||||
for route in (
|
||||
*manifest.frontend.routes,
|
||||
*manifest.frontend.settings_routes,
|
||||
*manifest.frontend.public_routes,
|
||||
)
|
||||
}
|
||||
for manifest in manifests:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
@@ -982,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
|
||||
f"Product area {area.id!r} has conflicting labels or icons"
|
||||
)
|
||||
area_definitions[area.id] = definition
|
||||
for surface in frontend.product_surfaces:
|
||||
definition = (
|
||||
surface.label,
|
||||
surface.icon,
|
||||
surface.entry_path,
|
||||
surface.description,
|
||||
)
|
||||
previous = surface_definitions.get(surface.id)
|
||||
if previous is not None and previous != definition:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has conflicting product identity metadata"
|
||||
)
|
||||
surface_definitions[surface.id] = definition
|
||||
for path in (surface.entry_path, *surface.aliases):
|
||||
concrete_owner = concrete_paths.get(path)
|
||||
if concrete_owner is not None:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} collides with a concrete route "
|
||||
f"owned by module {concrete_owner!r}"
|
||||
)
|
||||
previous_id = product_paths.get(path)
|
||||
if previous_id is not None and previous_id != surface.id:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} is shared by product surfaces "
|
||||
f"{previous_id!r} and {surface.id!r}"
|
||||
)
|
||||
product_paths[path] = surface.id
|
||||
for tool in frontend.quick_access_tools:
|
||||
previous_owner = tool_owners.get(tool.id)
|
||||
if previous_owner is not None:
|
||||
@@ -1469,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
||||
f"in module {manifest.id!r}"
|
||||
)
|
||||
seen_area_memberships.add(membership)
|
||||
seen_product_surfaces: set[str] = set()
|
||||
for surface in frontend.product_surfaces:
|
||||
_validate_product_surface(manifest, surface, known_surface_ids)
|
||||
if surface.id in seen_product_surfaces:
|
||||
raise RegistryError(
|
||||
f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
seen_product_surfaces.add(surface.id)
|
||||
seen_tools: set[str] = set()
|
||||
for tool in frontend.quick_access_tools:
|
||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||
@@ -1507,6 +1565,151 @@ def _validate_product_area(
|
||||
)
|
||||
|
||||
|
||||
def _validate_product_surface(
|
||||
manifest: ModuleManifest,
|
||||
surface: ProductSurfaceContribution,
|
||||
known_surface_ids: set[str],
|
||||
) -> None:
|
||||
module_id = manifest.id
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
if surface.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
|
||||
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
|
||||
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} uses unsupported contract version "
|
||||
f"{surface.contract_version!r}"
|
||||
)
|
||||
if not surface.label.strip() or not surface.icon.strip():
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} needs a label and icon"
|
||||
)
|
||||
for label, path in (
|
||||
("entry", surface.entry_path),
|
||||
("owner", surface.route_path),
|
||||
*(("alias", alias) for alias in surface.aliases),
|
||||
):
|
||||
if not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
|
||||
)
|
||||
if (
|
||||
surface.entry_path == surface.route_path
|
||||
or surface.entry_path in surface.aliases
|
||||
or surface.route_path in surface.aliases
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
|
||||
)
|
||||
if len(set(surface.aliases)) != len(surface.aliases):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} contains duplicate aliases"
|
||||
)
|
||||
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
|
||||
if surface.route_path not in route_paths:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown owner route "
|
||||
f"{surface.route_path!r}"
|
||||
)
|
||||
if not surface.surface_ids:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has no owner surfaces"
|
||||
)
|
||||
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
|
||||
if unknown_surfaces:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown surfaces: "
|
||||
+ ", ".join(sorted(unknown_surfaces))
|
||||
)
|
||||
allowed_presentations = {"task", "reader", "admin", "operator"}
|
||||
if (
|
||||
not surface.presentations
|
||||
or len(set(surface.presentations)) != len(surface.presentations)
|
||||
or set(surface.presentations) - allowed_presentations
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has invalid presentations"
|
||||
)
|
||||
declared_capabilities = {
|
||||
*manifest.required_capabilities,
|
||||
*manifest.optional_capabilities,
|
||||
*manifest.capability_factories,
|
||||
*(provider.name for provider in manifest.provides_interfaces),
|
||||
*(requirement.name for requirement in manifest.requires_interfaces),
|
||||
}
|
||||
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
|
||||
if unknown_capabilities:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undeclared capabilities: "
|
||||
+ ", ".join(sorted(unknown_capabilities))
|
||||
)
|
||||
search_source_ids = {source.id for source in manifest.search_sources}
|
||||
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
|
||||
if unknown_search_sources:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown search sources: "
|
||||
+ ", ".join(sorted(unknown_search_sources))
|
||||
)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
|
||||
if unknown_topics:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown documentation topics: "
|
||||
+ ", ".join(sorted(unknown_topics))
|
||||
)
|
||||
documented_help_contexts: set[str] = set()
|
||||
for topic in manifest.documentation:
|
||||
contexts = topic.metadata.get("help_contexts", ())
|
||||
if isinstance(contexts, (list, tuple, set, frozenset)):
|
||||
documented_help_contexts.update(
|
||||
context for context in contexts if isinstance(context, str)
|
||||
)
|
||||
unknown_help = set(surface.help_context_ids) - documented_help_contexts
|
||||
if unknown_help:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undocumented help contexts: "
|
||||
+ ", ".join(sorted(unknown_help))
|
||||
)
|
||||
_validate_product_availability_explanation(surface.id, surface.unavailable)
|
||||
if surface.degraded is not None:
|
||||
_validate_product_availability_explanation(surface.id, surface.degraded)
|
||||
|
||||
|
||||
def _validate_product_availability_explanation(
|
||||
surface_id: str,
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> None:
|
||||
allowed_reasons = {
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
}
|
||||
if explanation.reason not in allowed_reasons:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an invalid availability reason"
|
||||
)
|
||||
if any(
|
||||
not value.strip()
|
||||
for value in (
|
||||
explanation.title,
|
||||
explanation.description,
|
||||
explanation.resolution,
|
||||
)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an incomplete availability explanation"
|
||||
)
|
||||
|
||||
|
||||
def _validate_quick_access_tool(
|
||||
module_id: str,
|
||||
tool: QuickAccessTool,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
|
||||
|
||||
TenantErasureDisposition = Literal[
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
]
|
||||
TenantErasureStepKind = Literal[
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
]
|
||||
TenantErasureResultState = Literal[
|
||||
"completed",
|
||||
"pending",
|
||||
"blocked",
|
||||
"outcome_unknown",
|
||||
]
|
||||
|
||||
_DISPOSITIONS = frozenset(
|
||||
{
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
}
|
||||
)
|
||||
_STEP_KINDS = frozenset(
|
||||
{
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
}
|
||||
)
|
||||
_RESULT_STATES = frozenset(
|
||||
{"completed", "pending", "blocked", "outcome_unknown"}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: str, label: str, *, maximum: int) -> str:
|
||||
normalized = value.strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > maximum
|
||||
or any(ord(character) < 32 for character in normalized)
|
||||
):
|
||||
raise ValueError(f"Tenant erasure {label} is invalid.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _texts(
|
||||
values: tuple[str, ...],
|
||||
label: str,
|
||||
*,
|
||||
maximum_items: int = 100,
|
||||
maximum_length: int = 500,
|
||||
) -> tuple[str, ...]:
|
||||
if len(values) > maximum_items:
|
||||
raise ValueError(f"Tenant erasure {label} has too many entries.")
|
||||
normalized = tuple(
|
||||
_text(value, label, maximum=maximum_length) for value in values
|
||||
)
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError(f"Tenant erasure {label} contains duplicates.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
|
||||
if len(values) > 30:
|
||||
raise ValueError("Tenant erasure metrics has too many entries.")
|
||||
normalized: dict[str, int] = {}
|
||||
for key, value in values.items():
|
||||
normalized_key = _text(key, "metric key", maximum=80)
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError("Tenant erasure metric values must be non-negative integers.")
|
||||
normalized[normalized_key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureResource:
|
||||
resource_type: str
|
||||
count: int
|
||||
disposition: TenantErasureDisposition
|
||||
summary: str
|
||||
governance_ref: str | None = None
|
||||
external: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.resource_type, "resource type", maximum=120)
|
||||
_text(self.summary, "resource summary", maximum=1000)
|
||||
if type(self.count) is not int or self.count < 0:
|
||||
raise ValueError("Tenant erasure resource count is invalid.")
|
||||
if self.disposition not in _DISPOSITIONS:
|
||||
raise ValueError("Tenant erasure resource disposition is invalid.")
|
||||
if self.governance_ref is not None:
|
||||
_text(self.governance_ref, "governance reference", maximum=300)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"resource_type": self.resource_type,
|
||||
"count": self.count,
|
||||
"disposition": self.disposition,
|
||||
"summary": self.summary,
|
||||
"governance_ref": self.governance_ref,
|
||||
"external": self.external,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStep:
|
||||
step_id: str
|
||||
kind: TenantErasureStepKind
|
||||
summary: str
|
||||
destructive: bool
|
||||
irreversible: bool
|
||||
requires_reconciliation: bool = False
|
||||
depends_on: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.step_id, "step id", maximum=160)
|
||||
_text(self.summary, "step summary", maximum=1000)
|
||||
if self.kind not in _STEP_KINDS:
|
||||
raise ValueError("Tenant erasure step kind is invalid.")
|
||||
_texts(self.depends_on, "step dependencies", maximum_length=160)
|
||||
if self.step_id in self.depends_on:
|
||||
raise ValueError("Tenant erasure step cannot depend on itself.")
|
||||
if self.irreversible and not self.destructive:
|
||||
raise ValueError("An irreversible tenant erasure step must be destructive.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"step_id": self.step_id,
|
||||
"kind": self.kind,
|
||||
"summary": self.summary,
|
||||
"destructive": self.destructive,
|
||||
"irreversible": self.irreversible,
|
||||
"requires_reconciliation": self.requires_reconciliation,
|
||||
"depends_on": list(self.depends_on),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasurePreview:
|
||||
module_id: str
|
||||
complete: bool
|
||||
resources: tuple[TenantErasureResource, ...] = ()
|
||||
steps: tuple[TenantErasureStep, ...] = ()
|
||||
blockers: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
provider_revision: str = "1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.module_id, "module id", maximum=120)
|
||||
_text(self.provider_revision, "provider revision", maximum=120)
|
||||
_texts(self.blockers, "blockers", maximum_length=1000)
|
||||
_texts(self.warnings, "warnings", maximum_length=1000)
|
||||
if len(self.resources) > 500 or len(self.steps) > 500:
|
||||
raise ValueError("Tenant erasure preview is too large.")
|
||||
resource_types = [item.resource_type for item in self.resources]
|
||||
if len(resource_types) != len(set(resource_types)):
|
||||
raise ValueError("Tenant erasure preview repeats a resource type.")
|
||||
resources_requiring_action = tuple(
|
||||
item for item in self.resources if item.count > 0
|
||||
)
|
||||
if resources_requiring_action and not self.steps and not self.blockers:
|
||||
raise ValueError(
|
||||
"Tenant erasure resources require steps or an explicit blocker."
|
||||
)
|
||||
if any(
|
||||
item.count > 0 and item.disposition == "unavailable"
|
||||
for item in self.resources
|
||||
) and not self.blockers:
|
||||
raise ValueError(
|
||||
"Unavailable tenant erasure resources require an explicit blocker."
|
||||
)
|
||||
if not self.complete and not self.blockers:
|
||||
raise ValueError(
|
||||
"An incomplete tenant erasure preview requires an explicit blocker."
|
||||
)
|
||||
step_ids = [item.step_id for item in self.steps]
|
||||
if len(step_ids) != len(set(step_ids)):
|
||||
raise ValueError("Tenant erasure preview repeats a step id.")
|
||||
known_step_ids = set(step_ids)
|
||||
if any(
|
||||
dependency not in known_step_ids
|
||||
for step in self.steps
|
||||
for dependency in step.depends_on
|
||||
):
|
||||
raise ValueError("Tenant erasure step references an unknown dependency.")
|
||||
remaining = {
|
||||
step.step_id: set(step.depends_on)
|
||||
for step in self.steps
|
||||
}
|
||||
resolved: set[str] = set()
|
||||
while remaining:
|
||||
ready = sorted(
|
||||
step_id
|
||||
for step_id, dependencies in remaining.items()
|
||||
if dependencies.issubset(resolved)
|
||||
)
|
||||
if not ready:
|
||||
raise ValueError("Tenant erasure step dependencies contain a cycle.")
|
||||
resolved.update(ready)
|
||||
for step_id in ready:
|
||||
remaining.pop(step_id)
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and not self.blockers
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"provider_revision": self.provider_revision,
|
||||
"resources": [item.to_dict() for item in self.resources],
|
||||
"steps": [item.to_dict() for item in self.steps],
|
||||
"blockers": list(self.blockers),
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStepResult:
|
||||
state: TenantErasureResultState
|
||||
summary: str
|
||||
receipt_ref: str | None = None
|
||||
metrics: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.state not in _RESULT_STATES:
|
||||
raise ValueError("Tenant erasure result state is invalid.")
|
||||
_text(self.summary, "result summary", maximum=1000)
|
||||
if self.receipt_ref is not None:
|
||||
_text(self.receipt_ref, "receipt reference", maximum=500)
|
||||
_metrics(self.metrics)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"summary": self.summary,
|
||||
"receipt_ref": self.receipt_ref,
|
||||
"metrics": dict(sorted(_metrics(self.metrics).items())),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantErasureProvider(Protocol):
|
||||
module_id: str
|
||||
|
||||
def preview_tenant_erasure(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantErasurePreview:
|
||||
...
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureInventory:
|
||||
tenant_id: str
|
||||
generated_at: datetime
|
||||
complete: bool
|
||||
modules: tuple[TenantErasurePreview, ...]
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and all(item.allowed for item in self.modules)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
generated_at = self.generated_at
|
||||
if generated_at.tzinfo is None:
|
||||
generated_at = generated_at.replace(tzinfo=UTC)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tenant_id": self.tenant_id,
|
||||
"generated_at": generated_at.astimezone(UTC).isoformat(),
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"modules": [item.to_dict() for item in self.modules],
|
||||
}
|
||||
|
||||
|
||||
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Tenant erasure requires a module registry.")
|
||||
providers: dict[str, TenantErasureProvider] = {}
|
||||
for capability_name in sorted(capability_names()):
|
||||
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
|
||||
continue
|
||||
expected_module_id = capability_name.removeprefix(
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
provider = capability(capability_name)
|
||||
if not isinstance(provider, TenantErasureProvider):
|
||||
raise TypeError(
|
||||
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
|
||||
)
|
||||
module_id = _text(provider.module_id, "provider module id", maximum=120)
|
||||
if module_id != expected_module_id or module_id in providers:
|
||||
raise ValueError("Tenant erasure provider identity is invalid.")
|
||||
providers[module_id] = provider
|
||||
return providers
|
||||
|
||||
|
||||
def collect_tenant_erasure_inventory(
|
||||
registry: object,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
) -> TenantErasureInventory:
|
||||
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
|
||||
manifests = getattr(registry, "manifests", None)
|
||||
summary_providers = getattr(registry, "tenant_summary_providers", None)
|
||||
if not callable(manifests) or not callable(summary_providers):
|
||||
raise ValueError("Tenant erasure inventory requires a module registry.")
|
||||
provider_by_module = tenant_erasure_providers(registry)
|
||||
summary_by_module = dict(summary_providers())
|
||||
manifest_ids = {
|
||||
str(manifest.id)
|
||||
for manifest in manifests()
|
||||
if getattr(manifest, "id", None)
|
||||
}
|
||||
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
|
||||
previews: list[TenantErasurePreview] = []
|
||||
complete = True
|
||||
for module_id in sorted(module_ids):
|
||||
provider = provider_by_module.get(module_id)
|
||||
if provider is not None:
|
||||
try:
|
||||
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
|
||||
if not isinstance(preview, TenantErasurePreview):
|
||||
raise TypeError("provider returned an invalid preview")
|
||||
if preview.module_id != module_id:
|
||||
raise ValueError("provider returned another module's preview")
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: provider preview could not be completed",
|
||||
),
|
||||
)
|
||||
previews.append(preview)
|
||||
complete = complete and preview.complete
|
||||
continue
|
||||
summary_provider = summary_by_module.get(module_id)
|
||||
if summary_provider is None:
|
||||
previews.append(
|
||||
TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
warnings=(
|
||||
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
|
||||
),
|
||||
provider_revision="manifest-no-tenant-data",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
raw_counts = summary_provider(session, normalized_tenant_id)
|
||||
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
|
||||
resources = tuple(
|
||||
TenantErasureResource(
|
||||
resource_type=resource_type,
|
||||
count=count,
|
||||
disposition="unavailable" if count else "erase",
|
||||
summary=(
|
||||
"Tenant-owned data requires a module erasure provider."
|
||||
if count
|
||||
else "The module reported no tenant-owned records."
|
||||
),
|
||||
)
|
||||
for resource_type, count in sorted(counts.items())
|
||||
)
|
||||
blockers = (
|
||||
("Tenant-owned data exists but the module has no erasure provider.",)
|
||||
if any(counts.values())
|
||||
else ()
|
||||
)
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
resources=resources,
|
||||
blockers=blockers,
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: tenant summary could not be completed",
|
||||
),
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
previews.append(preview)
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return TenantErasureInventory(
|
||||
tenant_id=normalized_tenant_id,
|
||||
generated_at=timestamp,
|
||||
complete=complete,
|
||||
modules=tuple(previews),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
|
||||
"TenantErasureInventory",
|
||||
"TenantErasurePreview",
|
||||
"TenantErasureProvider",
|
||||
"TenantErasureResource",
|
||||
"TenantErasureStep",
|
||||
"TenantErasureStepResult",
|
||||
"collect_tenant_erasure_inventory",
|
||||
"tenant_erasure_providers",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Mapping, Protocol, runtime_checkable
|
||||
|
||||
|
||||
TICKET_INTEGRATION_CONTRACT_VERSION = "1"
|
||||
CAPABILITY_TICKET_ROUTING = "tickets.routing"
|
||||
CAPABILITY_TICKET_CASE_ESCALATION = "tickets.case_escalation"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketRoutingRequest:
|
||||
tenant_id: str
|
||||
ticket_id: str
|
||||
ticket_type: str
|
||||
priority: str
|
||||
title: str
|
||||
received_at: datetime
|
||||
queue_hint: str | None = None
|
||||
attributes: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Ticket routing tenant", 255)
|
||||
_required(self.ticket_id, "Ticket routing ticket", 255)
|
||||
_required(self.ticket_type, "Ticket routing type", 80)
|
||||
_required(self.priority, "Ticket routing priority", 40)
|
||||
_required(self.title, "Ticket routing title", 500)
|
||||
_aware(self.received_at, "Ticket routing received_at")
|
||||
_optional(self.queue_hint, "Ticket routing queue hint", 255)
|
||||
if len(self.attributes) > 100:
|
||||
raise ValueError("Ticket routing attributes are limited to 100 entries.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketRoutingPlan:
|
||||
provider_id: str
|
||||
queue_ref: str | None = None
|
||||
service_target_at: datetime | None = None
|
||||
explanation: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.provider_id, "Ticket routing provider", 200)
|
||||
_optional(self.queue_ref, "Ticket routing queue reference", 255)
|
||||
_optional(self.explanation, "Ticket routing explanation", 4_000)
|
||||
_aware(self.service_target_at, "Ticket routing service_target_at")
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket routing metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TicketRoutingProvider(Protocol):
|
||||
def route_ticket(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TicketRoutingRequest,
|
||||
) -> TicketRoutingPlan: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketCaseEscalationCommand:
|
||||
tenant_id: str
|
||||
ticket_id: str
|
||||
ticket_number: str
|
||||
title: str
|
||||
case_type_key: str
|
||||
occurred_at: datetime
|
||||
idempotency_key: str
|
||||
handoff_note: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Ticket escalation tenant", 255)
|
||||
_required(self.ticket_id, "Ticket escalation ticket", 255)
|
||||
_required(self.ticket_number, "Ticket escalation number", 255)
|
||||
_required(self.title, "Ticket escalation title", 500)
|
||||
_required(self.case_type_key, "Ticket escalation case type", 120)
|
||||
_required(self.idempotency_key, "Ticket escalation idempotency key", 255)
|
||||
_optional(self.handoff_note, "Ticket escalation handoff note", 10_000)
|
||||
_aware(self.occurred_at, "Ticket escalation occurred_at")
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketCaseEscalationResult:
|
||||
provider_id: str
|
||||
case_id: str
|
||||
case_number: str
|
||||
case_url: str
|
||||
replayed: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.provider_id, "Ticket escalation provider", 200)
|
||||
_required(self.case_id, "Ticket escalation case", 255)
|
||||
_required(self.case_number, "Ticket escalation case number", 255)
|
||||
_relative_url(self.case_url)
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TicketCaseEscalationProvider(Protocol):
|
||||
def escalate_ticket(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: TicketCaseEscalationCommand,
|
||||
) -> TicketCaseEscalationResult: ...
|
||||
|
||||
|
||||
def ticket_routing_provider(registry: object | None) -> TicketRoutingProvider | None:
|
||||
provider = _capability(registry, CAPABILITY_TICKET_ROUTING)
|
||||
return provider if isinstance(provider, TicketRoutingProvider) else None
|
||||
|
||||
|
||||
def ticket_case_escalation_provider(
|
||||
registry: object | None,
|
||||
) -> TicketCaseEscalationProvider | None:
|
||||
provider = _capability(registry, CAPABILITY_TICKET_CASE_ESCALATION)
|
||||
return provider if isinstance(provider, TicketCaseEscalationProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _required(value: str, label: str, maximum: int) -> None:
|
||||
if not value.strip() or len(value) > maximum:
|
||||
raise ValueError(f"{label} must contain 1 to {maximum} characters.")
|
||||
|
||||
|
||||
def _optional(value: str | None, label: str, maximum: int) -> None:
|
||||
if value is not None and (not value.strip() or len(value) > maximum):
|
||||
raise ValueError(f"{label} must contain 1 to {maximum} characters when set.")
|
||||
|
||||
|
||||
def _aware(value: datetime | None, label: str) -> None:
|
||||
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||
raise ValueError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _relative_url(value: str) -> None:
|
||||
if (
|
||||
not value.startswith("/")
|
||||
or value.startswith("//")
|
||||
or "\\" in value
|
||||
or len(value) > 1_500
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
||||
):
|
||||
raise ValueError("Ticket escalation URLs must be bounded application-relative paths.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_TICKET_CASE_ESCALATION",
|
||||
"CAPABILITY_TICKET_ROUTING",
|
||||
"TICKET_INTEGRATION_CONTRACT_VERSION",
|
||||
"TicketCaseEscalationCommand",
|
||||
"TicketCaseEscalationProvider",
|
||||
"TicketCaseEscalationResult",
|
||||
"TicketRoutingPlan",
|
||||
"TicketRoutingProvider",
|
||||
"TicketRoutingRequest",
|
||||
"ticket_case_escalation_provider",
|
||||
"ticket_routing_provider",
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
@@ -12,6 +13,12 @@ from govoplan_core.security.outbound_http import (
|
||||
)
|
||||
|
||||
|
||||
MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000
|
||||
_STANDARD_REDIRECT_SENSITIVE_HEADERS = frozenset(
|
||||
{"authorization", "proxy-authorization", "cookie", "cookie2"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HttpFetchResponse:
|
||||
status: int
|
||||
@@ -46,15 +53,27 @@ def fetch_http(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> HttpFetchResponse:
|
||||
if body is not None and len(body) > MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES:
|
||||
raise ValueError(
|
||||
"Outbound HTTP request body exceeds the 1000000-byte safety limit."
|
||||
)
|
||||
validated_url = validate_outbound_http_url(url, label=label)
|
||||
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||
validated_url,
|
||||
data=body,
|
||||
headers=dict(headers or {}),
|
||||
method=method,
|
||||
)
|
||||
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||
opener = build_outbound_http_opener(
|
||||
_PolicyRedirectHandler(
|
||||
label=label,
|
||||
sensitive_headers=redirect_sensitive_headers,
|
||||
)
|
||||
)
|
||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
response_headers = dict(response.headers.items())
|
||||
return HttpFetchResponse(
|
||||
@@ -76,16 +95,35 @@ def fetch_http_text(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
encoding: str = "utf-8",
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> str:
|
||||
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
|
||||
return fetch_http(
|
||||
url,
|
||||
timeout=timeout,
|
||||
label=label,
|
||||
method=method,
|
||||
headers=headers,
|
||||
body=body,
|
||||
max_bytes=max_bytes,
|
||||
redirect_sensitive_headers=redirect_sensitive_headers,
|
||||
).text(encoding)
|
||||
|
||||
|
||||
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, *, label: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
sensitive_headers: Iterable[str] = (),
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._sensitive_headers = _STANDARD_REDIRECT_SENSITIVE_HEADERS | {
|
||||
value.strip().lower() for value in sensitive_headers if value.strip()
|
||||
}
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||
@@ -95,7 +133,8 @@ class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
return None
|
||||
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||
for header in tuple(new_request.headers) + tuple(new_request.unredirected_hdrs):
|
||||
if header.lower() in self._sensitive_headers:
|
||||
new_request.remove_header(header)
|
||||
return new_request
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"system:tenants:create": "access:tenant:create",
|
||||
"system:tenants:update": "access:tenant:update",
|
||||
"system:tenants:suspend": "access:tenant:suspend",
|
||||
"system:tenants:erase": "access:tenant:erase",
|
||||
"system:accounts:read": "access:account:read",
|
||||
"system:accounts:create": "access:account:create",
|
||||
"system:accounts:update": "access:account:update",
|
||||
|
||||
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||
|
||||
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||
@@ -198,6 +200,9 @@ def _nav_item_payload(
|
||||
"visible": scoped.visible,
|
||||
"locked": scoped.locked,
|
||||
"lock_source": scoped.lock_source,
|
||||
"section": scoped.section.as_dict() if scoped.section else None,
|
||||
"custom_layout": scoped.custom_layout,
|
||||
"layout_source": scoped.layout_source,
|
||||
}
|
||||
for scope in ("module", "system", "tenant")
|
||||
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
|
||||
@@ -254,6 +259,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _product_availability_payload(
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"reason": explanation.reason,
|
||||
"title": explanation.title,
|
||||
"description": explanation.description,
|
||||
"resolution": explanation.resolution,
|
||||
"responsible_role": explanation.responsible_role,
|
||||
}
|
||||
|
||||
|
||||
def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": surface.contract_version,
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"label": surface.label,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"route_path": surface.route_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(surface.documentation_topic_ids),
|
||||
"required_all": list(surface.required_all),
|
||||
"required_any": list(surface.required_any),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable": _product_availability_payload(surface.unavailable),
|
||||
"degraded": (
|
||||
_product_availability_payload(surface.degraded)
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||
return {
|
||||
"id": tool.id,
|
||||
@@ -372,6 +418,10 @@ def _frontend_payload(
|
||||
"product_areas": [
|
||||
_product_area_payload(area) for area in frontend.product_areas
|
||||
],
|
||||
"product_surfaces": [
|
||||
_product_surface_payload(surface)
|
||||
for surface in frontend.product_surfaces
|
||||
],
|
||||
"quick_access_tools": [
|
||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||
],
|
||||
|
||||
@@ -164,6 +164,9 @@ class Settings(BaseSettings):
|
||||
ge=60,
|
||||
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
|
||||
)
|
||||
file_archive_work_root: str | None = Field(default=None, alias="FILE_ARCHIVE_WORK_ROOT")
|
||||
file_archive_staged_max_bytes: int = Field(default=2 * 1024 ** 3, ge=1, alias="FILE_ARCHIVE_STAGED_MAX_BYTES")
|
||||
file_archive_staged_per_actor: int = Field(default=4, ge=1, le=64, alias="FILE_ARCHIVE_STAGED_PER_ACTOR")
|
||||
|
||||
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
|
||||
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
||||
|
||||
@@ -71,6 +71,7 @@ from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
CampaignAccessProvider,
|
||||
CampaignDeliveryTaskProvider,
|
||||
CampaignMailPolicyContext,
|
||||
@@ -78,6 +79,10 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignPolicyContext,
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
CampaignWorkHandoffInspection,
|
||||
CampaignWorkHandoffRef,
|
||||
CampaignWorkHandoffRequest,
|
||||
CampaignWorkOrchestrationProvider,
|
||||
)
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
@@ -464,6 +469,40 @@ class _FakeCampaignRetentionProvider:
|
||||
return {"raw_campaign_json": {"eligible": int(dry_run)}}
|
||||
|
||||
|
||||
class _FakeCampaignWorkOrchestrationProvider:
|
||||
def prepare_handoff(self, session: object, principal: object, *, request):
|
||||
del session, principal
|
||||
return CampaignWorkHandoffRef(
|
||||
tenant_id=request.tenant_id,
|
||||
campaign_id=request.campaign_id or "campaign-created",
|
||||
campaign_version_id="campaign-version-1",
|
||||
campaign_revision=1,
|
||||
assignment_id="assignment-1",
|
||||
assignment_revision=1,
|
||||
status="open",
|
||||
action_url="/campaigns/campaign-1/work?assignment=assignment-1",
|
||||
campaign_ref="campaign:campaign-1:version:campaign-version-1:r1",
|
||||
assignment_ref="campaign-work-assignment:assignment-1:r1",
|
||||
)
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
):
|
||||
del session, principal, tenant_id, assignment_id
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=expected_revision in {None, 1},
|
||||
status="open",
|
||||
assignment_revision=1,
|
||||
assignment_ref="campaign-work-assignment:assignment-1:r1",
|
||||
)
|
||||
|
||||
|
||||
class _FakeSecretProvider:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
@@ -528,6 +567,10 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION)
|
||||
self.assertEqual(
|
||||
"campaigns.workOrchestration",
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
)
|
||||
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
|
||||
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
|
||||
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
|
||||
@@ -642,6 +685,10 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
|
||||
self.assertIsInstance(
|
||||
_FakeCampaignWorkOrchestrationProvider(),
|
||||
CampaignWorkOrchestrationProvider,
|
||||
)
|
||||
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
|
||||
self.assertIsInstance(_FakeAuditSink(), AuditSink)
|
||||
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
|
||||
@@ -676,6 +723,37 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1"))
|
||||
self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object()))
|
||||
|
||||
def test_campaign_work_handoff_contract_requires_one_campaign_source(self) -> None:
|
||||
request = CampaignWorkHandoffRequest(
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
idempotency_key="workflow-step-1",
|
||||
purpose="Review the campaign",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
)
|
||||
provider = _FakeCampaignWorkOrchestrationProvider()
|
||||
|
||||
handoff = provider.prepare_handoff(object(), object(), request=request)
|
||||
inspection = provider.inspect_handoff(
|
||||
object(),
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
assignment_id=handoff.assignment_id,
|
||||
expected_revision=handoff.assignment_revision,
|
||||
)
|
||||
|
||||
self.assertEqual("campaign-1", handoff.campaign_id)
|
||||
self.assertTrue(inspection.allowed)
|
||||
with self.assertRaisesRegex(ValueError, "either reference one campaign"):
|
||||
CampaignWorkHandoffRequest(
|
||||
tenant_id="tenant-1",
|
||||
idempotency_key="workflow-step-2",
|
||||
purpose="Review",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
)
|
||||
|
||||
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||
directory = _FakeAccessDirectory()
|
||||
semantic_directory = _FakeAccessSemanticDirectory()
|
||||
|
||||
@@ -5271,6 +5271,36 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id)
|
||||
self.assertIsNotNone(visible_key["revoked_at"])
|
||||
|
||||
def test_navigation_separator_layout_survives_system_tenant_and_personal_saves(self) -> None:
|
||||
headers, _ = self._login()
|
||||
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
|
||||
layout = {"contract_version": "1", "order": ["files.navigation.files", "separator:mail", "mail.navigation.mail"], "hidden": [], "locked": ["mail.navigation.mail"], "separators": [{"id": "separator:mail", "label": "Nachrichten"}]}
|
||||
saved = self.client.patch("/api/v1/admin/system/settings", headers=headers, json={
|
||||
**{key: system[key] for key in ("default_locale", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys")}, "navigation": layout,
|
||||
})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(layout, saved.json()["navigation"])
|
||||
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
|
||||
tenant_layout = {**layout, "locked": []}
|
||||
saved = self.client.patch("/api/v1/admin/tenant/settings", headers=headers, json={"default_locale": tenant["default_locale"], "navigation": tenant_layout})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(tenant_layout, saved.json()["navigation"])
|
||||
personal_layout = {**tenant_layout, "order": ["mail.navigation.mail", "separator:personal", "files.navigation.files"], "hidden": ["mail.navigation.mail"], "separators": [{"id": "separator:personal", "label": "Meine Arbeit"}]}
|
||||
saved = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": personal_layout}})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(personal_layout, saved.json()["user"]["ui_preferences"]["navigation"])
|
||||
loaded = self.client.get("/api/v1/auth/profile", headers=headers)
|
||||
self.assertEqual(personal_layout, loaded.json()["user"]["ui_preferences"]["navigation"])
|
||||
from govoplan_core.core.navigation import navigation_preferences_from_mapping, resolve_navigation_preferences
|
||||
# HTTP persistence is checked above; scope projection stays independently deterministic.
|
||||
resolved = resolve_navigation_preferences(("files.navigation.files", "mail.navigation.mail"), system=navigation_preferences_from_mapping(layout), tenant=navigation_preferences_from_mapping(tenant_layout), user=navigation_preferences_from_mapping(personal_layout))
|
||||
self.assertTrue(resolved["mail.navigation.mail"].visible)
|
||||
self.assertTrue(resolved["mail.navigation.mail"].locked)
|
||||
self.assertEqual("Meine Arbeit", resolved["files.navigation.files"].section.label)
|
||||
reset = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": None}})
|
||||
self.assertEqual(200, reset.status_code, reset.text)
|
||||
self.assertIsNone(reset.json()["user"]["ui_preferences"]["navigation"])
|
||||
|
||||
def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None:
|
||||
headers, _ = self._login()
|
||||
|
||||
@@ -6860,6 +6890,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||
"hidden": ["mail.navigation.mail"],
|
||||
"locked": [],
|
||||
"separators": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,13 +3,22 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationModuleRequirement,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPackageEvidence,
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPackageParent,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProviderExpectation,
|
||||
ConfigurationRequiredData,
|
||||
apply_configuration_package,
|
||||
dry_run_configuration_package,
|
||||
export_configuration_package,
|
||||
validate_configuration_package_derivation,
|
||||
)
|
||||
|
||||
@@ -27,6 +36,121 @@ def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
|
||||
|
||||
|
||||
class ConfigurationPackageArchitectureTests(unittest.TestCase):
|
||||
def test_deployment_data_references_are_declared_resolved_and_never_exported(self) -> None:
|
||||
class Provider:
|
||||
module_id = "forms"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.preflight_payloads: list[dict[str, object]] = []
|
||||
|
||||
def describe(self):
|
||||
from govoplan_core.core.configuration_packages import ConfigurationProviderDescription
|
||||
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("definition",),
|
||||
)
|
||||
|
||||
def preflight(self, fragment, context):
|
||||
del context
|
||||
self.preflight_payloads.append(dict(fragment.payload))
|
||||
return ConfigurationPreflightResult(plan=(ConfigurationPlanItem(
|
||||
action="create",
|
||||
module_id=self.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
),))
|
||||
|
||||
def apply(self, fragment, supplied_data, context):
|
||||
del supplied_data, context
|
||||
return ConfigurationApplyResult(
|
||||
created_refs={fragment.fragment_id or "definition": "form:resident-parking"}
|
||||
)
|
||||
|
||||
def export(self, selection, context):
|
||||
del selection, context
|
||||
return ConfigurationExportResult(
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id=self.module_id,
|
||||
fragment_type="definition",
|
||||
payload={"name": "Resident parking permit"},
|
||||
),),
|
||||
data_requirements=(ConfigurationRequiredData(
|
||||
key="payment_credential_ref",
|
||||
label="Payment credential reference",
|
||||
secret=True,
|
||||
),),
|
||||
)
|
||||
|
||||
def health(self, import_result, context):
|
||||
del import_result, context
|
||||
return ()
|
||||
|
||||
provider = Provider()
|
||||
package = ConfigurationPackageManifest(
|
||||
package_id="product.resident-parking",
|
||||
name="Resident parking permit",
|
||||
version="1.0.0",
|
||||
required_modules=(ConfigurationModuleRequirement("forms"),),
|
||||
data_requirements=({
|
||||
"key": "service_name",
|
||||
"label": "Public service name",
|
||||
},),
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id="forms",
|
||||
fragment_type="definition",
|
||||
fragment_id="resident-parking",
|
||||
payload={
|
||||
"definition": {
|
||||
"title": {"$data": "service_name"},
|
||||
}
|
||||
},
|
||||
),),
|
||||
)
|
||||
missing_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
)
|
||||
|
||||
missing = dry_run_configuration_package(package, (provider,), missing_context)
|
||||
|
||||
self.assertEqual([], provider.preflight_payloads)
|
||||
self.assertIn(
|
||||
"fragment_data_reference_missing",
|
||||
{item.code for item in missing.diagnostics},
|
||||
)
|
||||
|
||||
ready_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
supplied_data={"service_name": "Anwohnerparkausweis"},
|
||||
operator_user_id="operator-1",
|
||||
)
|
||||
ready = dry_run_configuration_package(package, (provider,), ready_context)
|
||||
applied = apply_configuration_package(package, (provider,), ready_context)
|
||||
exported = export_configuration_package(
|
||||
(provider,),
|
||||
ConfigurationExportSelection(
|
||||
tenant_id="tenant-1",
|
||||
module_ids=("forms",),
|
||||
),
|
||||
ready_context,
|
||||
)
|
||||
|
||||
self.assertFalse(any(item.severity == "blocker" for item in ready.diagnostics))
|
||||
self.assertEqual(
|
||||
"Anwohnerparkausweis",
|
||||
provider.preflight_payloads[-1]["definition"]["title"], # type: ignore[index]
|
||||
)
|
||||
self.assertIsNotNone(applied.rollback)
|
||||
assert applied.rollback is not None
|
||||
self.assertEqual("database_restore_required", applied.rollback.status)
|
||||
self.assertIsNotNone(exported.provenance)
|
||||
assert exported.provenance is not None
|
||||
self.assertEqual("operator-1", exported.provenance.exporter_id)
|
||||
self.assertEqual(
|
||||
("payment_credential_ref",),
|
||||
exported.provenance.redacted_secret_keys,
|
||||
)
|
||||
|
||||
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
|
||||
package = ConfigurationPackageManifest.from_mapping(
|
||||
{"package_id": "example", "name": "Example", "version": "1.0.0"}
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_core.core.datasources import (
|
||||
DatasourceArtifactBackendProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceGovernance,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
@@ -212,6 +213,55 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
self.assertEqual("upload", descriptor.kind)
|
||||
self.assertEqual("tabular", descriptor.shape)
|
||||
|
||||
def test_lifecycle_governance_round_trips_without_provider_specific_types(self) -> None:
|
||||
governance = DatasourceGovernance.from_mapping(
|
||||
{
|
||||
"approval_policy": {
|
||||
"version": "approval-v2",
|
||||
"required": True,
|
||||
"required_approvals": 2,
|
||||
},
|
||||
"retention_policy": {
|
||||
"version": "retention-v3",
|
||||
"enabled": True,
|
||||
"stage_days": 30,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("approval-v2", governance.approval_policy["version"])
|
||||
self.assertEqual(30, governance.retention_policy["stage_days"])
|
||||
self.assertEqual(
|
||||
governance.approval_policy,
|
||||
governance.to_dict()["approval_policy"],
|
||||
)
|
||||
self.assertEqual(
|
||||
governance.retention_policy,
|
||||
governance.to_dict()["retention_policy"],
|
||||
)
|
||||
|
||||
stage = DatasourceStage(
|
||||
ref="stage:governed",
|
||||
name="Governed stage",
|
||||
source_name="governed",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="awaiting_approval",
|
||||
approval={"status": "pending", "policy_version": "approval-v2"},
|
||||
)
|
||||
materialization = DatasourceMaterialization(
|
||||
ref="materialization:disposed",
|
||||
datasource_ref="datasource:governed",
|
||||
revision=1,
|
||||
state="disposed",
|
||||
fingerprint="abc123",
|
||||
disposition={"reason": "retention_policy", "policy_version": "retention-v3"},
|
||||
)
|
||||
|
||||
self.assertEqual("pending", stage.approval["status"])
|
||||
self.assertEqual("retention_policy", materialization.disposition["reason"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,7 +10,9 @@ from govoplan_core.core.modules import (
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
with_documentation_structured_translations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
@@ -83,6 +85,97 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||
|
||||
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"steps": ["Review", "Execute"],
|
||||
"verification": "Confirm the result.",
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"steps": ["Prüfen", "Ausführen"],
|
||||
"verification": "Das Ergebnis bestätigen.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registry_for(topic).validate()
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized_documentation_metadata(topic, "de")["steps"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"workflow", localized_documentation_metadata(topic, "de")["kind"]
|
||||
)
|
||||
|
||||
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
|
||||
missing_version = DocumentationTopic(
|
||||
id="example.localized.missing-version",
|
||||
title="Localized",
|
||||
summary="Invalid contract.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RegistryError, "require structured_translation_version"
|
||||
):
|
||||
registry_for(missing_version).validate()
|
||||
|
||||
incomplete_shape = DocumentationTopic(
|
||||
id="example.localized.incomplete",
|
||||
title="Localized",
|
||||
summary="Invalid shape.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translation_version="1",
|
||||
structured_translations={"de": {"limitations": ["Eins"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||
registry_for(incomplete_shape).validate()
|
||||
|
||||
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={"steps": ["Review", "Execute"]},
|
||||
)
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(topic,),
|
||||
)
|
||||
|
||||
localized = with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={
|
||||
topic.id: {"steps": ["Prüfen", "Ausführen"]},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized.documentation[0].structured_translations["de"]["steps"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "preserve list length"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={topic.id: {"steps": ["Prüfen"]}},
|
||||
)
|
||||
|
||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||
resolver = lambda _context, keys: { # noqa: E731
|
||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
|
||||
from govoplan_core.security.http_fetch import (
|
||||
_PolicyRedirectHandler,
|
||||
fetch_http,
|
||||
is_http_url,
|
||||
validate_http_url,
|
||||
)
|
||||
from govoplan_core.security.outbound_http import (
|
||||
DEFAULT_FILE_TRANSFER_BYTES,
|
||||
DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||
@@ -21,6 +26,51 @@ from govoplan_core.security.outbound_http import (
|
||||
|
||||
|
||||
class HttpFetchTests(unittest.TestCase):
|
||||
def test_fetch_http_forwards_a_bounded_request_body(self) -> None:
|
||||
class Response(io.BytesIO):
|
||||
status = 200
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
opener = Mock()
|
||||
opener.open.return_value = Response(b"{}")
|
||||
with patch(
|
||||
"govoplan_core.security.http_fetch.validate_outbound_http_url",
|
||||
return_value="https://wiki.example.test/api.php",
|
||||
), patch(
|
||||
"govoplan_core.security.http_fetch.build_outbound_http_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
response = fetch_http(
|
||||
"https://wiki.example.test/api.php",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
body=b"action=edit",
|
||||
max_bytes=1024,
|
||||
)
|
||||
|
||||
request = opener.open.call_args.args[0]
|
||||
self.assertEqual("POST", request.get_method())
|
||||
self.assertEqual(b"action=edit", request.data)
|
||||
self.assertEqual(b"{}", response.body)
|
||||
|
||||
def test_fetch_http_rejects_an_oversized_request_body_before_transport(self) -> None:
|
||||
with patch(
|
||||
"govoplan_core.security.http_fetch.validate_outbound_http_url"
|
||||
) as validate:
|
||||
with self.assertRaisesRegex(ValueError, "request body exceeds"):
|
||||
fetch_http(
|
||||
"https://wiki.example.test/api.php",
|
||||
method="POST",
|
||||
body=b"x" * 1_000_001,
|
||||
)
|
||||
validate.assert_not_called()
|
||||
|
||||
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
|
||||
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
|
||||
self.assertTrue(is_http_url("http://example.test/catalog.json"))
|
||||
@@ -189,9 +239,17 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://catalog.example.test/releases",
|
||||
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||
headers={
|
||||
"Authorization": "Bearer secret",
|
||||
"Cookie": "session=secret",
|
||||
"X-OTRS-Header-Password": "secret",
|
||||
"X-Request-ID": "request-1",
|
||||
},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Catalog URL",
|
||||
sensitive_headers=("X-OTRS-Header-Password",),
|
||||
)
|
||||
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
@@ -215,9 +273,38 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertIsNone(redirected.get_header("Authorization"))
|
||||
self.assertIsNone(redirected.get_header("Cookie"))
|
||||
self.assertIsNone(redirected.get_header("X-otrs-header-password"))
|
||||
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||
self.assertIsNone(downgrade)
|
||||
|
||||
def test_core_redirects_preserve_caller_sensitive_headers_on_the_same_origin(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://desk.example.test/original",
|
||||
headers={"X-OTRS-Header-SessionID": "secret"},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Service-desk URL",
|
||||
sensitive_headers=("X-OTRS-Header-SessionID",),
|
||||
)
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
):
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://desk.example.test/final",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertEqual("secret", redirected.get_header("X-otrs-header-sessionid"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -9,12 +10,53 @@ from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
InfrastructureDependency,
|
||||
collect_infrastructure_dependency_inventory,
|
||||
deployment_capability_status,
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
load_infrastructure_capability_receipt,
|
||||
)
|
||||
|
||||
|
||||
class _InventoryProvider:
|
||||
module_id = "mail"
|
||||
capability_ids = ("mail.smtp",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
return (
|
||||
InfrastructureDependency(
|
||||
capability_id="mail.smtp",
|
||||
module_id="mail",
|
||||
dependency_type="smtp_endpoint",
|
||||
dependency_ref="mail-server:server-1",
|
||||
state="active",
|
||||
scope="system",
|
||||
summary="One active SMTP endpoint uses the deployment relay.",
|
||||
metrics={"credential_binding_count": 1},
|
||||
required_action="Rebind or retire the endpoint before removal.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FailingInventoryProvider:
|
||||
module_id = "files"
|
||||
capability_ids = ("files.storage",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
raise RuntimeError("database URL must not escape")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, providers: dict[str, object]) -> None:
|
||||
self.providers = providers
|
||||
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return tuple(self.providers)
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
return self.providers.get(name)
|
||||
|
||||
|
||||
def _receipt_payload() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
||||
def test_collects_non_secret_provider_dependency_inventory(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.mail": _InventoryProvider(),
|
||||
"unrelated.capability": object(),
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertTrue(inventory.complete)
|
||||
self.assertEqual(("mail.smtp",), inventory.inspected_capability_ids)
|
||||
self.assertEqual("mail-server:server-1", inventory.dependencies[0].dependency_ref)
|
||||
self.assertEqual("2026-08-24T12:00:00+00:00", inventory.generated_at)
|
||||
self.assertNotIn("database URL", json.dumps(inventory.to_dict()))
|
||||
|
||||
def test_provider_failure_makes_inventory_incomplete_without_leaking_error(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.files": (
|
||||
_FailingInventoryProvider()
|
||||
)
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
)
|
||||
|
||||
self.assertFalse(inventory.complete)
|
||||
self.assertEqual("error", inventory.providers[0].state)
|
||||
self.assertNotIn("database URL", str(inventory.providers[0].error))
|
||||
|
||||
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
||||
|
||||
|
||||
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["access:tenant:erase"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
scopes_grant_compatible(
|
||||
["system:tenants:write"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
@@ -1015,8 +1027,10 @@ finally:
|
||||
json={"mode": "destroy", "reason": "not supported"},
|
||||
)
|
||||
self.assertEqual(409, destructive.status_code, destructive.text)
|
||||
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
||||
self.assertIn("tenant_data_present", issue_codes)
|
||||
self.assertIn(
|
||||
"Direct destructive deletion is disabled",
|
||||
destructive.json()["detail"]["message"],
|
||||
)
|
||||
|
||||
with database.session() as session:
|
||||
empty_tenant = Tenant(
|
||||
@@ -1029,15 +1043,43 @@ finally:
|
||||
session.commit()
|
||||
empty_tenant_id = empty_tenant.id
|
||||
|
||||
destroyed = client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
||||
erasure_policy = client.patch(
|
||||
"/api/v1/admin/tenant-erasure-policy",
|
||||
headers=headers,
|
||||
json={"mode": "destroy", "reason": "empty tenant cleanup"},
|
||||
json={
|
||||
"production_profile": False,
|
||||
"required_approvals": 1,
|
||||
"preview_ttl_seconds": 900,
|
||||
"recent_authentication_seconds": 900,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, destroyed.status_code, destroyed.text)
|
||||
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
||||
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
||||
self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
|
||||
erasure_preview = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
|
||||
headers=headers,
|
||||
json={
|
||||
"idempotency_key": f"empty-destroy-{name}",
|
||||
"reason": "empty tenant cleanup",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
|
||||
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
|
||||
operation_id = erasure_preview.json()["id"]
|
||||
approved_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
|
||||
self.assertEqual("ready", approved_erasure.json()["state"])
|
||||
executed_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
|
||||
self.assertEqual("completed", executed_erasure.json()["state"])
|
||||
self.assertIsNone(executed_erasure.json()["reason"])
|
||||
|
||||
retired = client.request(
|
||||
"DELETE",
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
|
||||
from govoplan_core.core.navigation import (
|
||||
NavigationPreferences,
|
||||
NavigationSeparator,
|
||||
navigation_preferences_from_settings,
|
||||
resolve_navigation_preferences,
|
||||
update_navigation_preferences,
|
||||
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
|
||||
|
||||
|
||||
class NavigationPreferenceTests(unittest.TestCase):
|
||||
def test_separator_order_and_labels_round_trip_across_scopes(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Arbeit")
|
||||
preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,))
|
||||
stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences))
|
||||
self.assertEqual(preferences, stored)
|
||||
resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored)
|
||||
self.assertIsNone(resolved["dashboard"].section)
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual(separator, resolved["mail"].section)
|
||||
self.assertTrue(resolved["dashboard"].custom_layout)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertNotIn(separator.id, resolved) # Never an authorized destination.
|
||||
|
||||
def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None:
|
||||
system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",))
|
||||
user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),))
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user)
|
||||
self.assertEqual("user", resolved["files"].layout_source)
|
||||
self.assertEqual("Persönlich", resolved["files"].section.label)
|
||||
self.assertTrue(resolved["files"].visible)
|
||||
self.assertIsNone(resolved["mail"].section)
|
||||
flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=()))
|
||||
self.assertTrue(flat["files"].custom_layout)
|
||||
self.assertIsNone(flat["files"].section)
|
||||
self.assertTrue(flat["files"].locked)
|
||||
|
||||
def test_legacy_preferences_inherit_separator_layout(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Work")
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",)))
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertFalse(resolved["mail"].visible)
|
||||
|
||||
def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None:
|
||||
from pydantic import ValidationError
|
||||
from govoplan_core.api.v1.schemas import NavigationPreferencesPayload
|
||||
for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}):
|
||||
with self.subTest(separator=separator), self.assertRaises(ValidationError):
|
||||
NavigationPreferencesPayload.model_validate({"separators": [separator]})
|
||||
|
||||
def test_user_order_overrides_tenant_and_system_order(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("dashboard", "files", "mail", "campaign"),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from sqlalchemy import create_engine, inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.ownership import OwnershipTransfer
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
|
||||
|
||||
class OwnershipHistoryMigrationTests(unittest.TestCase):
|
||||
def test_upgrade_existing_databases_without_rewriting_ownership(self) -> None:
|
||||
for track in ("release", "dev"):
|
||||
for legacy in (True, False):
|
||||
with self.subTest(track=track, legacy=legacy):
|
||||
self._verify_upgrade(track, legacy=legacy)
|
||||
|
||||
def _verify_upgrade(self, track: str, *, legacy: bool) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-ownership-upgrade-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
|
||||
config = alembic_config(database_url=url, enabled_modules=(), migration_track=track)
|
||||
command.upgrade(config, "b47e6f809a13")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
evidence = [{"sequence": 1, "action": "requested"}]
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(engine) as session:
|
||||
session.add(OwnershipTransfer(
|
||||
id="transfer-1", tenant_id="tenant-1", resource_module="campaigns",
|
||||
resource_type="campaign", resource_id="campaign-1", kind="owner_initiated",
|
||||
status="awaiting_target_acceptance", current_owner_type="user",
|
||||
current_owner_id="owner-1", target_owner_type="user", target_owner_id="owner-2",
|
||||
initiated_by_type="user", initiated_by_id="owner-1", reason="Existing request",
|
||||
approvals=[{"actor_id": "owner-1"}], decisions=evidence,
|
||||
idempotency_key="request-1", canonical_request_hash="a" * 64,
|
||||
expires_at=now + timedelta(days=7), revision=3, metadata_={"retained": True},
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
with engine.begin() as connection:
|
||||
if legacy:
|
||||
connection.execute(text("ALTER TABLE core_ownership_transfers DROP COLUMN decisions"))
|
||||
before = dict(connection.execute(text(
|
||||
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
|
||||
)).mappings().one())
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
with engine.connect() as connection:
|
||||
columns = {column["name"]: column for column in inspect(connection).get_columns(
|
||||
"core_ownership_transfers"
|
||||
)}
|
||||
self.assertFalse(columns["decisions"]["nullable"])
|
||||
after = dict(connection.execute(text(
|
||||
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
|
||||
)).mappings().one())
|
||||
self.assertEqual({key: after[key] for key in before}, before)
|
||||
with Session(engine) as session:
|
||||
transfer = session.scalars(select(OwnershipTransfer)).one()
|
||||
self.assertEqual(transfer.decisions, [] if legacy else evidence)
|
||||
self.assertEqual(transfer.approvals, [{"actor_id": "owner-1"}])
|
||||
transfer.decisions = [*transfer.decisions, {"action": "accepted"}]
|
||||
session.commit()
|
||||
command.downgrade(config, "b47e6f809a13")
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
with Session(engine) as session:
|
||||
self.assertEqual(session.get(OwnershipTransfer, "transfer-1").decisions[-1], {
|
||||
"action": "accepted"
|
||||
})
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,8 @@ from govoplan_core.core.configuration_safety import (
|
||||
ui_managed_configuration_fields_requiring_approval,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
FunctionAssignmentEscalationRule,
|
||||
FunctionAssignmentGovernanceDecision,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
parse_policy_source_path,
|
||||
@@ -19,6 +21,34 @@ from govoplan_core.core.policy import (
|
||||
|
||||
|
||||
class PolicyContractTests(unittest.TestCase):
|
||||
def test_function_assignment_policy_serializes_delegation_and_escalation(self) -> None:
|
||||
decision = FunctionAssignmentGovernanceDecision(
|
||||
allowed=True,
|
||||
delegation_allowed=True,
|
||||
maximum_delegation_depth=2,
|
||||
maximum_delegated_validity_days=30,
|
||||
escalation_rules=(
|
||||
FunctionAssignmentEscalationRule(
|
||||
step="authority",
|
||||
target_function_id="function-escalation",
|
||||
timeout_hours=48,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
payload = decision.to_dict()
|
||||
|
||||
self.assertEqual(2, payload["maximum_delegation_depth"])
|
||||
self.assertEqual(30, payload["maximum_delegated_validity_days"])
|
||||
self.assertEqual(
|
||||
"function-escalation",
|
||||
payload["escalation_rules"][0]["target_function_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"function-escalation",
|
||||
decision.escalation_rule("authority").target_function_id,
|
||||
)
|
||||
|
||||
def test_policy_source_paths_are_stable_and_round_trip(self) -> None:
|
||||
self.assertEqual(policy_source_path("system"), "system")
|
||||
self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
|
||||
surface_ids=("example.route.main",),
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="work.examples",
|
||||
module_id="example",
|
||||
label="Examples",
|
||||
description="Review and update governed examples.",
|
||||
icon="list-checks",
|
||||
entry_path="/work/examples",
|
||||
route_path="/example",
|
||||
surface_ids=("example.route.main",),
|
||||
presentations=("task", "reader"),
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="Examples are unavailable",
|
||||
description="Your current responsibility does not include examples.",
|
||||
resolution="Ask the responsible administrator to review your assignment.",
|
||||
responsible_role="Access administrator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="example.summary",
|
||||
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend = response.json()["modules"][0]["frontend"]
|
||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||
product_surface = frontend["product_surfaces"][0]
|
||||
self.assertEqual("1", product_surface["contract_version"])
|
||||
self.assertEqual("work.examples", product_surface["id"])
|
||||
self.assertEqual("/work/examples", product_surface["entry_path"])
|
||||
self.assertEqual("/example", product_surface["route_path"])
|
||||
self.assertEqual(["task", "reader"], product_surface["presentations"])
|
||||
self.assertEqual(
|
||||
"authorization",
|
||||
product_surface["unavailable"]["reason"],
|
||||
)
|
||||
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||
self.assertEqual(
|
||||
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend["quick_access_tools"][0]["help_context_id"],
|
||||
)
|
||||
|
||||
def test_registry_rejects_product_surface_without_owner_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
frontend=FrontendModule(
|
||||
module_id=manifest.id,
|
||||
routes=frontend.routes,
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id=surface.id,
|
||||
module_id=surface.module_id,
|
||||
label=surface.label,
|
||||
description=surface.description,
|
||||
icon=surface.icon,
|
||||
entry_path=surface.entry_path,
|
||||
route_path="/missing",
|
||||
surface_ids=surface.surface_ids,
|
||||
unavailable=surface.unavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "unknown owner route"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = replace(
|
||||
manifest,
|
||||
frontend=replace(
|
||||
frontend,
|
||||
routes=(
|
||||
*frontend.routes,
|
||||
FrontendRoute(path="/shortcut", component="ShortcutPage"),
|
||||
),
|
||||
product_surfaces=(replace(surface, aliases=("/shortcut",)),),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "collides with a concrete route"):
|
||||
registry.validate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.tenant_erasure import (
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||
TenantErasurePreview,
|
||||
TenantErasureResource,
|
||||
TenantErasureStep,
|
||||
TenantErasureStepResult,
|
||||
collect_tenant_erasure_inventory,
|
||||
tenant_erasure_providers,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
module_id = "files"
|
||||
|
||||
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||
del session
|
||||
assert tenant_id == "tenant-1"
|
||||
return TenantErasurePreview(
|
||||
module_id=self.module_id,
|
||||
complete=True,
|
||||
resources=(
|
||||
TenantErasureResource(
|
||||
resource_type="file_blobs",
|
||||
count=2,
|
||||
disposition="erase",
|
||||
summary="Two tenant-owned file blobs will be erased.",
|
||||
),
|
||||
),
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="erase-blobs",
|
||||
kind="erase",
|
||||
summary="Erase tenant-owned file blobs.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
del session, tenant_id, step_id, idempotency_key
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary="Tenant file blobs erased.",
|
||||
metrics={"deleted": 2},
|
||||
)
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
return self.execute_tenant_erasure_step(
|
||||
session, tenant_id, step_id, idempotency_key
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
|
||||
self._provider = provider
|
||||
self._counts = counts
|
||||
|
||||
def manifests(self):
|
||||
return (
|
||||
SimpleNamespace(id="core"),
|
||||
SimpleNamespace(id="files"),
|
||||
SimpleNamespace(id="wiki"),
|
||||
)
|
||||
|
||||
def capability_names(self):
|
||||
if self._provider is None:
|
||||
return ()
|
||||
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
||||
|
||||
def capability(self, name: str):
|
||||
assert name.endswith("files")
|
||||
return self._provider
|
||||
|
||||
def tenant_summary_providers(self):
|
||||
if self._counts is None:
|
||||
return {}
|
||||
return {"files": lambda _session, _tenant_id: self._counts}
|
||||
|
||||
|
||||
def test_contract_rejects_unsafe_irreversible_step() -> None:
|
||||
with pytest.raises(ValueError, match="must be destructive"):
|
||||
TenantErasureStep(
|
||||
step_id="unsafe",
|
||||
kind="erase",
|
||||
summary="Invalid step.",
|
||||
destructive=False,
|
||||
irreversible=True,
|
||||
)
|
||||
|
||||
|
||||
def test_contract_rejects_cyclic_step_dependencies() -> None:
|
||||
with pytest.raises(ValueError, match="contain a cycle"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="first",
|
||||
kind="erase",
|
||||
summary="First.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
depends_on=("second",),
|
||||
),
|
||||
TenantErasureStep(
|
||||
step_id="second",
|
||||
kind="verify",
|
||||
summary="Second.",
|
||||
destructive=False,
|
||||
irreversible=False,
|
||||
depends_on=("first",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
|
||||
resource = TenantErasureResource(
|
||||
resource_type="files",
|
||||
count=1,
|
||||
disposition="erase",
|
||||
summary="One file exists.",
|
||||
)
|
||||
with pytest.raises(ValueError, match="steps or an explicit blocker"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
resources=(resource,),
|
||||
)
|
||||
|
||||
|
||||
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(provider=_Provider()),
|
||||
object(),
|
||||
"tenant-1",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert inventory.complete
|
||||
assert inventory.allowed
|
||||
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
|
||||
assert inventory.modules[1].steps[0].irreversible
|
||||
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
|
||||
|
||||
|
||||
def test_summary_fallback_blocks_when_data_exists() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(counts={"file_blobs": 3}),
|
||||
object(),
|
||||
"tenant-1",
|
||||
)
|
||||
|
||||
files = next(item for item in inventory.modules if item.module_id == "files")
|
||||
assert inventory.complete
|
||||
assert not inventory.allowed
|
||||
assert files.resources[0].disposition == "unavailable"
|
||||
assert files.blockers == (
|
||||
"Tenant-owned data exists but the module has no erasure provider.",
|
||||
)
|
||||
|
||||
|
||||
def test_provider_identity_must_match_capability_suffix() -> None:
|
||||
provider = _Provider()
|
||||
provider.module_id = "mail"
|
||||
|
||||
with pytest.raises(ValueError, match="identity"):
|
||||
tenant_erasure_providers(_Registry(provider=provider))
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.tickets import (
|
||||
CAPABILITY_TICKET_CASE_ESCALATION,
|
||||
CAPABILITY_TICKET_ROUTING,
|
||||
TicketCaseEscalationCommand,
|
||||
TicketCaseEscalationResult,
|
||||
TicketRoutingPlan,
|
||||
TicketRoutingRequest,
|
||||
ticket_case_escalation_provider,
|
||||
ticket_routing_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def route_ticket(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return TicketRoutingPlan(provider_id="helpdesk", queue_ref="citizen-service")
|
||||
|
||||
def escalate_ticket(self, session, principal, *, command):
|
||||
del session, principal, command
|
||||
return TicketCaseEscalationResult(
|
||||
provider_id="cases",
|
||||
case_id="case-1",
|
||||
case_number="CASE-1",
|
||||
case_url="/cases/case-1",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities):
|
||||
self.capabilities = capabilities
|
||||
|
||||
def has_capability(self, name):
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name):
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class TicketContractTests(unittest.TestCase):
|
||||
def test_optional_providers_fail_open_when_absent(self) -> None:
|
||||
registry = _Registry({})
|
||||
self.assertIsNone(ticket_routing_provider(registry))
|
||||
self.assertIsNone(ticket_case_escalation_provider(registry))
|
||||
|
||||
def test_optional_providers_resolve_structurally(self) -> None:
|
||||
provider = _Provider()
|
||||
registry = _Registry(
|
||||
{
|
||||
CAPABILITY_TICKET_ROUTING: provider,
|
||||
CAPABILITY_TICKET_CASE_ESCALATION: provider,
|
||||
}
|
||||
)
|
||||
self.assertIs(provider, ticket_routing_provider(registry))
|
||||
self.assertIs(provider, ticket_case_escalation_provider(registry))
|
||||
|
||||
def test_commands_validate_tenant_time_and_relative_case_link(self) -> None:
|
||||
instant = datetime(2026, 8, 22, 9, 0, tzinfo=UTC)
|
||||
request = TicketRoutingRequest(
|
||||
tenant_id="tenant-1",
|
||||
ticket_id="ticket-1",
|
||||
ticket_type="request",
|
||||
priority="normal",
|
||||
title="Broken streetlight",
|
||||
received_at=instant,
|
||||
)
|
||||
self.assertEqual("ticket-1", request.ticket_id)
|
||||
|
||||
command = TicketCaseEscalationCommand(
|
||||
tenant_id="tenant-1",
|
||||
ticket_id="ticket-1",
|
||||
ticket_number="TKT-1",
|
||||
title="Broken streetlight",
|
||||
case_type_key="service-request",
|
||||
occurred_at=instant,
|
||||
idempotency_key="escalation-1",
|
||||
)
|
||||
self.assertEqual("service-request", command.case_type_key)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
TicketCaseEscalationResult(
|
||||
provider_id="cases",
|
||||
case_id="case-1",
|
||||
case_number="CASE-1",
|
||||
case_url="https://other.example/cases/1",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"initialJs": {
|
||||
"rawBytes": 524288,
|
||||
"gzipBytes": 163840
|
||||
"gzipBytes": 164128
|
||||
},
|
||||
"asyncChunk": {
|
||||
"rawBytes": 393216,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
|
||||
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function AddressExplorerScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
|
||||
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
|
||||
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
|
||||
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
|
||||
"addresses:address_book:write", "addresses:address_book:delete",
|
||||
"addresses:address_list:write", "addresses:address_list:delete",
|
||||
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<AddressBookPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
|
||||
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
|
||||
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
|
||||
import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
|
||||
const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" },
|
||||
tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
|
||||
export default function CampaignAttachmentsScenario() {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||
{ id: "files", label: "Files", version: "fixture", uiCapabilities: available ? {
|
||||
"files.fileExplorer": { ManagedFileChooser, listFileSpaces }
|
||||
} : {} }
|
||||
];
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
|
||||
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
|
||||
<ConcurrencyConflictProvider>
|
||||
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
|
||||
</ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { apiPostJson } from "../src/api/client";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
function job(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`,
|
||||
review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides };
|
||||
}
|
||||
|
||||
export default function CampaignBulkReviewScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buildToken, setBuildToken] = useState("build-one");
|
||||
const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)),
|
||||
job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }),
|
||||
job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }),
|
||||
job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }),
|
||||
job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })];
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<main><button onClick={() => setOpen(true)}>Open grouped review</button>
|
||||
<button data-testid="replace-build" onClick={() => setBuildToken("build-two")}>Replace build</button>
|
||||
{open && <BulkMessageReviewDialog rows={rows} buildToken={buildToken} disabled={params.has("read-only")}
|
||||
onClose={() => setOpen(false)} onAccept={async (selection) => {
|
||||
await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection);
|
||||
}} />}
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel";
|
||||
|
||||
export default function CampaignDeliveryPolicyScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"}>
|
||||
<CampaignDeliveryPolicyPanel settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }}
|
||||
scope={params.get("scope") === "tenant" ? "tenant" : "system"} canWrite={!params.has("readonly")} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review",
|
||||
"campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
export default function CampaignDeliveryProgressScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="delivery-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Real optional-module settings surface with test-owned HTTP responses only.
|
||||
import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||
{ id: "mail", label: "Mail", version: "fixture" }
|
||||
];
|
||||
|
||||
export default function CampaignMailSettingsScenario() {
|
||||
const language = new URLSearchParams(window.location.search).get("language") ?? "en";
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider>
|
||||
<MailSettingsPage settings={settings} campaignId="campaign-mail" />
|
||||
</ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||
import {
|
||||
HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn,
|
||||
type HeaderAddressValues
|
||||
} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const initialAddresses = [
|
||||
{ name: "Alpha", email: "alpha@example.test" },
|
||||
{ name: "Beta", email: "beta@example.test" },
|
||||
{ name: "Zulu", email: "zulu@example.test" }
|
||||
];
|
||||
const initialVersion: CampaignVersionDetail = {
|
||||
id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"',
|
||||
current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false,
|
||||
raw_json: {
|
||||
campaign: { name: "Recipient ordering" }, server: {},
|
||||
recipients: { allow_individual_to: true, to: initialAddresses },
|
||||
entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] }
|
||||
}, editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||
};
|
||||
|
||||
export default function CampaignRecipientOrderScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider><RecipientOrderEditor /></ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
|
||||
function RecipientOrderEditor() {
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [error, setError] = useState("");
|
||||
const [dialog, setDialog] = useState<"entry" | "header" | null>(null);
|
||||
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []);
|
||||
const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false,
|
||||
reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" });
|
||||
const entries = editor.displayDraft.entries as { defaults: Record<string, unknown>; inline: Record<string, unknown>[] };
|
||||
const recipients = editor.displayDraft.recipients as Record<string, unknown>;
|
||||
const entry = entries?.inline?.[0] ?? {};
|
||||
return <main>
|
||||
<button onClick={() => setDialog("entry")}>Edit individual addresses</button>
|
||||
<button onClick={() => setDialog("header")}>Edit global addresses</button>
|
||||
<button disabled={!editor.dirty || editor.saving} onClick={() => void editor.saveDraft()}>Save campaign</button>
|
||||
<output data-testid="recipient-order">{JSON.stringify(entry.to)}</output>
|
||||
<output data-testid="global-order">{JSON.stringify(recipients?.to)}</output>
|
||||
<output data-testid="primary-address">{String(entry.email ?? "")}</output>
|
||||
<output data-testid="recipient-dirty">{String(editor.dirty)}</output>
|
||||
<output data-testid="recipient-error">{editor.localError || error}</output>
|
||||
{dialog === "entry" && <RecipientAddressEditorDialog
|
||||
entry={entry} index={0} locked={false} recipientsSection={recipients} entryDefaults={entries.defaults ?? {}}
|
||||
onSave={(values, merges) => {
|
||||
// Same owning helper as RecipientDataPage; no test copy of ordering or
|
||||
// legacy primary-address/merge-field normalization.
|
||||
editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]);
|
||||
setDialog(null);
|
||||
}} onClose={() => setDialog(null)} />}
|
||||
{dialog === "header" && <HeaderAddressEditorDialog title="Global To addresses"
|
||||
columns={[getAddressColumn("to")]} values={{ to: recipients.to } as HeaderAddressValues} locked={false}
|
||||
onSave={(values) => { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage";
|
||||
import { useState } from "react";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
export default function CampaignReportScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("reporter");
|
||||
const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"];
|
||||
const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read :
|
||||
[...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{params.has("switch-account") && <button type="button" onClick={() => setAccount("another-reporter")}>Switch fixture account</button>}
|
||||
<CampaignReportPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="report-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails";
|
||||
import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useState } from "react";
|
||||
|
||||
const issues = Array.from({ length: 12 }, (_, index) => [
|
||||
{ code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` },
|
||||
{ code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` }
|
||||
]).flat();
|
||||
const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" }));
|
||||
|
||||
export default function CampaignReviewDetailsScenario() {
|
||||
const [count, setCount] = useState(12);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<button onClick={() => setCount(2)}>Reduce fixture details</button>
|
||||
<section aria-label="Validation details fixture"><ValidationDetails issues={issues.slice(0, count * 2)} /></section>
|
||||
<section aria-label="Repeated files fixture"><RepeatedFilesDetails findings={findings.slice(0, count)} /></section>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
export default function CampaignReviewScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth;
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{params.has("race") && <button onClick={() => setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current); next.set("version", "replacement-version"); return next;
|
||||
})}>Switch fixture version</button>}
|
||||
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} campaignId="review-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const initialVersion: CampaignVersionDetail = {
|
||||
id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"',
|
||||
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||
raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} },
|
||||
editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||
};
|
||||
|
||||
export default function CampaignSavingScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider><SavingEditor /></ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
|
||||
function SavingEditor() {
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []);
|
||||
const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false,
|
||||
reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" });
|
||||
const subject = (editor.displayDraft.template as Record<string, unknown>)?.subject ?? "";
|
||||
return <main>
|
||||
<label>Subject<input aria-label="Subject" value={String(subject)} onChange={(event) => editor.patch(["template", "subject"], event.target.value)} /></label>
|
||||
<button disabled={editor.saving} onClick={() => void editor.saveDraft().then((saved) => setResult(String(saved)))}>Save draft</button>
|
||||
<button disabled={editor.saving} onClick={() => { void editor.saveDraft(); void editor.saveDraft(); }}>Request save twice</button>
|
||||
<button disabled={editor.saving} onClick={() => void editor.discardDraft()}>Discard draft</button>
|
||||
<button onClick={() => setVersion({ ...initialVersion, raw_json: { ...initialVersion.raw_json, template: { subject: "Background refresh" } } })}>Background refresh</button>
|
||||
<Link to="/?campaign-saving&elsewhere">Other route</Link>
|
||||
<output data-testid="save-busy">{String(editor.saving)}</output>
|
||||
<output data-testid="save-dirty">{String(editor.dirty)}</output>
|
||||
<output data-testid="save-status">{editor.saveState}</output>
|
||||
<output data-testid="save-error">{editor.localError || error}</output>
|
||||
<output data-testid="save-result">{result}</output>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Exercise the owning Campaign hook, with network responses controlled by tests.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData";
|
||||
|
||||
export default function CampaignWorkspaceScenario() {
|
||||
const [campaignId, setCampaignId] = useState("campaign-a");
|
||||
const [accessToken, setAccessToken] = useState("token-a");
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true });
|
||||
return <main>
|
||||
<button onClick={() => void reload()}>Reload workspace</button>
|
||||
<button onClick={() => void reload({ force: true })}>Force reload workspace</button>
|
||||
<button onClick={() => setCampaignId("campaign-b")}>Switch campaign</button>
|
||||
<button onClick={() => setAccessToken("token-b")}>Switch identity</button>
|
||||
<button onClick={() => setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("version", "selected-version");
|
||||
return next;
|
||||
})}>Select version</button>
|
||||
<output data-testid="workspace-campaign">{data.campaign?.id ?? "none"}</output>
|
||||
<output data-testid="workspace-version">{data.currentVersion?.id ?? "none"}</output>
|
||||
<output data-testid="workspace-revision">{data.currentVersion?.edit_revision ?? 0}</output>
|
||||
<output data-testid="workspace-loading">{String(loading)}</output>
|
||||
<output data-testid="workspace-error">{error}</output>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||
import DataGridLayoutScenario from "./DataGridLayoutScenario";
|
||||
import NavigationLayoutScenario from "./NavigationLayoutScenario";
|
||||
import ManagedArchiveScenario from "./ManagedArchiveScenario";
|
||||
import FilesToolbarScenario from "./FilesToolbarScenario";
|
||||
import CredentialReferencesScenario from "./CredentialReferencesScenario";
|
||||
import FormControlLayoutScenario from "./FormControlLayoutScenario";
|
||||
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
|
||||
import CampaignReportScenario from "./CampaignReportScenario";
|
||||
import ModuleLayoutScenario from "./ModuleLayoutScenario";
|
||||
import HelpCenterScenario from "./HelpCenterScenario";
|
||||
import NotificationFilterScenario from "./NotificationFilterScenario";
|
||||
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
|
||||
import SearchFiltersScenario from "./SearchFiltersScenario";
|
||||
import AddressExplorerScenario from "./AddressExplorerScenario";
|
||||
import MailFolderExplorerScenario from "./MailFolderExplorerScenario";
|
||||
import MailToolbarScenario from "./MailToolbarScenario";
|
||||
import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario";
|
||||
import CampaignSavingScenario from "./CampaignSavingScenario";
|
||||
import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario";
|
||||
import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario";
|
||||
import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario";
|
||||
import CampaignReviewScenario from "./CampaignReviewScenario";
|
||||
import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
|
||||
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
|
||||
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
|
||||
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
|
||||
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
|
||||
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
|
||||
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||
import ActionToolbar from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
@@ -25,14 +56,26 @@ import StatePanel from "../src/components/StatePanel";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import WysiwygEditor from "../src/components/WysiwygEditor";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import HelpMenu from "../src/layout/HelpMenu";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types";
|
||||
import { projectProductNavigation } from "../src/platform/productSurfaces";
|
||||
import type {
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
EffectiveViewProjection,
|
||||
PlatformNavItem,
|
||||
PlatformWebModule,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessToolMetadata
|
||||
} from "../src/types";
|
||||
|
||||
export default function ConformanceApp() {
|
||||
const location = useLocation();
|
||||
@@ -40,6 +83,63 @@ export default function ConformanceApp() {
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
|
||||
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
|
||||
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-workspace")) return <CampaignWorkspaceScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-report")) return <CampaignReportScenario />;
|
||||
if (new URLSearchParams(location.search).has("module-layouts")) return <ModuleLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("help-center")) return <HelpCenterScenario />;
|
||||
if (new URLSearchParams(location.search).has("notification-filter")) return <NotificationFilterScenario />;
|
||||
if (new URLSearchParams(location.search).has("multi-select-filter")) return <MultiSelectFilterScenario />;
|
||||
if (new URLSearchParams(location.search).has("search-filters")) return <SearchFiltersScenario />;
|
||||
if (new URLSearchParams(location.search).has("address-explorer")) return <AddressExplorerScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-folder-explorer")) return <MailFolderExplorerScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-toolbar")) return <MailToolbarScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-delivery-progress")) return <CampaignDeliveryProgressScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-saving")) return <CampaignSavingScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-mail-settings")) return <CampaignMailSettingsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-attachments")) return <CampaignAttachmentsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-recipient-order")) return <CampaignRecipientOrderScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-review")) return <CampaignReviewScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-bulk-review")) return <CampaignBulkReviewScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-review-details")) return <CampaignReviewDetailsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-delivery-policy")) return <CampaignDeliveryPolicyScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-credential-policy")) return <MailCredentialPolicyScenario />;
|
||||
|
||||
if (new URLSearchParams(location.search).has("data-grid-layout")) return <DataGridLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("managed-archive")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <ManagedArchiveScenario language={params.get("language") ?? "en"} downloadAllowed={!params.has("no-download")} />;
|
||||
}
|
||||
if (new URLSearchParams(location.search).has("navigation-layout")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <NavigationLayoutScenario scope={(params.get("scope") ?? "user") as NavigationPreferenceScope} language={params.get("language") ?? "en"} disabled={params.has("disabled")} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("dialog-layout")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <DialogLayoutScenario templates={params.get("fixture") === "templates"} language={params.get("language") ?? "de"} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("wysiwyg-lifecycle")) {
|
||||
return <WysiwygLifecycleScenario source={new URLSearchParams(location.search).get("mode") === "source"} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("product-navigation")) {
|
||||
return <ProductNavigationScenario />;
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/forms/public/")) {
|
||||
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname === "/forms-runtime") {
|
||||
return <FormsRuntimePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname.startsWith("/forms-runtime/")) {
|
||||
return <FormInstancePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
@@ -172,6 +272,68 @@ export default function ConformanceApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function WysiwygLifecycleScenario({ source }: { source: boolean }) {
|
||||
const initialHtml = source
|
||||
? '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>'
|
||||
: "<p>Legacy <i>template</i></p>";
|
||||
const [value, setValue] = useState(initialHtml);
|
||||
const [changeCount, setChangeCount] = useState(0);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [mounted, setMounted] = useState(true);
|
||||
|
||||
return <main className="conformance-root">
|
||||
<h1>Rich-text lifecycle fixture</h1>
|
||||
<Button onClick={() => setDisabled((current) => !current)}>Toggle read-only</Button>
|
||||
<Button onClick={() => setMounted((current) => !current)}>Toggle editor mount</Button>
|
||||
<Button onClick={() => setValue(initialHtml.replace("Legacy", "Reloaded"))}>Load another value</Button>
|
||||
<output data-testid="wysiwyg-change-count">{changeCount}</output>
|
||||
<pre data-testid="wysiwyg-controlled-value">{value}</pre>
|
||||
{mounted && <WysiwygEditor
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
ariaLabel="Rich-text fixture content"
|
||||
labels={{ visual: "Visual fixture mode", source: "Source fixture mode" }}
|
||||
onChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
setChangeCount((current) => current + 1);
|
||||
}}
|
||||
/>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function ProductNavigationScenario() {
|
||||
const projection = useMemo(
|
||||
() => projectProductNavigation(
|
||||
PRODUCT_NAV_ITEMS,
|
||||
PRODUCT_NAV_MODULES,
|
||||
PRODUCT_NAV_AUTH
|
||||
),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<div className="app-shell" data-conformance-id="product-navigation">
|
||||
<IconRail
|
||||
navItems={projection.primaryItems}
|
||||
allToolItems={projection.allToolItems}
|
||||
productAreas={PRODUCT_NAV_AREAS}
|
||||
/>
|
||||
<main className="main-area">
|
||||
<PageLayout
|
||||
archetype="overview"
|
||||
mode="embedded"
|
||||
title="Anwohnerparkausweis bearbeiten"
|
||||
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
|
||||
>
|
||||
<StatePanel
|
||||
title="Vorgang ist bereit"
|
||||
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
|
||||
/>
|
||||
</PageLayout>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpConformanceScenario() {
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="help-heading">
|
||||
@@ -290,6 +452,132 @@ const CONFORMANCE_AUTH = {
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"tasks:item:read",
|
||||
"calendar:event:read",
|
||||
"mail:mailbox:read",
|
||||
"postbox:message:read",
|
||||
"files:file:read"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
|
||||
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
|
||||
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
|
||||
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
|
||||
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
|
||||
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
|
||||
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
|
||||
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
|
||||
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
|
||||
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
|
||||
productModule("tasks", productSurface({
|
||||
id: "work.items",
|
||||
moduleId: "tasks",
|
||||
label: "i18n:govoplan-core.product_surface.work",
|
||||
description: "i18n:govoplan-core.product_surface.work_description",
|
||||
iconName: "list-checks",
|
||||
entryPath: "/work",
|
||||
routePath: "/tasks",
|
||||
surfaceIds: ["tasks.nav.tasks"],
|
||||
anyOf: ["tasks:item:read"]
|
||||
})),
|
||||
productModule("files", productSurface({
|
||||
id: "records.files",
|
||||
moduleId: "files",
|
||||
label: "i18n:govoplan-core.product_surface.files",
|
||||
description: "i18n:govoplan-core.product_surface.files_description",
|
||||
iconName: "folder",
|
||||
entryPath: "/documents",
|
||||
routePath: "/files",
|
||||
surfaceIds: ["files.nav.files"],
|
||||
anyOf: ["files:file:read"]
|
||||
})),
|
||||
productModule("mail", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "mail",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/mail",
|
||||
surfaceIds: ["mail.nav.mail"],
|
||||
anyOf: ["mail:mailbox:read"],
|
||||
aliases: ["/inbox"]
|
||||
})),
|
||||
productModule("postbox", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "postbox",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/postbox",
|
||||
surfaceIds: ["postbox.nav.postbox"],
|
||||
anyOf: ["postbox:message:read"],
|
||||
aliases: ["/inbox"],
|
||||
order: 20
|
||||
})),
|
||||
productModule("calendar", productSurface({
|
||||
id: "meetings.calendar",
|
||||
moduleId: "calendar",
|
||||
label: "i18n:govoplan-core.product_surface.calendar",
|
||||
description: "i18n:govoplan-core.product_surface.calendar_description",
|
||||
iconName: "calendar",
|
||||
entryPath: "/agenda",
|
||||
routePath: "/calendar",
|
||||
surfaceIds: ["calendar.nav.calendar"],
|
||||
anyOf: ["calendar:event:read"]
|
||||
}))
|
||||
];
|
||||
|
||||
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
|
||||
return { id, label: id, version: "test", productSurfaces: [surface] };
|
||||
}
|
||||
|
||||
function productSurface(
|
||||
partial: Pick<ProductSurfaceContribution,
|
||||
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
|
||||
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
|
||||
): ProductSurfaceContribution {
|
||||
return {
|
||||
contractVersion: "1",
|
||||
presentations: ["task", "reader"],
|
||||
capabilityIds: [],
|
||||
searchSourceIds: [],
|
||||
helpContextIds: [],
|
||||
documentationTopicIds: [],
|
||||
allOf: [],
|
||||
aliases: [],
|
||||
order: 10,
|
||||
unavailable: {
|
||||
reason: "authorization",
|
||||
title: "Not available",
|
||||
description: "The destination is not available for this responsibility.",
|
||||
resolution: "Ask the access administrator to review the assignment."
|
||||
},
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
const FORMS_RUNTIME_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"forms_runtime:submission:assist",
|
||||
"forms_runtime:submission:participate",
|
||||
"forms_runtime:workspace:read",
|
||||
"forms_runtime:workspace:write"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const CONFORMANCE_SETTINGS: ApiSettings = {
|
||||
apiBaseUrl: "",
|
||||
apiKey: "",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Exercise the real shared credential editor with Mail's optional capability.
|
||||
// API fixtures supply public metadata only; this never loads or saves secrets.
|
||||
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
|
||||
import type { ApiSettings, PlatformWebModule } from "../src/types";
|
||||
|
||||
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const modules: PlatformWebModule[] = [{
|
||||
id: "mail", label: "Mail", version: "1",
|
||||
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
|
||||
}];
|
||||
|
||||
export default function CredentialReferencesScenario() {
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
|
||||
import Button from "../src/components/Button";
|
||||
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
|
||||
type Row = { id: string; name: string; detail: string };
|
||||
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
|
||||
|
||||
/** Genuine shared grid: intentionally undersized legacy action preference. */
|
||||
export default function DataGridLayoutScenario() {
|
||||
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
|
||||
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
|
||||
const composite = mode === "composite";
|
||||
const [width, setWidth] = useState(900);
|
||||
const [mounted, setMounted] = useState(true);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const [extraAction, setExtraAction] = useState(false);
|
||||
const [clicked, setClicked] = useState("");
|
||||
const columns = useMemo<DataGridColumn<Row>[]>(() => [
|
||||
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
|
||||
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
|
||||
{
|
||||
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
|
||||
minWidth: mode === "oversized" ? 500 : undefined,
|
||||
columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end",
|
||||
render: (row) => {
|
||||
const group = <TableActionGroup actions={[
|
||||
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setClicked(`Inspect ${row.name}`) },
|
||||
{ id: "up", label: "Move up", icon: <ArrowUp />, disabledReason: "Already first", onClick: () => undefined },
|
||||
{ id: "down", label: "Move down", icon: <ArrowDown />, onClick: () => setClicked("Move down") },
|
||||
{ id: "remove", label: "Remove row", icon: <Trash2 />, onClick: () => setClicked("Remove row") },
|
||||
extraAction && { id: "add", label: "Add below", icon: <Plus />, onClick: () => setClicked("Add below") }
|
||||
]} />;
|
||||
return composite ? <div role="group" aria-label="Composite controls" style={{ display: "flex", gap: 4, width: "100%" }}>
|
||||
<Button className="table-action-button" aria-label="Extra control" onClick={() => setClicked("Extra control")}><Plus /></Button>
|
||||
{group}
|
||||
</div> : group;
|
||||
}
|
||||
}
|
||||
], [behavior, composite, extraAction, mode]);
|
||||
return (
|
||||
<main style={{ padding: 16, minWidth: 0 }}>
|
||||
<h1>Data grid layout conformance</h1>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<Button onClick={() => setWidth(320)}>Narrow grid</Button>
|
||||
<Button onClick={() => setWidth(900)}>Wide grid</Button>
|
||||
<Button onClick={() => setMounted((value) => !value)}>Toggle grid mount</Button>
|
||||
<Button onClick={() => setEmpty((value) => !value)}>Toggle empty rows</Button>
|
||||
<Button onClick={() => setExtraAction((value) => !value)}>Toggle extra action</Button>
|
||||
</div>
|
||||
<output data-testid="clicked-action">{clicked}</output>
|
||||
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
|
||||
{mounted && <DataGrid
|
||||
id={`layout-conformance-${behavior}`}
|
||||
rows={empty ? [] : rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
initialFit={behavior === "free" ? "content" : "container"}
|
||||
resizeBehavior={behavior}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => setClicked("Add first row")} />}
|
||||
/>}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage";
|
||||
import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-templates/webui/src/styles/templates.css";
|
||||
import Button from "../src/components/Button";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
import { FormGrid } from "../src/components/ContentGrid";
|
||||
import FormField from "../src/components/FormField";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fixtureAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["templates:template:read", "templates:template:write"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (templates) return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[templateTranslations]}>
|
||||
<TemplatesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} />
|
||||
</PlatformLanguageProvider>;
|
||||
return <main className="conformance-root">
|
||||
<Button onClick={() => setOpen(true)}>Open layout fixture</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`LangeDialogüberschriftOhneTrennzeichen${"Zusatz".repeat(12)}`}
|
||||
description={`Referenz:${"abcdef0123456789".repeat(12)}`}
|
||||
onClose={() => setOpen(false)}
|
||||
footer={<><Button>Abbrechen</Button><Button variant="primary">Änderungen speichern</Button></>}
|
||||
>
|
||||
<DialogForm onSubmit={(event) => event.preventDefault()}>
|
||||
<DialogSection>
|
||||
<FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label={`Feldname${"Lang".repeat(20)}`}><input defaultValue={"Langer Wert ".repeat(20)} /></FormField>
|
||||
<FormField label="Auswahl"><select defaultValue="long"><option value="long">{"Lange Auswahlliste ".repeat(20)}</option></select></FormField>
|
||||
</FormGrid>
|
||||
</DialogSection>
|
||||
<FormField label="Mehrzeiliger Inhalt"><textarea defaultValue={"Nachweistext ".repeat(60)} rows={5} /></FormField>
|
||||
<div style={{ maxWidth: "100%", minWidth: 0, overflowX: "auto" }} data-testid="dialog-local-scroll">
|
||||
<table style={{ width: 1400 }}><tbody><tr><td>Absichtlich breite Tabelle</td><td>Letzte Tabellenspalte</td></tr></tbody></table>
|
||||
</div>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Real owning Files module with fixture-only authentication; tests intercept every API call.
|
||||
import { useLocation } from "react-router";
|
||||
import { useState } from "react";
|
||||
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fullAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["files:file:read", "files:file:download", "files:file:upload", "files:file:organize", "files:file:delete", "files:file:share", "access:role:read"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const readOnlyAuth = { ...fullAuth, scopes: ["files:file:read"] };
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function FilesToolbarScenario() {
|
||||
const parameters = new URLSearchParams(useLocation().search);
|
||||
const [nextSession, setNextSession] = useState(false);
|
||||
const auth = parameters.has("read-only") ? readOnlyAuth : fullAuth;
|
||||
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{parameters.has("session-switch") && <button type="button" onClick={() => setNextSession(true)}>Switch fixture session</button>}
|
||||
<FilesPage settings={nextSession ? { ...settings, accessToken: "fixture-next-session" } : settings}
|
||||
auth={nextSession ? { ...auth, user: { ...auth.user, id: "fixture-next-user" } } : auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
|
||||
import FormField from "../src/components/FormField";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import ToggleSwitch from "../src/components/ToggleSwitch";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { AttachmentRulesDataGrid } from "../../../govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay";
|
||||
import type { AttachmentRule } from "../../../govoplan-campaign/webui/src/features/campaigns/utils/attachments";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
/** Real shared controls and Campaign attachment editor; no API writes. */
|
||||
export default function FormControlLayoutScenario() {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [rules, setRules] = useState<AttachmentRule[]>([]);
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<main style={{ padding: 16, minWidth: 0 }}>
|
||||
<h1>Shared form-control layout</h1>
|
||||
<FormGrid columns={2} data-testid="mixed-form-grid">
|
||||
<FormField label="Password" help="Leave blank to retain the configured secret.">
|
||||
<PasswordField value={secret} onValueChange={setSecret} generator />
|
||||
</FormField>
|
||||
<ToggleSwitch label="Remove configured secret" checked={checked} onChange={setChecked} disabled={Boolean(secret)} />
|
||||
<FormField label={"Long translated field label with additional context ".repeat(4)}>
|
||||
<input aria-label="Long label field" />
|
||||
</FormField>
|
||||
<FormField label="Short label"><input aria-label="Short label field" /></FormField>
|
||||
<FormField label="Wrapped field"><input aria-label="Wrapped field" /></FormField>
|
||||
<GridItem><ToggleSwitch label="Wrapped switch" checked={checked} onChange={setChecked} /></GridItem>
|
||||
</FormGrid>
|
||||
<FormLayout columns={2} data-testid="mixed-form-layout" onSubmit={(event) => event.preventDefault()}>
|
||||
<FormField label="Other setting"><input aria-label="Other setting" /></FormField>
|
||||
<ToggleSwitch label="Other switch" checked={checked} onChange={setChecked} />
|
||||
</FormLayout>
|
||||
<h2>Global attachments</h2>
|
||||
<div data-testid="global-attachments">
|
||||
<AttachmentRulesDataGrid id="layout-global-attachments" rules={rules}
|
||||
settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }} campaignId="fixture-campaign"
|
||||
basePaths={[{ id: "fixture-source", name: "Fixture source", path: "fixtures" }]}
|
||||
onChange={setRules} />
|
||||
</div>
|
||||
<h2>Compact actions resist broad consumer styles</h2>
|
||||
<style>{".fixture-broad-button-style .btn { width: 100%; }"}</style>
|
||||
<div className="fixture-broad-button-style" data-testid="compact-action">
|
||||
<TableActionGroup actions={[{ id: "add", label: "Add fixture item", icon: <Plus />, onClick: () => setChecked(true) }]} />
|
||||
</div>
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
|
||||
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
export default function HelpCenterScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { MailProfileScope } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
export default function MailCredentialPolicyScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
|
||||
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
|
||||
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
|
||||
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
|
||||
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function MailFolderExplorerScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function MailToolbarScenario() {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const [tenant, setTenant] = useState("first");
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
|
||||
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Conformance-only composition: render the owning module, never a test copy.
|
||||
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fixtureAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
|
||||
const auth = downloadAllowed ? fixtureAuth : {
|
||||
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
|
||||
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
|
||||
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
|
||||
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
|
||||
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
|
||||
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
|
||||
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-committee/webui/src/styles/committee.css";
|
||||
import "../../../govoplan-voting/webui/src/styles/voting.css";
|
||||
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
|
||||
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
|
||||
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
|
||||
import "../../../govoplan-idm/webui/src/styles/idm.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function ModuleLayoutScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
|
||||
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
|
||||
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
|
||||
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
|
||||
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
|
||||
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
|
||||
"idm:relationship:read", "idm:relationship:write"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const context = { settings, auth };
|
||||
const module = params.get("module-layouts");
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
|
||||
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
|
||||
{module === "committee" && <CommitteePage {...context} />}
|
||||
{module === "voting" && <VotingPage {...context} />}
|
||||
{module === "scheduling" && <SchedulingPage {...context} />}
|
||||
{module === "risk" && <RiskCompliancePage {...context} />}
|
||||
{module === "organizations" && <OrganizationsPage {...context} />}
|
||||
{module === "idm" && <TypedRelationshipsPanel {...context} />}
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import Button from "../src/components/Button";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import MultiSelectFilter from "../src/components/MultiSelectFilter";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
const options = [
|
||||
{ value: "first", label: "First tag" },
|
||||
{ value: "long", label: "X".repeat(80) },
|
||||
{ value: "last", label: "Last tag" }
|
||||
];
|
||||
|
||||
export default function MultiSelectFilterScenario() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selection, setSelection] = useState<string[] | null>(null);
|
||||
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en">
|
||||
<main className="content-pad">
|
||||
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
|
||||
<Button>Outside action</Button>
|
||||
{filter}
|
||||
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
|
||||
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
|
||||
panelStyle={{ transform: "translateZ(0)" }}
|
||||
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
|
||||
>
|
||||
<div style={{ overflow: "hidden", maxHeight: 100 }}>
|
||||
<p>The filter escapes this clipped and transformed dialog.</p>
|
||||
{filter}
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { Folder, LayoutDashboard, Mail } from "lucide-react";
|
||||
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
|
||||
const items: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
|
||||
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
|
||||
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
|
||||
];
|
||||
const areas: ProductAreaContribution[] = [
|
||||
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
|
||||
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
|
||||
];
|
||||
|
||||
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
|
||||
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
|
||||
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
|
||||
} : null);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
|
||||
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
|
||||
<main style={{ minWidth: 0, padding: 16 }}>
|
||||
<h1>Navigation editor fixture</h1>
|
||||
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
|
||||
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
|
||||
</main>
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
|
||||
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
|
||||
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
|
||||
scopes: ["notifications:notification:read"], roles: [], groups: [],
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||
};
|
||||
|
||||
export default function NotificationFilterScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("filter-user");
|
||||
useEffect(() => {
|
||||
const changeAccount = () => setAccount("other-user");
|
||||
window.addEventListener("conformance-notification-account", changeAccount);
|
||||
return () => window.removeEventListener("conformance-notification-account", changeAccount);
|
||||
}, []);
|
||||
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
|
||||
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { StrictMode, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Button from "../src/components/Button";
|
||||
import FormField from "../src/components/FormField";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/layout.css";
|
||||
import "../src/styles/forms.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const GENERATOR_OPTIONS = { length: 24 };
|
||||
|
||||
function PasswordFieldScenario() {
|
||||
const [password, setPassword] = useState("fixture-unchanged-password");
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [changes, setChanges] = useState(0);
|
||||
|
||||
return (
|
||||
<main className="conformance-root">
|
||||
<h1>Optional password generator</h1>
|
||||
<FormField label="Editable password">
|
||||
<PasswordField
|
||||
data-testid="editable-password"
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
generator
|
||||
generatorOptions={GENERATOR_OPTIONS}
|
||||
helpContextId="access.authentication.password"
|
||||
helpModuleId="access"
|
||||
onValueChange={(value) => {
|
||||
setPassword(value);
|
||||
setChanges((count) => count + 1);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<output data-testid="password-changes">{changes}</output>
|
||||
<Button data-testid="toggle-generator-access" onClick={() => setDisabled((value) => !value)}>
|
||||
{disabled ? "Enable generation" : "Disable generation"}
|
||||
</Button>
|
||||
<FormField label="Sign-in password">
|
||||
<PasswordField value="fixture-sign-in-password" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Disabled password">
|
||||
<PasswordField generator disabled value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Read-only password">
|
||||
<PasswordField generator readOnly value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Deliberately no Core barrel or other fixture imports: they could eagerly load
|
||||
// the generator and mask a regression in this field's real lazy boundary.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"}>
|
||||
<PasswordFieldScenario />
|
||||
</PlatformLanguageProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,25 +1,140 @@
|
||||
// Narrow facade used only by the conformance build. It lets the optional
|
||||
// Quick Access module exercise its real rail without pulling the composed
|
||||
// application's generated module catalogue into this isolated test bundle.
|
||||
export { apiFetch } from "../src/api/client";
|
||||
// Narrow facade used only by the conformance build. It lets optional modules
|
||||
// exercise their real task surfaces without pulling the composed application's
|
||||
// generated module catalogue into this isolated test bundle.
|
||||
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
|
||||
export { fetchAuthGroups } from "../src/api/auth";
|
||||
export { default as FormSection } from "../src/components/FormSection";
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
|
||||
export type * from "../src/api/mailContracts";
|
||||
export type * from "../src/types";
|
||||
export { default as FieldLabel } from "../src/components/help/FieldLabel";
|
||||
export { default as PasswordField } from "../src/components/PasswordField";
|
||||
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
|
||||
export { default as ExplorerTree } from "../src/components/ExplorerTree";
|
||||
export type { ExplorerTreeNodeContext } from "../src/components/ExplorerTree";
|
||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions, DataGridPaginationBar } from "../src/components/table/DataGrid";
|
||||
export type { DataGridColumn, DataGridQueryState, DataGridListOption } from "../src/components/table/DataGrid";
|
||||
export { default as TableActionGroup } from "../src/components/table/TableActionGroup";
|
||||
export { revisionConflictFromError, threeWayMerge } from "../src/api/concurrency";
|
||||
export { useConcurrencyConflictResolver } from "../src/components/ConcurrencyConflictDialog";
|
||||
export { useRegisterUnsavedChanges, UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "../src/components/UnsavedChangesGuard";
|
||||
export { useDeltaWatermarks } from "../src/utils/deltaHooks";
|
||||
export { default as ReferenceSelect } from "../src/components/ReferenceSelect";
|
||||
export { unavailableReferenceOption } from "../src/components/ReferenceSelect";
|
||||
export { filterSearchableSelectOptions } from "../src/components/SearchableSelect";
|
||||
export type { ReferenceOptionProvider, ReferenceOption, CredentialReferenceSelectorContext, CredentialReferenceSelectorsUiCapability } from "../src/components/ReferenceSelect";
|
||||
export { apiReferenceOptionProvider } from "../src/platform/referenceProviders";
|
||||
export { fetchResourceAccessExplanation, fetchResourceAccessExplanationSubjects } from "../src/api/resourceAccess";
|
||||
export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser, ResourceAccessExplanationResponse, ResourceAccessExplanationSubjectsResponse } from "../src/api/resourceAccess";
|
||||
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
|
||||
export type { ActionBlockerReason } from "../src/components/ActionBlockerHint";
|
||||
export { default as MessageDisplayPanel } from "../src/components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment } from "../src/components/MessageDisplayPanel";
|
||||
export { default as GuidedReviewList } from "../src/components/GuidedReviewList";
|
||||
export { default as InlineHelp } from "../src/components/help/InlineHelp";
|
||||
export { default as ActionToolbar } from "../src/components/ActionToolbar";
|
||||
export { ToolbarGroup } from "../src/components/ActionToolbar";
|
||||
export { default as MultiSelectFilter } from "../src/components/MultiSelectFilter";
|
||||
export { default as CountBadge } from "../src/components/CountBadge";
|
||||
export { default as Button } from "../src/components/Button";
|
||||
export { default as Card } from "../src/components/Card";
|
||||
export { default as MetricGrid } from "../src/components/MetricGrid";
|
||||
export { default as MetricCard } from "../src/components/MetricCard";
|
||||
export { default as PageActionBar } from "../src/components/PageActionBar";
|
||||
export { default as PageLayout } from "../src/components/PageLayout";
|
||||
export { default as PageTitle } from "../src/components/PageTitle";
|
||||
export { default as AdminIconButton } from "../src/components/admin/AdminIconButton";
|
||||
export { default as ModuleSubnav } from "../src/layout/ModuleSubnav";
|
||||
export type { ModuleSubnavGroup } from "../src/layout/ModuleSubnav";
|
||||
export { default as DateTimeField } from "../src/components/DateTimeField";
|
||||
export { default as PeoplePicker } from "../src/components/people/PeoplePicker";
|
||||
export type { PeoplePickerItem, PeoplePickerSearch, PeoplePickerSearchGroup } from "../src/components/people/peoplePickerTypes";
|
||||
export type { FormatDateTimeOptions } from "../src/utils/datetime";
|
||||
export { default as SearchableSelect } from "../src/components/SearchableSelect";
|
||||
export type { SearchableSelectOption } from "../src/components/SearchableSelect";
|
||||
export { FormLayout } from "../src/components/ContentGrid";
|
||||
export { apiPatchJson, isApiError } from "../src/api/client";
|
||||
export { hasAnyScope } from "../src/utils/permissions";
|
||||
export { useEffectiveView, useViewSurfaces } from "../src/platform/ViewContext";
|
||||
export { isViewSurfaceVisible } from "../src/platform/views";
|
||||
export { MailServerFolderLookupResultView } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
|
||||
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
|
||||
export { adminErrorMessage } from "../src/components/admin/adminUtils";
|
||||
export { default as ConnectionTree } from "../src/components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
|
||||
export { default as StageRail } from "../src/components/StageRail";
|
||||
export type { StageRailTone } from "../src/components/StageRail";
|
||||
export { default as PolicyLockedHint } from "../src/components/PolicyLockedHint";
|
||||
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "../src/components/PolicyPathHelp";
|
||||
export type { NormalizedPolicySourcePathItem } from "../src/components/PolicyPathHelp";
|
||||
export { default as PolicySourcePath } from "../src/components/PolicySourcePath";
|
||||
export type { PolicySourcePathItem } from "../src/components/PolicySourcePath";
|
||||
export { PolicyRow, PolicyTable } from "../src/components/PolicyTable";
|
||||
export { default as MailServerSettingsPanel, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerImapSettings, MailServerSmtpSettings } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export { mergeDeltaRows } from "../src/utils/delta";
|
||||
export { ReferenceMultiSelect, customReferenceOption, staticReferenceOptionProvider } from "../src/components/ReferenceSelect";
|
||||
export { platformModuleReferenceProvider } from "../src/platform/referenceProviders";
|
||||
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
|
||||
export { default as ContentSection } from "../src/components/ContentSection";
|
||||
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
|
||||
export { default as Dialog } from "../src/components/Dialog";
|
||||
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
|
||||
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
|
||||
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
|
||||
export { default as FileDropZone } from "../src/components/FileDropZone";
|
||||
export { default as FilterBar } from "../src/components/FilterBar";
|
||||
export { default as FormField } from "../src/components/FormField";
|
||||
export { FormGrid, default as ContentGrid } from "../src/components/ContentGrid";
|
||||
export { default as IconButton } from "../src/components/IconButton";
|
||||
export { default as LoadingFrame } from "../src/components/LoadingFrame";
|
||||
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
|
||||
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
|
||||
export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
|
||||
export { default as SegmentedControl } from "../src/components/SegmentedControl";
|
||||
export {
|
||||
default as SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent
|
||||
} from "../src/components/SelectionList";
|
||||
export { default as StatePanel } from "../src/components/StatePanel";
|
||||
export { default as StatusBadge } from "../src/components/StatusBadge";
|
||||
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
|
||||
export {
|
||||
useGuardedNavigate,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard
|
||||
} from "../src/components/UnsavedChangesGuard";
|
||||
export {
|
||||
i18nMessage,
|
||||
usePlatformLanguage
|
||||
} from "../src/i18n/LanguageContext";
|
||||
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
|
||||
export {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
export { i18nMessage } from "../src/i18n/LanguageContext";
|
||||
export { hasScope } from "../src/utils/permissions";
|
||||
export { formatDateTime } from "../src/utils/datetime";
|
||||
export { insertAfter, moveArrayItem } from "../src/utils/arrayOrder";
|
||||
export { addressesFromValue, dedupeAddresses, parseMailboxAddressText } from "../src/utils/emailAddresses";
|
||||
export type { MailboxAddress } from "../src/utils/emailAddresses";
|
||||
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
|
||||
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
|
||||
export { default as WorkspaceLayout } from "../src/components/WorkspaceLayout";
|
||||
export type {
|
||||
ApiSettings,
|
||||
GlobalSearchProps,
|
||||
SearchContextContribution,
|
||||
SearchContextsUiCapability,
|
||||
DeltaDeletedItem,
|
||||
FilesManagedFileLinkTarget,
|
||||
AuthInfo,
|
||||
PlatformRouteContext,
|
||||
PlatformTranslations,
|
||||
QuickAccessRailProps,
|
||||
QuickAccessToolsUiCapability
|
||||
} from "../src/types";
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
void capabilityName;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import SearchPage from "../../../govoplan-search/webui/src/features/search/SearchPage";
|
||||
import GlobalSearch from "../../../govoplan-search/webui/src/components/GlobalSearch";
|
||||
import { searchFilterTranslations } from "../../../govoplan-search/webui/src/i18n/searchFilterTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import "../../../govoplan-search/webui/src/styles/search.css";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "search-user", account_id: "search-account", email: "search@example.test" },
|
||||
tenant: { id: "search-tenant", name: "Search fixture", slug: "search" },
|
||||
scopes: ["search:result:read"], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||
};
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "files", label: "Files", version: "1", uiCapabilities: { "search.contexts": { contexts: [{ id: "files.current", moduleId: "files", label: "Current files", pathPrefixes: ["/files"], resourceTypes: ["file"] }] } } },
|
||||
{ id: "mail", label: "Mail", version: "1" },
|
||||
];
|
||||
|
||||
export default function SearchFiltersScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("search-user");
|
||||
const accessToken = params.has("same-token") ? "search-user" : account;
|
||||
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||
const scopedAuth = useMemo(() => ({ ...auth,
|
||||
user: { ...auth.user, id: account, account_id: `${account}-account` },
|
||||
tenant: { ...auth.tenant, id: `${account}-tenant` },
|
||||
}), [account]);
|
||||
useEffect(() => {
|
||||
const changeAccount = () => setAccount("other-user");
|
||||
window.addEventListener("conformance-search-account", changeAccount);
|
||||
return () => window.removeEventListener("conformance-search-account", changeAccount);
|
||||
}, []);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[searchFilterTranslations]}>
|
||||
<PlatformModulesProvider modules={modules}>
|
||||
{params.has("overlay") ? <GlobalSearch settings={settings} auth={scopedAuth} /> : <SearchPage settings={settings} auth={scopedAuth} />}
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import ConformanceApp from "./ConformanceApp";
|
||||
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
|
||||
import { productSurfaceTranslations } from "../src/index";
|
||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
@@ -14,6 +16,7 @@ import "../src/styles/badges.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
||||
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||
@@ -35,9 +38,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="de">
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? (["navigation-layout", "managed-archive"].some((key) => new URLSearchParams(window.location.search).has(key)) ? "en" : "de")}
|
||||
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
|
||||
<UnsavedChangesProvider>
|
||||
<ConformanceApp />
|
||||
<Routes>
|
||||
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
|
||||
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
|
||||
<Route path="*" element={<ConformanceApp />} />
|
||||
</Routes>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Password field loading conformance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./PasswordFieldMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function mockAddresses(page: Page) {
|
||||
const errors: string[] = [];
|
||||
const writes: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const timestamp = "2026-09-01T12:00:00Z";
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() !== "GET") writes.push(path);
|
||||
let body: unknown = {};
|
||||
if (path === "/api/v1/addresses/address-books") body = { address_books: [{
|
||||
id: "book-fixture", name: "Fixture address book", scope_type: "tenant",
|
||||
source_kind: "local", read_only: false, contact_count: 0, created_at: timestamp, updated_at: timestamp
|
||||
}] };
|
||||
else if (path === "/api/v1/addresses/address-lists") body = { address_lists: [{
|
||||
id: "list-fixture", address_book_id: "book-fixture", name: "Fixture address list",
|
||||
source_kind: "local", read_only: false, entry_count: 0, created_at: timestamp, updated_at: timestamp
|
||||
}] };
|
||||
else if (path === "/api/v1/addresses/sync-sources") body = { sync_sources: [] };
|
||||
else if (path === "/api/v1/addresses/contacts") body = { contacts: [], total: 0, limit: 50, offset: 0 };
|
||||
else if (path.endsWith("/entries")) body = { entries: [] };
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return { errors, writes };
|
||||
}
|
||||
|
||||
test("Addressbook keeps Reload/New together and moves specialist actions out of the narrow tree header", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.setViewportSize({ width: 1360, height: 900 });
|
||||
await page.goto("/?address-explorer&language=en&theme=light");
|
||||
const toolbar = page.getByRole("toolbar", { name: "Address book actions", exact: true });
|
||||
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
|
||||
const create = toolbar.getByRole("button", { name: "Add address book", exact: true });
|
||||
await expect(reload).toBeEnabled();
|
||||
await expect(create).toBeVisible();
|
||||
const [reloadBox, createBox] = await Promise.all([reload.boundingBox(), create.boundingBox()]);
|
||||
expect(createBox!.x).toBeGreaterThan(1100);
|
||||
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
|
||||
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
|
||||
await expect(page.locator('.address-tree-header button')).toHaveCount(1);
|
||||
await toolbar.getByRole("button", { name: "Import / export", exact: true }).click();
|
||||
const transfer = page.getByRole("dialog", { name: "Import / export", exact: true });
|
||||
await expect(transfer.getByRole("button", { name: "Import contacts", exact: true })).toBeVisible();
|
||||
await expect(transfer.getByRole("button", { name: "Export address book", exact: true })).toBeVisible();
|
||||
await expect(transfer.getByRole("combobox", { name: "vCard export version" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await toolbar.getByRole("button", { name: "Connections", exact: true }).click();
|
||||
const connections = page.getByRole("dialog", { name: "Connections", exact: true });
|
||||
await expect(connections.getByRole("button", { name: "Connect CardDAV", exact: true })).toBeVisible();
|
||||
await expect(connections.getByRole("button", { name: "Connect LDAP or Active Directory", exact: true })).toBeVisible();
|
||||
await expect(connections.getByRole("button", { name: "Run sync", exact: true })).toBeDisabled();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Addressbook labels select only; folder buttons toggle, including after Reload", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.goto("/?address-explorer&language=en&theme=light");
|
||||
const root = page.locator('.address-tree-list > .explorer-tree-children > div').first();
|
||||
const label = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-node');
|
||||
const folder = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-toggle');
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||
await label.click();
|
||||
await expect(label).toHaveAttribute("aria-current", "true");
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Select an address book in this group.", { exact: false })).toBeVisible();
|
||||
await folder.click();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await label.click();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await page.locator('[data-page-action-slot="reload"] button').click();
|
||||
await expect(page.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(label).toHaveAttribute("aria-current", "true");
|
||||
await folder.click();
|
||||
const book = page.locator('.address-tree-list .explorer-tree-node').filter({ hasText: "Fixture address book" });
|
||||
await book.click();
|
||||
await expect(book).toHaveAttribute("aria-current", "true");
|
||||
await page.locator('.address-tree-header').getByRole("button", { name: "Manage", exact: true }).click();
|
||||
const manage = page.getByRole("dialog", { name: "Manage selected book or list", exact: true });
|
||||
await expect(manage.getByRole("button", { name: "Add address list", exact: true })).toBeVisible();
|
||||
await expect(manage.getByRole("button", { name: "Edit address book", exact: true })).toBeVisible();
|
||||
await expect(manage.locator('.form-section-separated').getByRole("button", { name: "Delete address book", exact: true })).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Addressbook specialist controls retain permission blockers in German", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.goto("/?address-explorer&language=de&read-only&theme=light");
|
||||
await expect(page.getByRole("button", { name: "Adressbuch hinzufügen", exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Import / Export", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Import / Export", exact: true });
|
||||
await expect(dialog.getByRole("button", { name: "Kontakte importieren", exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole("heading", { name: "In das ausgewählte Adressbuch importieren", exact: true })).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); });
|
||||
let revision = 1;
|
||||
const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] };
|
||||
let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {},
|
||||
attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }],
|
||||
global: [], zip } };
|
||||
const writes: Record<string, any>[] = [];
|
||||
const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: null, deleted: [], full: true, has_more: false, watermark: `w${revision}`
|
||||
} });
|
||||
if (url.pathname === "/api/v1/files/spaces") return route.fulfill({ json: { spaces: [{ id: "space-1", label: "My files", space_type: "managed", owner_type: "user", owner_id: "user-1" }] } });
|
||||
if (url.pathname === "/api/v1/files/folders") return route.fulfill({ json: { folders: [], next_cursor: null } });
|
||||
if (url.pathname === "/api/v1/files") return route.fulfill({ json: { files: [], next_cursor: null } });
|
||||
if (url.pathname === "/api/v1/files/delta") return route.fulfill({ json: { files: [], folders: [{ id: "folder-1", owner_type: "user", owner_id: "user-1", path: "letters", name: "letters" }], deleted: [], full: true, has_more: false, watermark: "files-w1" } });
|
||||
if (url.pathname.endsWith("/versions/version-attachments")) return route.fulfill({ json: version() });
|
||||
if (url.pathname.endsWith("/archive-encryption-policy")) return route.fulfill({ json: {
|
||||
available: true, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail"],
|
||||
policy_hash: "fixture", source_path: [], diagnostics: [], reason: "Legacy ZipCrypto is blocked by policy.", legacy_label: "Legacy ZipCrypto"
|
||||
} });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/autosave")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
raw = body.campaign_json; revision++;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-attachments");
|
||||
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
|
||||
return { writes, errors, zip };
|
||||
}
|
||||
|
||||
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const input = page.locator("#campaign-attachment-sources .chooser-display-input");
|
||||
for (const action of ["click", "Enter", "Space", "click"]) {
|
||||
if (action === "click") await input.click();
|
||||
else { await input.focus(); await page.keyboard.press(action); }
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: /Use.*folder|Select.*folder/i })).toBeEnabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("temporarily unavailable Files capability does not silently turn managed paths into text edits", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Toggle Files capability" }).click();
|
||||
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeDisabled();
|
||||
await expect(page.getByText(/The Files browser is currently unavailable/).first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Toggle Files capability" }).click();
|
||||
await page.locator("#campaign-attachment-sources .chooser-display-input").click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("attachment source corrections save without changing or reauthorizing legacy ZIP configuration", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]').fill("Updated source name");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(fixture.zip);
|
||||
expect(fixture.writes[0].campaign_json.attachments.base_paths[0].name).toBe("Updated source name");
|
||||
await page.reload();
|
||||
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function setup(page: Page, options: { firstFailure?: boolean; delay?: Promise<void> } = {}) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/v1/conformance/review-state", async (route) => {
|
||||
writes.push(route.request().postDataJSON());
|
||||
if (options.delay) await options.delay;
|
||||
if (options.firstFailure && writes.length === 1) {
|
||||
await route.fulfill({ status: 409, json: { detail: "Synthetic review revision conflict" } }); return;
|
||||
}
|
||||
await route.fulfill({ json: { saved: true } });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function openAttachmentGroup(page: Page, query = "") {
|
||||
page.on("pageerror", (error) => { throw error; });
|
||||
await page.goto(`/?campaign-bulk-review${query}`);
|
||||
await page.getByRole("button", { name: "Open grouped review" }).click();
|
||||
if (await page.getByRole("combobox").isEnabled()) await page.getByRole("combobox").selectOption("same-attachment-condition");
|
||||
return page.getByRole("dialog");
|
||||
}
|
||||
|
||||
test("grouped review accepts only exact selected eligible IDs with shared required reason", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(3);
|
||||
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
|
||||
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.getByRole("textbox").fill("The optional file is intentionally omitted for these recipients.");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes).toEqual([{ buildToken: "build-one", categoryKey: "same-attachment-condition", jobIds: ["job-000", "job-002"], reason: "The optional file is intentionally omitted for these recipients." }]);
|
||||
});
|
||||
|
||||
test("failed grouped save retains reason and selection, and retries explicitly", async ({ page }) => {
|
||||
const writes = await setup(page, { firstFailure: true });
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Accepted after inspecting frozen evidence.");
|
||||
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("Synthetic review revision conflict");
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("Accepted after inspecting frozen evidence.");
|
||||
await expect(dialog.getByRole("checkbox", { name: "job-001@example.test" })).not.toBeChecked();
|
||||
expect(writes).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(writes[1]).toEqual(writes[0]);
|
||||
});
|
||||
|
||||
test("grouped save blocks double clicks and dismissal until acknowledged", async ({ page }) => {
|
||||
let acknowledge!: () => void;
|
||||
const writes = await setup(page, { delay: new Promise<void>((resolve) => { acknowledge = resolve; }) });
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Shared operational exception.");
|
||||
const accept = dialog.getByRole("button", { name: "Accept 3 selected messages" });
|
||||
await accept.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); });
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
await expect(dialog.getByRole("button", { name: /^(Cancel|Abbrechen)$/ })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
acknowledge();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("grouped review is bounded and clearly counts the remaining matching messages", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page, "&large");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(200);
|
||||
await expect(dialog).toContainText("Showing 200 of 205 matching unreviewed messages in this category; 200 selected.");
|
||||
await dialog.getByRole("textbox").fill("Confirmed shared exception.");
|
||||
await dialog.getByRole("button", { name: "Accept 200 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes[0].jobIds).toHaveLength(200);
|
||||
});
|
||||
|
||||
test("a replaced build or missing permission cannot be bulk accepted", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
let dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Do not save stale build evidence.");
|
||||
await page.getByTestId("replace-build").evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(dialog.getByRole("alert")).toContainText("The build changed");
|
||||
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
|
||||
dialog = await openAttachmentGroup(page, "&read-only");
|
||||
await expect(dialog.getByRole("button", { name: /Accept .* selected messages/ })).toBeDisabled();
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("German bulk review labels and category changes preserve explicit scope", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page, "&language=de");
|
||||
await expect(dialog).toHaveAccessibleName("Gleichartige Prüfbedingungen bestätigen");
|
||||
await expect(dialog.getByRole("button", { name: "3 ausgewählte Nachrichten bestätigen" })).toBeDisabled();
|
||||
await dialog.getByRole("combobox").selectOption("other-condition");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
|
||||
await expect(dialog.getByRole("textbox", { name: "Gemeinsamer Prüfvermerk" })).toHaveValue("");
|
||||
await dialog.getByRole("button", { name: "1 ausgewählte Nachrichten bestätigen" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes[0]).toMatchObject({ categoryKey: "other-condition", jobIds: ["other-category"], reason: "" });
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function fixture(page: Page, options: { language?: string; scope?: string; readonly?: boolean; ceiling?: number; reject?: number; hold?: boolean } = {}) {
|
||||
let value: number | null = null;
|
||||
let revision = 1;
|
||||
let reject = options.reject;
|
||||
let release: (() => void) | undefined;
|
||||
let rejectRead = false;
|
||||
const writes: any[] = [];
|
||||
const errors: string[] = [];
|
||||
const inherited = options.ceiling ?? 25;
|
||||
const maximum = options.ceiling ?? 500;
|
||||
page.on("pageerror", (cause) => errors.push(cause.message));
|
||||
const state = () => ({ scope: options.scope ?? "system", synchronous_send_max_recipients: value,
|
||||
revision: String(revision).padStart(64, "0"), max_configurable_recipients: maximum,
|
||||
effective_max_recipients: value ?? inherited, inherited_max_recipients: inherited,
|
||||
absolute_max_recipients: 500, deployment_ceiling_explicit: Boolean(options.ceiling), deployment_max_recipients: maximum });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (!route.request().url().includes("/campaigns/settings/delivery-policy/")) return route.fulfill({ status: 404, json: { detail: "Not found" } });
|
||||
if (route.request().method() === "GET") return route.fulfill(rejectRead ? { status: 503, json: { detail: "Fixture reload unavailable" } } : { json: state() });
|
||||
expect(route.request().method()).toBe("PUT");
|
||||
const body = route.request().postDataJSON(); writes.push(body);
|
||||
if (options.hold) await new Promise<void>((resolve) => { release = resolve; });
|
||||
if (reject) {
|
||||
const status = reject; reject = undefined;
|
||||
return route.fulfill({ status, json: { detail: "Fixture policy save rejected; draft was not saved" } });
|
||||
}
|
||||
value = body.synchronous_send_max_recipients; revision += 1;
|
||||
return route.fulfill({ json: state() });
|
||||
});
|
||||
await page.goto(`/?campaign-delivery-policy&language=${options.language ?? "en"}&scope=${options.scope ?? "system"}${options.readonly ? "&readonly" : ""}`);
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue(String(inherited));
|
||||
return { writes, errors, release: () => release?.(), failRead: () => { rejectRead = true; } };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`administrator deliberately raises implicit 25 to 200 and reloads saved policy in ${language}`, async ({ page }) => {
|
||||
const f = await fixture(page, { language });
|
||||
const save = page.getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true });
|
||||
await expect(save).toBeDisabled();
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await save.click();
|
||||
await expect(save).toBeDisabled();
|
||||
expect(f.writes).toEqual([{ synchronous_send_max_recipients: 200, expected_revision: "1".padStart(64, "0") }]);
|
||||
await page.getByRole("button", { name: language === "de" ? "Gespeicherte Versandrichtlinie neu laden" : "Reload saved delivery policy", exact: true }).click();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await expect(page.getByRole("checkbox")).not.toBeChecked();
|
||||
expect(f.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [422, 409]) {
|
||||
test(`policy save ${status} retains draft for an explicit retry`, async ({ page }) => {
|
||||
const f = await fixture(page, { reject: status });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Fixture policy save rejected" })).toBeVisible();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(2);
|
||||
expect(f.writes[1].synchronous_send_max_recipients).toBe(200);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit ceiling prevents invalid save and clearing override submits inheritance", async ({ page }) => {
|
||||
const f = await fixture(page, { scope: "tenant", ceiling: 40 });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("41");
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(0);
|
||||
await page.getByRole("spinbutton").fill("0");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes.map((body) => body.synchronous_send_max_recipients)).toEqual([0, null]);
|
||||
});
|
||||
|
||||
test("read-only policy cannot be edited or saved", async ({ page }) => {
|
||||
const f = await fixture(page, { readonly: true });
|
||||
await expect(page.getByRole("checkbox")).toBeDisabled();
|
||||
await expect(page.getByRole("spinbutton")).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("pending policy save disables edits and reload; failed refresh preserves acknowledged value", async ({ page }) => {
|
||||
const f = await fixture(page, { hold: true });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => f.writes.length).toBe(1);
|
||||
await expect(page.getByRole("spinbutton")).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Reload saved delivery policy", exact: true })).toBeDisabled();
|
||||
f.release();
|
||||
await expect(page.getByRole("spinbutton")).toBeEnabled();
|
||||
f.failRead();
|
||||
await page.getByRole("button", { name: "Reload saved delivery policy", exact: true }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Fixture reload unavailable" })).toBeVisible();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: {
|
||||
kind?: "smtp" | "imap"; language?: "en" | "de"; holdWrite?: Promise<void>; interrupt?: boolean; failRefresh?: boolean;
|
||||
diagnostics?: boolean; authoring?: boolean; holdPreview?: Promise<void>;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? "smtp";
|
||||
const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Delivery fixture:", error.message); });
|
||||
const writes: { path: string; payload: Record<string, any> }[] = [];
|
||||
const wholeReads: string[] = [];
|
||||
let progressReads = 0; let previews = 0; let acknowledged = false; let active = false; let validated = false;
|
||||
const jobs = Array.from({ length: 4 }, (_value, index) => ({ id: `delivery-job-${index + 1}`, entry_index: index + 1, entry_id: `entry-${index + 1}`,
|
||||
recipient_email: `recipient-${index + 1}@example.test`, subject: `Delivery message ${index + 1}`, build_status: "built", validation_status: "ready", queue_status: "draft",
|
||||
send_status: kind === "imap" ? "smtp_accepted" : "not_queued", imap_status: options.diagnostics ? ["pending", "appending", "failed", "outcome_unknown"][index] : kind === "imap" ? "pending" : "not_requested",
|
||||
resolved_recipients: { to: [{ email: `recipient-${index + 1}@example.test` }] }, issues: [], attachments: [], review_decision: { eligible: false }, attempt_count: kind === "imap" ? 1 : 0
|
||||
}));
|
||||
const version = () => ({ id: "delivery-version", campaign_id: "delivery-campaign", version_number: 1, edit_revision: 1, strong_etag: '"delivery-version:1"',
|
||||
review_build_token: "delivery-build", locked_at: options.authoring && !validated ? null : "2026-09-07T10:00:00Z", workflow_state: options.authoring && !validated ? "draft" : "built",
|
||||
validation_summary: options.authoring && !validated ? null : { ok: true, warning_count: 0, error_count: 0, issues: [] },
|
||||
build_summary: options.authoring ? null : { built_count: 4, blocked_count: 0 }, execution_snapshot_hash: "frozen-snapshot",
|
||||
raw_json: { campaign: { name: "Delivery fixture" }, server: { mail_profile_id: "profile-fixture" },
|
||||
delivery: { rate_limit: { messages_per_minute: 30 }, imap_append_sent: { enabled: kind === "imap", folder: "Sent" } },
|
||||
entries: { inline: jobs.map(job => ({ id: job.entry_id, email: job.recipient_email, active: true })) } },
|
||||
editor_state: { review_send: { review_build_token: "delivery-build", inspection_complete: true, reviewed_message_keys: [], issue_decisions: [] } }
|
||||
});
|
||||
const baseJobs = (url?: URL) => {
|
||||
const states = url?.searchParams.getAll("imap_status") ?? [];
|
||||
const selected = states.length ? jobs.filter(job => states.includes(job.imap_status)) : jobs;
|
||||
return { jobs: selected, page: 1, page_size: 200, total: selected.length, total_unfiltered: 4, pages: 1, counts: {}, filtered_counts: {},
|
||||
review: { blocking_count: 0, required_count: 0, bulk_acceptable_count: 0, reviewed_required_count: 0, inspection_complete: true } };
|
||||
};
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (/\/(workspace\/delta|summary|jobs|jobs\/delta)$/.test(url.pathname)) wholeReads.push(url.pathname);
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "delivery-campaign", name: "Delivery fixture", current_version_id: "delivery-version", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: { cards: { jobs_total: 4, sent: kind === "imap" ? 4 : 0, failed: 0 },
|
||||
delivery: { background_workers_enabled: false }, status_counts: { send: kind === "imap" ? { smtp_accepted: 4 } : { not_queued: 4 },
|
||||
imap: kind === "imap" ? options.diagnostics ? { pending: 1, appending: 1, failed: 1, outcome_unknown: 1 } : { pending: 4 } : {} }, attachments: {} },
|
||||
deleted: [], full: true, has_more: false, watermark: "delivery-workspace"
|
||||
} });
|
||||
if (url.pathname.endsWith("/delivery-options")) return route.fulfill({ json: { worker_queue_available: false,
|
||||
synchronous_send: { allowed: true, eligible_recipient_job_count: 4, policy: { max_recipient_jobs: 200 } }, approval_gate: { configured: false, available: false } } });
|
||||
if (url.pathname.endsWith("/delivery-progress")) {
|
||||
progressReads++;
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture progress unavailable" } });
|
||||
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: 4,
|
||||
smtp: { total: 4, processed: acknowledged ? 4 : 1, accepted: acknowledged || kind === "imap" ? 4 : 1, active: active && kind === "smtp" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0, paused: 0, cancelled: 0 },
|
||||
imap: { total: 4, processed: acknowledged ? 4 : 1, appended: acknowledged ? 4 : 1, active: active && kind === "imap" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0 },
|
||||
status_counts: { send: {}, queue: {}, imap: {} }
|
||||
} });
|
||||
}
|
||||
if (url.pathname.endsWith("/jobs/delta")) {
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
|
||||
return route.fulfill({ json: { ...baseJobs(url), full: true, has_more: false, deleted: [], watermark: "diagnostic-watermark" } });
|
||||
}
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
|
||||
return route.fulfill({ json: baseJobs(url) });
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (url.pathname.endsWith("/attachments/preview")) {
|
||||
previews++;
|
||||
if (previews === 1 && options.holdPreview) await options.holdPreview;
|
||||
const unlinked = options.authoring && previews >= 2 && !validated;
|
||||
const file = { id: "newly-unlinked-file", filename: "letter.pdf", display_path: "letters/letter.pdf", linked_to_campaign: false };
|
||||
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", shared_file_count: 0,
|
||||
rules: [], linkable_files: unlinked ? [file] : [], unused_shared_files: [] } });
|
||||
}
|
||||
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
|
||||
if (url.pathname.endsWith("/validate")) { validated = true; return route.fulfill({ json: { ok: true, issues: [] } }); }
|
||||
if (url.pathname.endsWith("/send-now") || url.pathname.endsWith("/append-sent")) {
|
||||
active = true;
|
||||
if (options.holdWrite) await options.holdWrite;
|
||||
active = false;
|
||||
if (options.interrupt) return route.fulfill({ status: 503, json: { detail: "Fixture request interrupted; verify stored outcomes." } });
|
||||
acknowledged = true;
|
||||
for (const job of jobs) { job.send_status = "smtp_accepted"; if (kind === "imap") job.imap_status = "appended"; }
|
||||
return route.fulfill({ json: { result: { attempted_count: 4, sent_count: 4, failed_count: 0, outcome_unknown_count: 0, paused_count: 0,
|
||||
pending_count: 4, appended_count: 4, processed_count: 4, results: [] } } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-delivery-progress&language=${options.language ?? "en"}`);
|
||||
if (options.authoring) await expect.poll(() => previews).toBe(1);
|
||||
else await expect(page.getByRole("table", { name: "campaign-delivery-campaign-workflow-built-messages", exact: true }).getByText("Delivery message 1", { exact: true })).toBeVisible();
|
||||
return { writes, wholeReads, errors, get progressReads() { return progressReads; }, get previews() { return previews; } };
|
||||
}
|
||||
|
||||
async function start(page: Page, kind: "smtp" | "imap") {
|
||||
if (kind === "smtp") {
|
||||
await page.getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
|
||||
} else await page.getByRole("button", { name: /^(Append pending IMAP now|Ausstehende IMAP-Kopien jetzt anhängen)$/ }).click();
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) for (const kind of ["smtp", "imap"] as const) test(`${language}: actual ${kind} request shows live active progress without refreshing the underlying workflow`, async ({ page }) => {
|
||||
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { kind, language, holdWrite: held });
|
||||
await start(page, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
const wholeReads = fixture.wholeReads.length;
|
||||
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
|
||||
await expect(dialog.getByText(language === "de" ? "In Bearbeitung" : "In progress", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("status").first()).toContainText(/1.*4/);
|
||||
expect(fixture.wholeReads.length).toBe(wholeReads);
|
||||
await page.keyboard.press("Escape"); await expect(dialog).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
release();
|
||||
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const kind of ["smtp", "imap"] as const) test(`${kind}: failed display refresh preserves acknowledged success`, async ({ page }) => {
|
||||
const fixture = await install(page, { kind, failRefresh: true });
|
||||
await start(page, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
await expect(dialog.getByText(/The request finished|Die Anfrage ist abgeschlossen/i).first()).toBeVisible();
|
||||
await dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await expect(page.getByText(kind === "smtp" ? /Send finished\. SMTP accepted 4/ : /IMAP append processed 4 job/)).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an interrupted send remains uncertain and is never replayed by progress polling", async ({ page }) => {
|
||||
const fixture = await install(page, { interrupt: true });
|
||||
await start(page, "smtp");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Fixture request interrupted; verify stored outcomes.", { exact: false })).toBeVisible();
|
||||
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("IMAP diagnostics use shared SMTP and IMAP list filters", async ({ page }) => {
|
||||
const fixture = await install(page, { kind: "imap", diagnostics: true });
|
||||
await page.getByRole("button", { name: /^(Load IMAP diagnostics|IMAP-Diagnose laden)$/ }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
const filterButtons = table.getByRole("button", { name: /^Filter (SMTP|IMAP)$/ });
|
||||
await expect(filterButtons).toHaveCount(2);
|
||||
await filterButtons.last().click();
|
||||
await expect(page.getByRole("checkbox", { name: /Pending|Ausstehend/, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("checkbox", { name: /Outcome uncertain|Ergebnis ungewiss/, exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: /^(Deselect all|Alle abwählen)$/ }).click();
|
||||
await page.getByRole("checkbox", { name: "Pending", exact: true }).check();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("recipient-2@example.test", { exact: true })).toHaveCount(0);
|
||||
await table.getByRole("button", { name: "Filter SMTP", exact: true }).click();
|
||||
await expect(page.getByRole("checkbox", { name: "SMTP accepted", exact: true })).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("acknowledged IMAP append refreshes pending diagnostics immediately", async ({ page }) => {
|
||||
const fixture = await install(page, { kind: "imap" });
|
||||
await page.getByRole("button", { name: "Load IMAP diagnostics", exact: true }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
await start(page, "imap");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Close", exact: true }).last()).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(table).toHaveCount(0);
|
||||
await expect(page.getByText(/IMAP append processed 4 job/)).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("lock waits for attachment preview and rechecks fresh unlinked matches before validation", async ({ page }) => {
|
||||
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { authoring: true, holdPreview: held });
|
||||
const lock = page.getByRole("button", { name: /^(Lock and validate|Sperren und validieren)$/ });
|
||||
await expect(lock).toBeDisabled(); expect(fixture.writes).toHaveLength(0);
|
||||
release(); await expect(lock).toBeEnabled();
|
||||
await lock.click();
|
||||
await expect.poll(() => fixture.previews).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: /^(Link and lock|Verknüpfen und sperren)$/ }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].path).toMatch(/\/validate$/);
|
||||
expect(fixture.writes[0].payload.link_unshared_matches).toBe(true);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const zip = { enabled: true, archives: [{
|
||||
id: "legacy-zip", name: "Existing recipient archive", method: "zip_standard",
|
||||
password_enabled: true, password_delivery_channel: "separate_mail"
|
||||
}] };
|
||||
const defaultCredential = {
|
||||
id: "credential-default", name: "Default account", is_active: true, is_default: true,
|
||||
public_data: { username: "sender@example.test" }
|
||||
};
|
||||
const alternateCredential = {
|
||||
id: "credential-alternate", name: "Explicit alternate account", is_active: true, is_default: false,
|
||||
public_data: { username: "alternate@example.test" }
|
||||
};
|
||||
const inactiveCredential = {
|
||||
id: "credential-inactive", name: "Inactive account", is_active: false, is_default: false,
|
||||
public_data: { username: "inactive@example.test" }
|
||||
};
|
||||
const profile = {
|
||||
id: "profile-1", name: "Campaign delivery", scope_type: "tenant", scope_id: "tenant-1", is_active: true,
|
||||
servers: [{
|
||||
id: "smtp-1", name: "Active SMTP server", protocol: "smtp", is_active: true, is_default: true,
|
||||
config: { host: "smtp.example.test", port: 587, security: "starttls" },
|
||||
credentials: [defaultCredential, alternateCredential, inactiveCredential]
|
||||
}, {
|
||||
id: "smtp-inactive", name: "Inactive SMTP server", protocol: "smtp", is_active: false, is_default: false,
|
||||
config: { host: "inactive.example.test", port: 587, security: "starttls" },
|
||||
credentials: [defaultCredential]
|
||||
}]
|
||||
};
|
||||
|
||||
async function install(page: Page, options: {
|
||||
language?: "en" | "de";
|
||||
selectedServer?: Record<string, string>;
|
||||
excludeProfile?: boolean;
|
||||
requireCredential?: boolean;
|
||||
} = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => { errors.push(error.message); });
|
||||
let revision = 1;
|
||||
let migrationRequired = true;
|
||||
let raw: Record<string, unknown> = {
|
||||
version: "1.0", campaign: { id: "campaign-mail", name: "Mail repair fixture", mode: "send" },
|
||||
server: options.selectedServer ?? { mail_profile_id: "profile-1", smtp_server_id: "smtp-1" },
|
||||
template: { subject: "Fixture subject", text: "Fixture body" },
|
||||
recipients: { from: [{ email: "sender@example.test" }] }, entries: { inline: [] },
|
||||
attachments: { zip }, delivery: { imap_append_sent: { enabled: false, folder: "auto" } }
|
||||
};
|
||||
const writes: Record<string, any>[] = [];
|
||||
const reads: URL[] = [];
|
||||
const unexpectedWrites: string[] = [];
|
||||
const version = () => ({
|
||||
id: "version-mail", campaign_id: "campaign-mail", version_number: 2,
|
||||
schema_version: "1.0", edit_revision: revision, strong_etag: `"version-mail:${revision}"`,
|
||||
current_flow: "manual", current_step: "mail-settings", workflow_state: "editing",
|
||||
is_complete: false, editor_state: { created_from: "minimal_campaign" },
|
||||
user_lock_state: null, locked_at: null, published_at: null,
|
||||
created_at: "2026-09-07T10:00:00Z", updated_at: "2026-09-07T10:00:00Z",
|
||||
raw_json: raw, mail_profile_migration_required: migrationRequired
|
||||
});
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
reads.push(url);
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "campaign-mail", name: "Mail repair fixture", current_version_id: "version-mail", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: null, deleted: [],
|
||||
watermark: `mail-watermark-${revision}`, has_more: false, full: true
|
||||
} });
|
||||
if (url.pathname === "/api/v1/mail/profiles") return route.fulfill({ json: {
|
||||
profiles: options.excludeProfile ? [] : [profile]
|
||||
} });
|
||||
if (url.pathname.endsWith("/versions/version-mail")) return route.fulfill({ json: version() });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/versions/version-mail/autosave")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
if (options.requireCredential && !body.campaign_json.server.smtp_credential_id) {
|
||||
return route.fulfill({ status: 422, json: {
|
||||
detail: "Campaign delivery cannot use the selected profile because the effective SMTP credential policy requires an explicit credential selection for this campaign."
|
||||
} });
|
||||
}
|
||||
if (body.base_revision !== revision) return route.fulfill({ status: 409, json: { detail: {
|
||||
code: "revision_conflict", resource: { type: "campaign_version", id: "version-mail" },
|
||||
current_revision: revision, submitted_base_revision: body.base_revision
|
||||
} } });
|
||||
raw = body.campaign_json;
|
||||
revision += 1;
|
||||
if (body.migrate_legacy_mail_settings) migrationRequired = false;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
unexpectedWrites.push(`${request.method()} ${url.pathname}`);
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-mail-settings&language=${options.language ?? "en"}`);
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Reload profiles|Profile neu laden/ })).toBeEnabled();
|
||||
return { writes, reads, errors, unexpectedWrites, raw: () => raw, revision: () => revision };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) {
|
||||
test(`real Mail settings never display inherited defaults as explicit saved credentials in ${language}`, async ({ page }) => {
|
||||
const fixture = await install(page, { language, requireCredential: true });
|
||||
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
|
||||
await expect(credential).toHaveValue("");
|
||||
await expect(credential.locator("option:checked")).toContainText(language === "en" ? "Use profile credentials" : "Profil-Zugangsdaten verwenden");
|
||||
await expect(credential.locator('option[value="credential-default"]')).toHaveCount(1);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await credential.selectOption("credential-default");
|
||||
await page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
await expect(page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true })).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0].migrate_legacy_mail_settings).toBe(true);
|
||||
expect(fixture.writes[0].campaign_json.server).toEqual({
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
|
||||
});
|
||||
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(zip);
|
||||
expect(fixture.writes[0].base_revision).toBe(1);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(credential).toHaveValue("credential-default");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit-credential 422 preserves the real Mail draft and can be corrected then saved", async ({ page }) => {
|
||||
const fixture = await install(page, { requireCredential: true });
|
||||
await page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true }).click();
|
||||
await expect(page.getByText(/effective SMTP credential policy requires an explicit credential selection/)).toBeVisible();
|
||||
expect(fixture.revision()).toBe(1);
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toHaveValue("smtp-1");
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeEnabled();
|
||||
await page.getByRole("combobox", { name: "SMTP credential", exact: true }).selectOption("credential-alternate");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBeUndefined();
|
||||
expect(fixture.writes[1].campaign_json.server.smtp_credential_id).toBe("credential-alternate");
|
||||
expect(fixture.writes[1].campaign_json.server.smtp_server_id).toBe("smtp-1");
|
||||
expect(fixture.writes[1].campaign_json.attachments.zip).toEqual(zip);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
});
|
||||
|
||||
for (const credentialId of ["credential-missing", "credential-inactive"]) {
|
||||
test(`a stored ${credentialId} stays visibly unavailable until explicitly replaced`, async ({ page }) => {
|
||||
const fixture = await install(page, { selectedServer: {
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: credentialId
|
||||
} });
|
||||
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
|
||||
await expect(credential).toHaveValue(credentialId);
|
||||
await expect(credential.locator("option:checked")).toHaveText("Selected credential is unavailable");
|
||||
await expect(credential.locator('option[value="credential-inactive"]')).toHaveCount(credentialId === "credential-inactive" ? 1 : 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await credential.selectOption("credential-default");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBe("credential-default");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const serverId of ["smtp-missing", "smtp-inactive"]) {
|
||||
test(`a stored ${serverId} cannot silently fall back to the default SMTP endpoint`, async ({ page }) => {
|
||||
const fixture = await install(page, { selectedServer: {
|
||||
mail_profile_id: "profile-1", smtp_server_id: serverId, smtp_credential_id: "credential-default"
|
||||
} });
|
||||
const server = page.getByRole("combobox", { name: "SMTP server", exact: true });
|
||||
await expect(server).toHaveValue(serverId);
|
||||
await expect(server.locator("option:checked")).toContainText("unavailable");
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await server.selectOption("smtp-1");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
expect(fixture.writes[0].campaign_json.server).toEqual({
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
|
||||
});
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("an unavailable profile remains identified and cannot be silently migrated", async ({ page }) => {
|
||||
const fixture = await install(page, { excludeProfile: true });
|
||||
await expect(page.getByRole("combobox", { name: "Profile", exact: true })).toHaveValue("profile-1");
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.reads.filter((url) => url.pathname === "/api/v1/mail/profiles").every((url) => url.searchParams.get("campaign_id") === "campaign-mail")).toBe(true);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, language = "en", rejectSave = false) {
|
||||
let revision = 1;
|
||||
let raw: any = null;
|
||||
let rejected = false;
|
||||
const writes: any[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const version = () => ({ id: "order-version", campaign_id: "order-campaign", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"order-version:${revision}"`, editor_state: {},
|
||||
workflow_state: "editing", current_flow: "manual", current_step: "recipients", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
|
||||
const body = route.request().postDataJSON(); writes.push(body);
|
||||
if (rejectSave && !rejected) {
|
||||
rejected = true;
|
||||
return route.fulfill({ status: 422, json: { detail: "Fixture policy rejected this save" } });
|
||||
}
|
||||
raw = body.campaign_json; revision += 1;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (route.request().method() === "GET") return route.fulfill({ json: version() });
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-recipient-order&language=${language}`);
|
||||
await expect(page.getByTestId("recipient-order")).toContainText("alpha@example.test");
|
||||
return { writes, errors, raw: () => raw };
|
||||
}
|
||||
|
||||
async function moveLastToFirst(page: Page) {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(2).click();
|
||||
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(1).click();
|
||||
await expect(dialog.locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`individual recipient order survives dialog Save, reopen and actual campaign POST in ${language}`, async ({ page }) => {
|
||||
const fixture = await install(page, language);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true }).click();
|
||||
await expect(page.getByTestId("primary-address")).toHaveText("zulu@example.test");
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
|
||||
expect(fixture.writes).toHaveLength(0); // Dialog Save applies the page draft, not a hidden network mutation.
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Abbrechen" : "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.writes[0].campaign_json.entries.inline[0].to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test"
|
||||
]);
|
||||
expect(fixture.raw().entries.inline[0].email).toBe("zulu@example.test");
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("global header addresses preserve their explicitly selected order", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.raw().recipients.to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test"
|
||||
]);
|
||||
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
});
|
||||
|
||||
test("paste deduplicates without alphabetically rearranging existing or newly added addresses", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").locator(".recipient-address-category").evaluate((section) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", "Zulu <ZULU@example.test>; Omega <omega@example.test>; Charlie <charlie@example.test>");
|
||||
section.dispatchEvent(new ClipboardEvent("paste", { clipboardData: clipboard, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]')).toHaveCount(5);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.raw().entries.inline[0].to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test", "omega@example.test", "charlie@example.test"
|
||||
]);
|
||||
});
|
||||
|
||||
test("failed campaign save retains the reordered dialog result for explicit retry", async ({ page }) => {
|
||||
const fixture = await install(page, "en", true);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-error")).toContainText("Fixture policy rejected");
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[1].campaign_json.entries.inline[0].to).toEqual(fixture.writes[0].campaign_json.entries.inline[0].to);
|
||||
});
|
||||
|
||||
test("Cancel deliberately discards only the dialog's unconfirmed reorder", async ({ page }) => {
|
||||
await install(page);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
await expect(page.getByTestId("primary-address")).toHaveText("alpha@example.test");
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise<void>; switchAccount?: boolean } = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const writes: { path: string; payload: Record<string, any> }[] = [];
|
||||
let detailReads = 0;
|
||||
let holdNextRead: Promise<void> | undefined;
|
||||
let heldReads = 0;
|
||||
const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"];
|
||||
const jobs = statuses.map((status, index) => ({
|
||||
id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1,
|
||||
recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`,
|
||||
resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }],
|
||||
cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] },
|
||||
build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft",
|
||||
send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1,
|
||||
postbox_attempt_count: 0, print_attempt_count: 0,
|
||||
attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } }
|
||||
}));
|
||||
const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"',
|
||||
workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} };
|
||||
const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length },
|
||||
delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: {
|
||||
send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])),
|
||||
imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length]))
|
||||
} });
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" },
|
||||
versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark"
|
||||
} });
|
||||
if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: {
|
||||
campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length,
|
||||
smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 },
|
||||
imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {}
|
||||
} });
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase();
|
||||
const matchesStatus = (column: "send" | "imap", status: string) => {
|
||||
const filter = url.searchParams.get(`filter_${column}`);
|
||||
return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status);
|
||||
};
|
||||
const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) &&
|
||||
matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status)));
|
||||
const hold = holdNextRead;
|
||||
if (hold) { holdNextRead = undefined; heldReads++; await hold; }
|
||||
return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } });
|
||||
}
|
||||
if (/\/jobs\/job-\d+$/.test(url.pathname)) {
|
||||
detailReads++;
|
||||
return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } });
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
|
||||
if (options.holdWrite) await options.holdWrite;
|
||||
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } });
|
||||
if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) {
|
||||
expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false);
|
||||
expect(payload.version_id).toBe("report-version");
|
||||
for (const id of payload.job_ids) {
|
||||
const job = jobs.find(row => row.id === id)!;
|
||||
expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status);
|
||||
job.send_status = "smtp_accepted";
|
||||
}
|
||||
return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length,
|
||||
sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } });
|
||||
}
|
||||
const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`));
|
||||
if (url.pathname.endsWith("/recover-claim") && job) {
|
||||
expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false;
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
if (url.pathname.endsWith("/resolve-outcome") && job) {
|
||||
expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary";
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`);
|
||||
const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true });
|
||||
await expect(table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise<void>) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => {
|
||||
const fixture = await install(page, { language });
|
||||
await expect(fixture.table.getByText("Additional person <additional-1@example.test>", { exact: false })).toBeVisible();
|
||||
await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.detailReads).toBe(0);
|
||||
await page.getByRole("textbox").first().fill("blind-2@example.test");
|
||||
await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled();
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]);
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
expect(fixture.jobs[5].send_status).toBe("queued");
|
||||
await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]);
|
||||
expect(fixture.writes[0].payload.include_permanent).toBe(true);
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/);
|
||||
expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirst: true });
|
||||
await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID.");
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.jobs[2].send_status).toBe("failed_temporary");
|
||||
expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1);
|
||||
await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs.");
|
||||
await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim");
|
||||
expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4");
|
||||
expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log.");
|
||||
await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled();
|
||||
release(); await expect(dialog).toHaveCount(0);
|
||||
await page.goto("/?campaign-report&language=en&read-only");
|
||||
await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled"));
|
||||
for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) {
|
||||
fixture.jobs[5][field] = 1;
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled();
|
||||
fixture.jobs[5][field] = 0;
|
||||
}
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(1);
|
||||
await continuations.click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.jobs[3].send_status).toBe("sending");
|
||||
expect(fixture.jobs[2].send_status).toBe("outcome_unknown");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
for (const [label, count, ids] of [
|
||||
["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]],
|
||||
["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]]
|
||||
] as const) {
|
||||
const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button");
|
||||
await expect(shortcut).toHaveText(String(count));
|
||||
await shortcut.click();
|
||||
await expect(shortcut).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length);
|
||||
for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("old report reads cannot restore rows after switching account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true });
|
||||
fixture.holdNextJobsRead(held);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect.poll(() => fixture.heldReads).toBe(1);
|
||||
fixture.jobs[0].subject = "New account message";
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).click();
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true, holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
// Simulate an externally changed authentication provider while the request is pending.
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click());
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const email = `${"long-address-segment-".repeat(5)}@example.test`;
|
||||
fixture.jobs[0].resolved_recipients.bcc = [{ email }];
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email });
|
||||
await expect(cell.getByText(email, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height })))
|
||||
.toMatchObject({ overflow: 0 });
|
||||
expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`${language}: validation cause/outcome groups and repeated files are fully paginated`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto(`/?campaign-review-details&language=${language}`);
|
||||
const validation = page.getByRole("region", { name: "Validation details fixture" });
|
||||
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
|
||||
const next = language === "en" ? "Next page" : "Nächste Seite";
|
||||
const previous = language === "en" ? "Previous page" : "Vorherige Seite";
|
||||
const details = language === "en" ? "Technical details" : "Technische Details";
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(10);
|
||||
const first = validation.locator("[data-validation-issue-group]").first();
|
||||
await expect(first.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await expect(first.getByText("Policy excludes recipient 1 without attachments.", { exact: true })).toBeVisible();
|
||||
await first.getByText(details, { exact: true }).click();
|
||||
await expect(first.getByText(/\/entries\/recipient-1\/attachments\/0/)).toBeVisible();
|
||||
await expect(first.getByText(/missing_attachment_coverage/)).toBeVisible();
|
||||
await validation.getByRole("button", { name: next, exact: true }).click();
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
|
||||
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
|
||||
await expect(validation.getByRole("button", { name: next, exact: true })).toBeDisabled();
|
||||
await validation.getByRole("button", { name: previous, exact: true }).click();
|
||||
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await validation.getByRole("combobox").selectOption("25");
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(12);
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
await repeated.getByRole("button", { name: next, exact: true }).click();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
|
||||
await repeated.getByRole("button", { name: previous, exact: true }).click();
|
||||
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("detail lists clamp their last page when refreshed results shrink", async ({ page }) => {
|
||||
await page.goto("/?campaign-review-details&language=en");
|
||||
const validation = page.getByRole("region", { name: "Validation details fixture" });
|
||||
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
|
||||
await validation.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await repeated.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reduce fixture details", exact: true }).click();
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
|
||||
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
for (const region of [validation, repeated]) {
|
||||
await expect(region.getByRole("button", { name: "Previous page", exact: true })).toBeDisabled();
|
||||
await expect(region.getByRole("button", { name: "Next page", exact: true })).toBeDisabled();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise<void>; holdFirstJobs?: Promise<void>; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); });
|
||||
const writes: Record<string, any>[] = [];
|
||||
const reads: string[] = [];
|
||||
let revision = 1;
|
||||
let complete = false;
|
||||
let buildGeneration = 1;
|
||||
let selectedVersionId = "review-version";
|
||||
let jobRequests = 0;
|
||||
const builds: Record<string, unknown>[] = [];
|
||||
const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference"
|
||||
: buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`;
|
||||
const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}`
|
||||
: buildGeneration === 1 ? subject : `Rebuilt ${subject}`;
|
||||
const decisions = new Map<string, Record<string, unknown>>();
|
||||
const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"];
|
||||
if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." });
|
||||
const jobs = statuses.map((status, offset) => {
|
||||
const index = offset + 1;
|
||||
return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`,
|
||||
entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`,
|
||||
build_status: "built", validation_status: status, send_status: "not_queued",
|
||||
resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [],
|
||||
issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }],
|
||||
review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] }
|
||||
}; });
|
||||
const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(),
|
||||
locked_at: "2026-09-07T09:00:00Z",
|
||||
current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] },
|
||||
raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "<p>Fixture message</p>" },
|
||||
server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } },
|
||||
editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete,
|
||||
reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } }
|
||||
});
|
||||
const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200,
|
||||
total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {},
|
||||
review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
reads.push(url.pathname);
|
||||
if (url.pathname.endsWith("/workspace/delta")) {
|
||||
selectedVersionId = url.searchParams.get("version_id") ?? "review-version";
|
||||
return route.fulfill({ json: {
|
||||
campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } },
|
||||
deleted: [], full: true, has_more: false, watermark: `w-${revision}`
|
||||
} }); }
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
jobRequests++;
|
||||
const snapshot = jobResponse();
|
||||
if (jobRequests === 1 && options.holdFirstJobs) {
|
||||
// A stale response advertising another page must not start that
|
||||
// follow-up request after the selected build has changed.
|
||||
snapshot.pages = 2;
|
||||
await options.holdFirstJobs;
|
||||
}
|
||||
if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } });
|
||||
return route.fulfill({ json: snapshot });
|
||||
}
|
||||
if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } });
|
||||
if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: {
|
||||
campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0,
|
||||
rules: [], linkable_files: [], unused_shared_files: []
|
||||
} });
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/review-state")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
if (options.holdSave) await options.holdSave;
|
||||
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } });
|
||||
if (options.conflictFirst && writes.length === 1) {
|
||||
decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" });
|
||||
revision++;
|
||||
return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } });
|
||||
}
|
||||
expect(body.merge_progress).toBe(true);
|
||||
expect(body.build_token).toBe(publicBuildToken());
|
||||
expect(body.base_revision).toBe(revision);
|
||||
for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision);
|
||||
revision++; complete = body.inspection_complete;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/build")) {
|
||||
builds.push(request.postDataJSON());
|
||||
buildGeneration++; revision++; complete = false; decisions.clear();
|
||||
return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`);
|
||||
if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1);
|
||||
else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
||||
return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } };
|
||||
}
|
||||
|
||||
async function openMessage(page: Page, index = 1) {
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible();
|
||||
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
return page.getByRole("dialog");
|
||||
}
|
||||
|
||||
test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const dialog = await openMessage(page);
|
||||
const readsBefore = fixture.reads.length;
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document.");
|
||||
expect(fixture.reads.length).toBe(readsBefore);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0].inspection_complete).toBe(false);
|
||||
expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document.");
|
||||
expect(fixture.reads.length).toBe(readsBefore);
|
||||
await page.reload();
|
||||
await openMessage(page);
|
||||
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirst: true });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason.");
|
||||
expect(fixture.decisions.size).toBe(0);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => {
|
||||
let acknowledge!: () => void;
|
||||
const fixture = await install(page, { holdSave: new Promise<void>((resolve) => { acknowledge = resolve; }) });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => {
|
||||
button.click(); button.click();
|
||||
});
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled();
|
||||
for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
||||
acknowledge();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("German individual review records its reason and skips intentional exclusions", async ({ page }) => {
|
||||
const fixture = await install(page, { language: "de" });
|
||||
let dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor.");
|
||||
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang.");
|
||||
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.decisions.has("job-3")).toBe(false);
|
||||
await page.reload();
|
||||
dialog = await openMessage(page);
|
||||
await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(2);
|
||||
await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0);
|
||||
await dialog.getByRole("textbox").fill("Both recipients have already received these documents.");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1,
|
||||
inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] });
|
||||
expect(fixture.writes[0].issue_decisions).toEqual([
|
||||
{ job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." },
|
||||
{ job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." }
|
||||
]);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
||||
await openMessage(page, 2);
|
||||
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
||||
expect(fixture.decisions.has("job-3")).toBe(false);
|
||||
await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click();
|
||||
await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].inspection_complete).toBe(true);
|
||||
const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) });
|
||||
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("read-only campaign access permits inspection but no review decisions", async ({ page }) => {
|
||||
const fixture = await install(page, { readOnly: true });
|
||||
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled();
|
||||
const dialog = await openMessage(page);
|
||||
const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true });
|
||||
if (await accept.count()) await expect(accept).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => {
|
||||
const fixture = await install(page, { conflictFirst: true });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception.");
|
||||
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.decisions.has("job-1")).toBe(false);
|
||||
expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document.");
|
||||
expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[1].base_revision).toBe(2);
|
||||
expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]);
|
||||
expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => {
|
||||
const fixture = await install(page, { stateMatrix: true, language: "de" });
|
||||
const section = page.locator("#campaign-built-message-details");
|
||||
const table = section.getByRole("table");
|
||||
const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]');
|
||||
await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0);
|
||||
await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0);
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]);
|
||||
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich");
|
||||
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung");
|
||||
await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click();
|
||||
const filter = page.locator(".data-grid-filter-popover");
|
||||
await expect(filter.getByRole("checkbox")).toHaveCount(4);
|
||||
for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible();
|
||||
await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click();
|
||||
await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check();
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]);
|
||||
await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click();
|
||||
// The convenience action uses the very same derived state, adding blockers
|
||||
// but never reintroducing accepted rows just because their build said ask.
|
||||
await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click();
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]);
|
||||
await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click();
|
||||
await expect(stateCells).toHaveCount(6);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
expect(fixture.jobRequests).toBe(1);
|
||||
await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.builds).toHaveLength(1);
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 });
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => {
|
||||
let releaseOld!: () => void;
|
||||
const fixture = await install(page, { holdFirstJobs: new Promise<void>((resolve) => { releaseOld = resolve; }) });
|
||||
await page.getByRole("button", { name: "Switch fixture version", exact: true }).click();
|
||||
await expect(page).toHaveURL(/version=replacement-version/);
|
||||
await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2);
|
||||
releaseOld();
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirstJobs: true });
|
||||
await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible();
|
||||
const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ });
|
||||
await expect(retry).toBeEnabled();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(fixture.jobRequests).toBe(1);
|
||||
await retry.click();
|
||||
await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise<void>; holdFirstRead?: Promise<void> } = {}) {
|
||||
page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); });
|
||||
let revision = 1;
|
||||
let subject = "Original subject";
|
||||
let rejected = false;
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
let reads = 0;
|
||||
const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (options.hold) await options.hold;
|
||||
if (options.rejectSave && !rejected) {
|
||||
rejected = true;
|
||||
return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } });
|
||||
}
|
||||
if ((options.conflict && !rejected) || body.base_revision !== revision) {
|
||||
rejected = true;
|
||||
if (options.conflict) { revision = 2; subject = "Other editor's subject"; }
|
||||
return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } });
|
||||
}
|
||||
subject = body.campaign_json.template.subject;
|
||||
revision += 1;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) {
|
||||
reads++;
|
||||
const snapshot = version();
|
||||
if (reads === 1 && options.holdFirstRead) await options.holdFirstRead;
|
||||
return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-saving&language=en");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject");
|
||||
return { writes, reads: () => reads, subject: () => subject };
|
||||
}
|
||||
|
||||
test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectSave: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.subject()).toBe("Unsaved work");
|
||||
});
|
||||
|
||||
test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { hold: new Promise<void>((resolve) => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work");
|
||||
await page.getByRole("button", { name: "Request save twice", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled();
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work");
|
||||
release();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work");
|
||||
expect(fixture.subject()).toBe("Submitted work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Use my change", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Apply resolution", exact: true }).click();
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Newer unsaved work");
|
||||
});
|
||||
|
||||
test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Saved");
|
||||
await expect(page.getByTestId("save-error")).toContainText("was saved");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => {
|
||||
const fixture = await install(page, { conflict: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Save cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => {
|
||||
await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft");
|
||||
await page.getByRole("button", { name: "Background refresh", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
});
|
||||
|
||||
test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft");
|
||||
release();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Discard cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft");
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
release();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Saved work");
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user