Compare commits

...
18 Commits
Author SHA1 Message Date
zemion a6d056a3df release: lock complete 0.1.45 WebUI composition to tagged sources
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 03:15:07 +02:00
zemion c3daa4a9aa perf(webui): load optional password generation on demand 2026-09-08 03:15:07 +02:00
zemion c209b3c27d fix(release): include Tasks and complete Git-installable module composition 2026-09-08 02:06:35 +02:00
zemion 32c70a4657 perf(webui): keep settings-only appearance defaults out of startup 2026-09-08 01:41:04 +02:00
zemion b75ca34295 feat: consolidate shared UI and harden browser authority for release 2026-09-08 01:35:05 +02:00
zemion ac40774785 feat(webui): expose stable product destinations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 18:06:52 +02:00
zemion 9a3008002d feat: define governed tenant erasure contracts
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 16:00:06 +02:00
zemion 9cb2080938 feat: collect infrastructure dependency inventories
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 15:00:08 +02:00
zemion 08c3e47b6d Exercise accessible resident permit journeys
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 14:03:43 +02:00
zemion 6e518fa6a2 feat: compose stable product surfaces
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 13:21:24 +02:00
zemion f98cf9ced8 feat(help): enforce owner-aware high-risk contexts
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:48:23 +02:00
zemion d2e491348d feat(docs): validate owner structured translations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 01:15:30 +02:00
zemion 562d278f60 feat(core): add structured documentation localization contract
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 20:25:12 +02:00
zemion c6f6faf64f feat(core): add datasource lifecycle governance contracts
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 19:37:44 +02:00
zemion 1c3ee9e8c7 Release Core v0.1.35 configuration package safeguards
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 18:05:33 +02:00
zemion aa91063211 Release Core v0.1.34 with JMAP mail contracts
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 17:08:54 +02:00
zemion fa2d5d40dd Release Core v0.1.33 with redirect-sensitive headers
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 16:11:02 +02:00
zemion 6ccef162f6 Release Core v0.1.32 with bounded HTTP request bodies
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 14:32:24 +02:00
189 changed files with 14225 additions and 1293 deletions
@@ -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
+1
View File
@@ -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
+35
View File
@@ -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.
+36
View File
@@ -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:
+11
View File
@@ -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
+68 -3
View File
@@ -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.
+34
View File
@@ -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
+38 -1
View File
@@ -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 14 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
+42
View File
@@ -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:
+66
View File
@@ -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
+64
View File
@@ -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.
+55 -5
View File
@@ -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
+9
View File
@@ -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
+6
View File
@@ -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.
+4 -2
View File
@@ -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
+38
View File
@@ -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.
+497
View File
@@ -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
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.31"
version = "0.1.45"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
+8
View File
@@ -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):
+3 -2
View File
@@ -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
@@ -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 0500 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",
+9
View File
@@ -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",
+256 -1
View File
@@ -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))
+59 -5
View File
@@ -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(
+203
View File
@@ -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,
+467
View File
@@ -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",
]
+44 -5
View File
@@ -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,8 +133,9 @@ 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"):
new_request.remove_header(header)
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"),
+50
View File
@@ -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
],
+3
View File
@@ -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")
+31
View File
@@ -5271,6 +5271,36 @@ class ApiSmokeTests(unittest.TestCase):
visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id)
self.assertIsNotNone(visible_key["revoked_at"])
def test_navigation_separator_layout_survives_system_tenant_and_personal_saves(self) -> None:
headers, _ = self._login()
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
layout = {"contract_version": "1", "order": ["files.navigation.files", "separator:mail", "mail.navigation.mail"], "hidden": [], "locked": ["mail.navigation.mail"], "separators": [{"id": "separator:mail", "label": "Nachrichten"}]}
saved = self.client.patch("/api/v1/admin/system/settings", headers=headers, json={
**{key: system[key] for key in ("default_locale", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys")}, "navigation": layout,
})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(layout, saved.json()["navigation"])
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
tenant_layout = {**layout, "locked": []}
saved = self.client.patch("/api/v1/admin/tenant/settings", headers=headers, json={"default_locale": tenant["default_locale"], "navigation": tenant_layout})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(tenant_layout, saved.json()["navigation"])
personal_layout = {**tenant_layout, "order": ["mail.navigation.mail", "separator:personal", "files.navigation.files"], "hidden": ["mail.navigation.mail"], "separators": [{"id": "separator:personal", "label": "Meine Arbeit"}]}
saved = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": personal_layout}})
self.assertEqual(200, saved.status_code, saved.text)
self.assertEqual(personal_layout, saved.json()["user"]["ui_preferences"]["navigation"])
loaded = self.client.get("/api/v1/auth/profile", headers=headers)
self.assertEqual(personal_layout, loaded.json()["user"]["ui_preferences"]["navigation"])
from govoplan_core.core.navigation import navigation_preferences_from_mapping, resolve_navigation_preferences
# HTTP persistence is checked above; scope projection stays independently deterministic.
resolved = resolve_navigation_preferences(("files.navigation.files", "mail.navigation.mail"), system=navigation_preferences_from_mapping(layout), tenant=navigation_preferences_from_mapping(tenant_layout), user=navigation_preferences_from_mapping(personal_layout))
self.assertTrue(resolved["mail.navigation.mail"].visible)
self.assertTrue(resolved["mail.navigation.mail"].locked)
self.assertEqual("Meine Arbeit", resolved["files.navigation.files"].section.label)
reset = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": None}})
self.assertEqual(200, reset.status_code, reset.text)
self.assertIsNone(reset.json()["user"]["ui_preferences"]["navigation"])
def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None:
headers, _ = self._login()
@@ -6860,6 +6890,7 @@ class ApiSmokeTests(unittest.TestCase):
"order": ["files.navigation.files", "mail.navigation.mail"],
"hidden": ["mail.navigation.mail"],
"locked": [],
"separators": None,
},
},
)
@@ -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"}
+50
View File
@@ -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")
+91 -4
View File
@@ -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()
+76
View File
@@ -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())
+51 -9
View File
@@ -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",
+41
View File
@@ -4,6 +4,7 @@ import unittest
from govoplan_core.core.navigation import (
NavigationPreferences,
NavigationSeparator,
navigation_preferences_from_settings,
resolve_navigation_preferences,
update_navigation_preferences,
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
class NavigationPreferenceTests(unittest.TestCase):
def test_separator_order_and_labels_round_trip_across_scopes(self) -> None:
separator = NavigationSeparator("separator:work", "Arbeit")
preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,))
stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences))
self.assertEqual(preferences, stored)
resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored)
self.assertIsNone(resolved["dashboard"].section)
self.assertEqual(separator, resolved["files"].section)
self.assertEqual(separator, resolved["mail"].section)
self.assertTrue(resolved["dashboard"].custom_layout)
self.assertEqual("system", resolved["files"].layout_source)
self.assertNotIn(separator.id, resolved) # Never an authorized destination.
def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None:
system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",))
user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),))
resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user)
self.assertEqual("user", resolved["files"].layout_source)
self.assertEqual("Persönlich", resolved["files"].section.label)
self.assertTrue(resolved["files"].visible)
self.assertIsNone(resolved["mail"].section)
flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=()))
self.assertTrue(flat["files"].custom_layout)
self.assertIsNone(flat["files"].section)
self.assertTrue(flat["files"].locked)
def test_legacy_preferences_inherit_separator_layout(self) -> None:
separator = NavigationSeparator("separator:work", "Work")
resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",)))
self.assertEqual(separator, resolved["files"].section)
self.assertEqual("system", resolved["files"].layout_source)
self.assertFalse(resolved["mail"].visible)
def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None:
from pydantic import ValidationError
from govoplan_core.api.v1.schemas import NavigationPreferencesPayload
for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}):
with self.subTest(separator=separator), self.assertRaises(ValidationError):
NavigationPreferencesPayload.model_validate({"separators": [separator]})
def test_user_order_overrides_tenant_and_system_order(self) -> None:
resolved = resolve_navigation_preferences(
("dashboard", "files", "mail", "campaign"),
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from alembic import command
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session
from govoplan_core.core.ownership import OwnershipTransfer
from govoplan_core.db.migrations import alembic_config
class OwnershipHistoryMigrationTests(unittest.TestCase):
def test_upgrade_existing_databases_without_rewriting_ownership(self) -> None:
for track in ("release", "dev"):
for legacy in (True, False):
with self.subTest(track=track, legacy=legacy):
self._verify_upgrade(track, legacy=legacy)
def _verify_upgrade(self, track: str, *, legacy: bool) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-ownership-upgrade-") as directory:
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
config = alembic_config(database_url=url, enabled_modules=(), migration_track=track)
command.upgrade(config, "b47e6f809a13")
engine = create_engine(url)
try:
evidence = [{"sequence": 1, "action": "requested"}]
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OwnershipTransfer(
id="transfer-1", tenant_id="tenant-1", resource_module="campaigns",
resource_type="campaign", resource_id="campaign-1", kind="owner_initiated",
status="awaiting_target_acceptance", current_owner_type="user",
current_owner_id="owner-1", target_owner_type="user", target_owner_id="owner-2",
initiated_by_type="user", initiated_by_id="owner-1", reason="Existing request",
approvals=[{"actor_id": "owner-1"}], decisions=evidence,
idempotency_key="request-1", canonical_request_hash="a" * 64,
expires_at=now + timedelta(days=7), revision=3, metadata_={"retained": True},
created_at=now, updated_at=now,
))
session.commit()
with engine.begin() as connection:
if legacy:
connection.execute(text("ALTER TABLE core_ownership_transfers DROP COLUMN decisions"))
before = dict(connection.execute(text(
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
)).mappings().one())
command.upgrade(config, "c58a2d7e9f10")
command.upgrade(config, "c58a2d7e9f10")
with engine.connect() as connection:
columns = {column["name"]: column for column in inspect(connection).get_columns(
"core_ownership_transfers"
)}
self.assertFalse(columns["decisions"]["nullable"])
after = dict(connection.execute(text(
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
)).mappings().one())
self.assertEqual({key: after[key] for key in before}, before)
with Session(engine) as session:
transfer = session.scalars(select(OwnershipTransfer)).one()
self.assertEqual(transfer.decisions, [] if legacy else evidence)
self.assertEqual(transfer.approvals, [{"actor_id": "owner-1"}])
transfer.decisions = [*transfer.decisions, {"action": "accepted"}]
session.commit()
command.downgrade(config, "b47e6f809a13")
command.upgrade(config, "c58a2d7e9f10")
with Session(engine) as session:
self.assertEqual(session.get(OwnershipTransfer, "transfer-1").decisions[-1], {
"action": "accepted"
})
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -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()
+180
View File
@@ -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))
+1 -1
View File
@@ -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>;
}
+290 -2
View File
@@ -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>;
}
+9
View File
@@ -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>;
}
+23
View File
@@ -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>;
}
+64
View File
@@ -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>,
);
+127 -12
View File
@@ -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>;
}
+12 -3
View File
@@ -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>
+12
View File
@@ -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");
});
@@ -0,0 +1,127 @@
import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
function response(campaignId = "campaign-a", revision = 1, versionId = `${campaignId}-version`, hasMore = false) {
const version = { id: versionId, campaign_id: campaignId, version_number: 1, edit_revision: revision, raw_json: {} };
return {
campaign: { id: campaignId, current_version_id: versionId },
versions: [version], current_version: version, summary: null,
deleted: [], watermark: `watermark-${campaignId}-${revision}`, has_more: hasMore, full: true
};
}
async function fixture(page: Page) {
const requests: URL[] = [];
const state = {
handler: async (route: Route) => {
const url = new URL(route.request().url());
const campaignId = url.pathname.split("/")[4];
await route.fulfill({ json: response(campaignId, 1, url.searchParams.get("version_id") ?? undefined) });
}
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/workspace/delta")) {
requests.push(url);
await state.handler(route);
} else await route.fulfill({ json: {} });
});
await page.goto("/?campaign-workspace");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
requests.length = 0;
return { state, requests };
}
test("failed refresh retains saved workspace and retries without a stale watermark", async ({ page }) => {
const { state, requests } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 503, json: { detail: "Refresh unavailable" } }); };
await page.getByRole("button", { name: "Force reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Refresh unavailable");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
state.handler = async (route) => { await route.fulfill({ json: response("campaign-a", 2) }); };
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("2");
expect(requests[requests.length - 1].searchParams.has("since")).toBe(false);
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
for (const staleFails of [false, true]) {
test(`newer reload wins over an older ${staleFails ? "failed" : "successful"} response`, async ({ page }) => {
const { state } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill(staleFails ? { status: 503, json: { detail: "Obsolete error" } } : { json: response("campaign-a", 2) });
finished.resolve();
} else await route.fulfill({ json: response("campaign-a", 3) });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
}
for (const change of ["campaign", "identity", "version"] as const) {
test(`changing ${change} discards old responses and cancels obsolete pagination`, async ({ page }) => {
const { state, requests } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill({ json: response("campaign-a", 99, "obsolete-version", true) });
finished.resolve();
} else await route.fulfill({ json: response(change === "campaign" ? "campaign-b" : "campaign-a", 2, "fresh-version") });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: { campaign: "Switch campaign", identity: "Switch identity", version: "Select version" }[change] }).click();
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
expect(requests).toHaveLength(2);
expect(requests[1].searchParams.has("since")).toBe(false);
});
}
test("failed initial load for a different campaign never reveals the old campaign", async ({ page }) => {
const { state } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 403, json: { detail: "Campaign unavailable" } }); };
await page.getByRole("button", { name: "Switch campaign" }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Campaign unavailable");
await expect(page.getByTestId("workspace-campaign")).toHaveText("none");
await expect(page.getByTestId("workspace-version")).toHaveText("none");
});
test("a failed later page never publishes a partially refreshed workspace", async ({ page }) => {
const { state } = await fixture(page);
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
await route.fulfill(requestCount === 1
? { json: response("campaign-a", 4, "partial-version", true) }
: { status: 503, json: { detail: "Second page unavailable" } });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Second page unavailable");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-revision")).toHaveText("1");
});
@@ -0,0 +1,177 @@
import { expect, test, type Page } from "@playwright/test";
const credential = {
id: "fixture-credential", name: "Mailbox login", description: "Fixture metadata only",
credential_kind: "username_password", scope_type: "tenant", scope_id: null,
public_data: { username: "fixture@example.test" }, secret_configured: true,
allowed_modules: ["mail"], allowed_server_refs: ["mail:smtp-server", "mail:imap-server", "mail:deleted-server"],
inherit_to_lower_scopes: false, is_active: true,
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z"
};
async function installFixtures(page: Page, release: Promise<void>, options: { failFirst?: boolean; paginated?: boolean; denied?: boolean; retrySave?: Promise<void> } = {}) {
let loads = 0;
let savedCredential = credential;
const saves: Record<string, unknown>[] = [];
const forbiddenRequests: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname === "/api/v1/credentials") {
await route.fulfill({ json: { credentials: [savedCredential] } });
return;
}
if (request.method() === "PATCH" && url.pathname === `/api/v1/credentials/${credential.id}` && options.retrySave) {
const body = request.postDataJSON() as Record<string, unknown>;
saves.push(body);
await options.retrySave;
if (saves.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Fixture save failed; keep your draft and retry." } });
} else {
savedCredential = { ...credential, name: String(body.name) };
await route.fulfill({ json: savedCredential });
}
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/platform/modules") {
await route.fulfill({ json: { modules: [{ id: "mail", label: "Mail", version: "1" }] } });
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/mail/settings/delta") {
loads += 1;
const number = loads;
await release;
if (options.denied || (options.failFirst && number === 1)) {
await route.fulfill({ status: options.denied ? 403 : 503, json: { detail: "Fixture catalogue unavailable" } });
return;
}
expect(url.searchParams.get("scope_type")).toBe("tenant");
expect(url.searchParams.get("include_inactive")).toBe("true");
if (options.paginated && url.searchParams.get("since")) {
await route.fulfill({ json: { full: false, has_more: false, watermark: "fixture-watermark-2", profiles: [],
deleted: [{ id: "deleted-profile", resource_type: "mail_profile" }] } });
return;
}
await route.fulfill({ json: {
full: true, has_more: Boolean(options.paginated), watermark: "fixture-watermark", deleted: [],
profiles: [{ id: "fixture-profile", name: "Town hall", slug: "town-hall", servers: [
{ id: "smtp-server", name: "Outgoing town hall", protocol: "smtp", is_active: true, scope_type: "tenant" },
{ id: "imap-server", name: "Incoming town hall", protocol: "imap", is_active: false, scope_type: "tenant" }
] }, ...(options.paginated ? [{ id: "deleted-profile", name: "Removed profile", slug: "removed", servers: [
{ id: "deleted-server", name: "Removed server", protocol: "smtp", is_active: true, scope_type: "tenant" }
] }] : [])]
} });
return;
}
forbiddenRequests.push(`${request.method()} ${url.pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { get loads() { return loads; }, forbiddenRequests, saves };
}
test("credential server labels resolve on first opening despite StrictMode cleanup", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
await expect(dialog).toBeVisible();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
release();
const servers = dialog.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
await expect(servers.locator(".is-inactive")).toContainText("Incoming town hall");
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await dialog.locator('input[data-help-context-id="access.credentials.field.name"]').fill("Renamed metadata only");
await expect(servers).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(1);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("paginated server catalogues remove deleted profiles while retaining their selected references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { paginated: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers).not.toContainText("Removed server");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("denied server metadata remains unavailable without dropping stored references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { denied: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers.locator(".is-unavailable")).toHaveCount(3);
for (const reference of credential.allowed_server_refs) await expect(servers).toContainText(reference);
await expect(servers).not.toContainText("Outgoing town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("temporary server-catalogue failures can recover by reopening the editor", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { failFirst: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" }).locator(".is-unavailable")).toHaveCount(3);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" })).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("closing while servers load does not poison the next credential editor", async ({ page }) => {
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
release();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("failed credential saves show their error inside the editor and retain the draft for explicit retry", async ({ page }) => {
let finishSave!: () => void;
const retrySave = new Promise<void>((resolve) => { finishSave = resolve; });
const fixture = await installFixtures(page, Promise.resolve(), { retrySave });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
const name = dialog.locator('input[data-help-context-id="access.credentials.field.name"]');
const secret = dialog.locator('input[type="password"]');
await name.fill("Renamed mailbox credential");
await secret.fill("synthetic-fixture-replacement");
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect.poll(() => fixture.saves.length).toBe(1);
await expect(name).toBeDisabled();
await expect(secret).toBeDisabled();
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeDisabled();
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
finishSave();
await expect(dialog.getByRole("alert")).toContainText("Fixture save failed; keep your draft and retry.");
await expect(page.getByRole("alert")).toHaveCount(1);
await expect(name).toHaveValue("Renamed mailbox credential");
await expect(secret).toHaveValue("synthetic-fixture-replacement");
expect(fixture.saves).toHaveLength(1);
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("button", { name: "Edit Renamed mailbox credential", exact: true })).toBeVisible();
expect(fixture.saves).toHaveLength(2);
expect(fixture.saves[1]).toEqual(fixture.saves[0]);
expect(fixture.saves[1].allowed_server_refs).toEqual(credential.allowed_server_refs);
expect(fixture.forbiddenRequests).toEqual([]);
});
@@ -0,0 +1,141 @@
import { expect, test, type Page } from "@playwright/test";
const actionCell = (page: Page) => page.locator('.data-grid-body-cell[data-column-id="actions"]');
const header = (page: Page, id: string) => page.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
const width = (page: Page, id: string) => header(page, id).evaluate((element) => element.getBoundingClientRect().width);
async function expectActionsUnclipped(page: Page) {
await expect.poll(async () => actionCell(page).evaluate((cell) => {
const bounds = cell.getBoundingClientRect();
const scroller = cell.closest(".data-grid-scroll-region")!;
const viewport = scroller.getBoundingClientRect();
return Array.from(cell.querySelectorAll("button")).every((button) => {
const rect = button.getBoundingClientRect();
return rect.width >= 35 && rect.left >= bounds.left && rect.right <= bounds.right
&& rect.top >= bounds.top && rect.bottom <= bounds.bottom
&& rect.left >= viewport.left && rect.right <= viewport.right;
});
})).toBe(true);
}
test("undersized action tracks fit real controls and stay visible through narrow horizontal scrolling", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/?data-grid-layout");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(181);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeLessThanOrEqual(160);
const scroller = page.locator(".data-grid-scroll-region");
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth / 2; });
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Inspect Alpha", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Inspect Alpha");
await scroller.focus();
await expect(scroller).toBeFocused();
// The application shell's supported viewport floor is 320px; its padded
// content area is narrower and must still contain the complete action set.
await page.setViewportSize({ width: 320, height: 720 });
await expectActionsUnclipped(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
expect(errors).toEqual([]);
});
test("pointer and keyboard resizing persists, cancels safely, and adapts to container resize", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const handle = header(page, "name").getByRole("separator");
await expect(handle).toHaveAttribute("aria-orientation", "vertical");
const initial = await width(page, "name");
await handle.focus();
await handle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2, { steps: 5 });
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 110, 0);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeLessThan(initial + 40);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await handle.press("Enter");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial, 0);
const resetBox = (await handle.boundingBox())!;
await page.mouse.move(resetBox.x + resetBox.width / 2, resetBox.y + resetBox.height / 2);
await page.mouse.down();
await page.mouse.move(resetBox.x + resetBox.width / 2 + 50, resetBox.y + resetBox.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
});
test("free content tracks retain explicit user resizing on remount", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=free");
const handle = header(page, "name").getByRole("separator");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await handle.press("ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
});
test("empty and changing action sets reserve the same complete slots", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const initial = await width(page, "actions");
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await expectActionsUnclipped(page);
await expect.poll(() => width(page, "actions")).toBeCloseTo(initial, 0);
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await page.getByRole("button", { name: "Toggle extra action", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeGreaterThan(initial + 35);
await expectActionsUnclipped(page);
});
test("constrained resizing redistributes tracks without introducing overflow", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=constrained");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await header(page, "name").getByRole("separator").press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
expect(await page.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expectActionsUnclipped(page);
});
test("explicit composite action groups include outer controls in their measured minimum", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=composite");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(221);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Extra control", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Extra control");
});
test("oversized explicit sticky minima release stickiness instead of obscuring every data column", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=oversized");
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).toHaveClass(/data-grid-release-sticky/);
const name = page.locator('.data-grid-body-cell[data-column-id="name"]');
await expect(name).toBeInViewport();
await page.locator(".data-grid-scroll-region").evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).not.toHaveClass(/data-grid-release-sticky/);
});
@@ -0,0 +1,52 @@
import { expect, test, type Locator } from "@playwright/test";
async function expectDialogFits(dialog: Locator) {
await expect(dialog).toBeVisible();
const sizes = await dialog.evaluate((panel) => {
const body = panel.querySelector<HTMLElement>(".dialog-body")!;
const rect = panel.getBoundingClientRect();
const overflowControls = Array.from(panel.querySelectorAll("input,select,textarea,.dialog-close,.dialog-footer button")).filter((control) => {
const bounds = control.getBoundingClientRect();
return bounds.left < rect.left - 1 || bounds.right > rect.right + 1;
}).map((control) => control.tagName);
return { horizontalOverflow: body.scrollWidth - body.clientWidth, left: rect.left, right: rect.right, viewport: window.innerWidth, overflowControls };
});
expect(sizes.horizontalOverflow).toBeLessThanOrEqual(1);
expect(sizes.left).toBeGreaterThanOrEqual(0);
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
expect(sizes.overflowControls).toEqual([]);
}
for (const width of [320, 390, 768, 1280]) {
test(`shared dialog keeps narrow form content and local table scrolling inside ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/?dialog-layout");
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
const tableScroll = page.getByTestId("dialog-local-scroll");
expect(await tableScroll.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await tableScroll.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
expect(await tableScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
await expectDialogFits(dialog);
});
for (const language of ["en", "de"]) {
test(`actual Templates Add dialog fits ${width}px in ${language}`, async ({ page }) => {
const unexpectedWrites: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (request.method() !== "GET") unexpectedWrites.push(`${request.method()} ${request.url()}`);
await route.fulfill({ json: { items: [] } });
});
await page.setViewportSize({ width, height: 900 });
await page.goto(`/?dialog-layout&fixture=templates&language=${language}`);
await page.getByRole("button", { name: language === "de" ? "Vorlage hinzufügen" : "Add template", exact: true }).click();
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
await dialog.locator("input").fill("Eine sehr lange Vorlagenbezeichnung ".repeat(8));
await expectDialogFits(dialog);
await expect(dialog.locator(".dialog-footer button").last()).toBeEnabled();
expect(unexpectedWrites).toEqual([]);
});
}
}
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
for (const initiallyEmpty of [false, true]) {
test(`Files ignores a late ${initiallyEmpty ? "space discovery" : "folder listing"} Reload after session change`, async ({ page }) => {
let release!: () => void;
const heldReload = new Promise<void>((resolve) => { release = resolve; });
let holdNextRead = false;
let holdingRead = false;
const unexpected: string[] = [];
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") {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture forbids writes" } });
return;
}
const next = request.headers().authorization === "Bearer fixture-next-session";
const actor = next ? "fixture-next-user" : "fixture-user";
const name = next ? "new-session.zip" : "old-session.zip";
const delayed = holdNextRead && !next && path === (initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files");
if (delayed) {
holdNextRead = false;
holdingRead = true;
await heldReload;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: initiallyEmpty && !next && !delayed ? [] : [
{ id: `user:${actor}`, label: next ? "New session files" : "Old session files", owner_type: "user", owner_id: actor, space_type: "managed" }
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [{ id: `${actor}-archive`, tenant_id: "fixture-tenant", owner_type: "user", owner_id: actor,
display_path: name, filename: name, size_bytes: 42, content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false, shares: [], metadata: {}, deleted_at: null
}], total: 1, next_cursor: null } });
} else {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
await page.goto("/?files-toolbar&session-switch&language=en");
if (initiallyEmpty) await expect(page.getByText("No file spaces available.", { exact: true })).toBeVisible();
else await expect(page.locator(".file-row").filter({ hasText: "old-session.zip" })).toBeVisible();
const reload = page.locator('[data-interface-id="files.workspace.actions"]').getByRole("button", { name: "Reload", exact: true });
await expect(reload).toBeEnabled();
holdNextRead = true;
await reload.click();
await expect.poll(() => holdingRead).toBe(true);
await page.getByRole("button", { name: "Switch fixture session", exact: true }).click();
await expect(page.locator(".file-row").filter({ hasText: "new-session.zip" })).toBeVisible();
const oldResponse = page.waitForResponse((response) => response.url().includes(initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files?") && response.request().headers().authorization !== "Bearer fixture-next-session");
release();
await oldResponse;
await expect(page.locator(".file-tree-root")).toHaveText(["New session files"]);
await expect(page.locator(".file-row")).toHaveCount(1);
await expect(page.locator(".file-row")).toContainText("new-session.zip");
await expect(reload).toBeEnabled();
expect(unexpected).toEqual([]);
});
}
@@ -0,0 +1,199 @@
import { expect, test, type Page } from "@playwright/test";
const archive = {
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
shares: [], metadata: {}, deleted_at: null
};
async function fixtures(page: Page, connector = false) {
const reads: string[] = [];
const forbidden: string[] = [];
const state = { fail: false };
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const readOnlyPattern = request.method() === "POST" && path === "/api/v1/files/resolve-patterns";
if (request.method() !== "GET" && !readOnlyPattern) {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture refuses all writes" } });
return;
}
reads.push(`${request.method()} ${path}${url.search}`);
if (state.fail && (path === "/api/v1/files" || path.endsWith("/browse"))) {
await route.fulfill({ status: 503, json: { detail: "Fixture listing temporarily unavailable" } });
return;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: [
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
...(connector ? [{ id: "connector:fixture-remote", label: "Remote archive", owner_type: "user", owner_id: "fixture-user", space_type: "connector", connector_space_id: "fixture-remote", connector_profile_id: "fixture-profile", remote_path: "source", provider: "s3", read_only: true, sync_mode: "manual" }] : [])
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [archive], total: 1, next_cursor: null } });
} else if (readOnlyPattern) {
await route.fulfill({ json: { patterns: [{ pattern: "*.zip", matches: [archive] }], unmatched: [] } });
} else if (path === "/api/v1/files/connectors/profiles") {
await route.fulfill({ json: { profiles: [] } });
} else if (path.endsWith("/fixture-profile/browse")) {
const folder = url.searchParams.get("path") ?? "source";
await route.fulfill({ json: { profile_id: "fixture-profile", provider: "s3", path: folder, library_id: null,
read_only: true, has_more: false, decision: { allowed: true }, items: folder.endsWith("/nested")
? [{ kind: "file", name: "remote-letter.pdf", path: "source/nested/remote-letter.pdf", metadata: {}, size_bytes: 42 }]
: [{ kind: "folder", name: "nested", path: "source/nested", metadata: {} }]
} });
} else {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
return { reads, forbidden, state };
}
const header = (page: Page) => page.locator('[data-interface-id="files.workspace.actions"]');
const archiveRow = (page: Page) => page.locator(".file-row").filter({ hasText: "small-archive.zip" });
test("Files global Reload, Create folder and primary Upload remain above both workspace panes", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const toolbar = header(page);
await expect(toolbar).toHaveAttribute("data-workspace-action-scope", "workspace");
await expect(toolbar.getByRole("link", { name: "Open user documentation", exact: true })).toBeVisible();
await expect(toolbar.locator('[data-page-action-group="trailing"] button')).toHaveText(["Reload", /Create Folder/, /Upload/]);
const [bar, reload, create, upload, tree] = await Promise.all([
toolbar.boundingBox(), toolbar.getByRole("button", { name: "Reload", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Create Folder", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Upload", exact: true }).boundingBox(), page.locator(".file-tree-panel").boundingBox()
]);
expect(bar && reload && create && upload && tree).toBeTruthy();
expect(reload!.x + reload!.width).toBeLessThanOrEqual(create!.x);
expect(create!.x + create!.width).toBeLessThanOrEqual(upload!.x);
expect(upload!.x + upload!.width).toBeGreaterThan(bar!.x + bar!.width - 24);
expect(bar!.y + bar!.height).toBeLessThanOrEqual(tree!.y + 1);
await expect(toolbar.getByRole("button", { name: "Upload", exact: true })).toHaveClass(/primary/);
await expect(page.locator('.file-list-sticky [data-workspace-action-scope="workspace"]')).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
test("Files selection tools preserve organization, access and confirmed destructive actions", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
await expect(dialog).toContainText("1 file");
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeEnabled();
}
const remove = dialog.locator('[data-page-action-separation="destructive"]').getByRole("button", { name: "Delete", exact: true });
await remove.click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("alertdialog")).toContainText(/delete|Delete/);
await page.getByRole("alertdialog").getByRole("button", { name: "Cancel", exact: true }).click();
await archiveRow(page).click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Unpack archive", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files Connections and imports exposes explicit tools without starting synchronization", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const initialReads = fixture.reads.length;
await header(page).getByRole("button", { name: "Connections and imports", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Connections and imports", exact: true });
await expect(dialog).toContainText("never synchronizes or imports");
await expect(dialog.getByRole("button", { name: "Sync", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Add Space", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add connector space", exact: true })).toBeVisible();
expect(fixture.reads.slice(initialReads).every((read) => read.startsWith("GET "))).toBe(true);
expect(fixture.forbidden).toEqual([]);
});
test("Files Reload preserves active pattern and property filters and retains loaded data after failure", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-search-row input:not([type=checkbox])").fill("*.zip");
await page.locator(".file-search-row").getByRole("button", { name: "Search", exact: true }).click();
await page.getByRole("combobox", { name: "Campaign use", exact: true }).selectOption("linked");
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before).some((read) => read.startsWith("POST /api/v1/files/resolve-patterns"))).toBe(true);
await expect.poll(() => fixture.reads.slice(before).some((read) => read.includes("campaign_usage=linked"))).toBe(true);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect(page.locator(".file-search-row input:not([type=checkbox])")).toHaveValue("*.zip");
await expect(page.getByRole("combobox", { name: "Campaign use", exact: true })).toHaveValue("linked");
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(archiveRow(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files reader keeps creation visible with permission reasons and cannot use selected write tools", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&read-only&language=en");
await expect(archiveRow(page)).toBeVisible();
const upload = header(page).getByRole("button", { name: "Upload", exact: true });
await expect(upload).toBeDisabled();
await header(page).locator(".disabled-action-tooltip").filter({ has: page.getByRole("button", { name: "Upload", exact: true }) }).focus();
await expect(page.getByRole("tooltip")).toContainText(/upload permission/i);
await expect(header(page).getByRole("button", { name: "Create Folder", exact: true })).toBeDisabled();
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access", "Delete"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeDisabled();
}
expect(fixture.forbidden).toEqual([]);
});
test("Files connector Reload only re-browses the selected remote folder and retains it on failure", async ({ page }) => {
const fixture = await fixtures(page, true);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-tree-root").filter({ hasText: "Remote archive" }).click();
await page.locator(".file-list-panel").getByText("nested", { exact: true }).dblclick();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before)).toEqual(["GET /api/v1/files/connectors/profiles/fixture-profile/browse?path=source%2Fnested"]);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files grouped tools and creation remain reachable without horizontal overflow on narrow German screens", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=de");
await expect(archiveRow(page)).toBeVisible();
await expect(header(page).getByRole("button", { name: "Hochladen", exact: true })).toBeVisible();
await header(page).getByRole("button", { name: "Verbindungen und Importe", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Verbindungen und Importe", exact: true });
await expect(dialog).toBeVisible();
const overflow = await dialog.evaluate((element) => element.scrollWidth - element.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
for (const width of [1440, 900, 390]) {
test(`mixed form controls and attachment actions remain aligned at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1200 });
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route((url) => url.pathname.startsWith("/api/"), (route) => route.abort());
await page.goto("/?form-control-layout&language=en");
const grid = page.getByTestId("mixed-form-grid");
const password = grid.locator('input[type="password"]');
const remove = grid.locator(".toggle-switch-row").filter({ hasText: "Remove configured secret" });
await expect(remove).toBeVisible();
if (width > 1100) {
for (const [input, toggle] of [
[password, remove.locator(".toggle-switch-track")],
[grid.getByRole("textbox", { name: "Wrapped field", exact: true }), grid.locator(".content-grid-item .toggle-switch-track")],
[page.getByRole("textbox", { name: "Other setting", exact: true }), page.getByTestId("mixed-form-layout").locator(".toggle-switch-track")]
]) {
await expect.poll(async () => {
const a = await input.boundingBox(), b = await toggle.boundingBox();
return a && b ? Math.abs(a.y + a.height / 2 - b.y - b.height / 2) : Infinity;
}).toBeLessThan(2);
}
const long = await grid.getByRole("textbox", { name: "Long label field" }).boundingBox();
const short = await grid.getByRole("textbox", { name: "Short label field" }).boundingBox();
expect(Math.abs(long!.y - short!.y)).toBeLessThan(2);
} else {
const field = await password.boundingBox(), toggle = await remove.boundingBox();
expect(toggle!.y).toBeGreaterThan(field!.y + field!.height);
expect(toggle!.height).toBeLessThanOrEqual(64);
}
await remove.getByRole("checkbox").focus();
await page.keyboard.press("Space");
await expect(remove.getByRole("checkbox")).toBeChecked();
await password.fill("fixture-only");
await expect(remove.getByRole("checkbox")).toBeDisabled();
await expect(page.getByTestId("compact-action").getByRole("button")).toHaveCSS("width", "36px");
const attachments = page.getByTestId("global-attachments");
const add = attachments.getByRole("button", { name: "Add first attachment", exact: true });
await expect(add).toHaveCSS("width", "36px");
await expect.poll(() => attachments.locator(".data-grid-empty-action-cell").evaluate((node) => node.getBoundingClientRect().width)).toBeLessThanOrEqual(200);
await add.click();
await expect(attachments.locator(".data-grid-empty-action-cell")).toHaveCount(0);
await expect(attachments.getByRole("button", { name: "Add attachment below", exact: true })).toHaveCSS("width", "36px");
await expect.poll(() => page.locator("main").evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
expect(errors).toEqual([]);
});
}
@@ -0,0 +1,72 @@
import { expect, test, type Page } from "@playwright/test";
async function installHelp(page: Page, language = "en") {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/docs/context?**", (route) => {
const topic = (id: string, title: string, metadata = {}) => ({
id, title, source_module_id: "forms", kind: "reference", anchor_id: `topic-${id}`, summary: "Application guidance", body: "Public service application instructions", order: 1,
active: true, layer: "configured", target_layer: "configured", links: [], unlocks: [], related_modules: [], area_module_ids: ["forms", "services"],
metadata: { tags: ["Application", "Antrag"], ...metadata }, blockers: { modules: [], capabilities: [], scopes: [], configuration: [] }
});
return route.fulfill({ json: {
actor: { documentation_type: new URL(route.request().url()).searchParams.get("type") ?? "user", available_documentation_types: ["user", "admin"] },
versions: { supported_versions: [], installed_versions: {}, fallback_policy: "Installed versions" },
topic_groups: { system: [], pattern: [], workflow: [], reference: [topic("service", "Service application"), topic("field", "Application field", { parent_topic_id: "service" })] },
layers: { configured: { modules: [{ id: "forms", name: language === "de" ? "Formulare" : "Forms" }, { id: "services", name: language === "de" ? "Leistungen" : "Services" }], routes: [], permissions: [] },
available: { routes: [] }, evidence: { optional_modules: [], sources: [] } }
} });
});
await page.goto(`/?help-center&language=${language}`);
await expect(page.locator(".docs-tree-page").filter({ hasText: "Service application" }).first()).toBeVisible();
return errors;
}
test("help keeps repeated topic selection and expansion local to the clicked occurrence", async ({ page }) => {
const errors = await installHelp(page);
const area = page.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Services$/ }) }).first();
await area.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
const repeated = area.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Service application$/ }) }).first();
await repeated.locator(":scope > .docs-tree-row > .docs-tree-page").click();
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
await expect(repeated.locator(":scope > .docs-tree-row > .docs-tree-page")).toHaveClass(/is-active/);
await repeated.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
await expect(page.locator(".docs-tree-page").filter({ hasText: /^Application field$/ })).toHaveCount(1);
await page.reload();
await expect(area.locator(".docs-tree-page.is-active")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
for (const language of ["en", "de"]) test(`help finds topics from tags and localized area names (${language})`, async ({ page }) => {
const errors = await installHelp(page, language);
const search = page.getByRole("searchbox");
await search.fill(language === "de" ? "leistungen antrag" : "services application");
const results = page.getByRole("region", { name: language === "de" ? "Passende Hilfethemen" : "Matching help topics" });
await expect(results.getByRole("option", { name: "Service application", exact: true })).toHaveCount(1);
await expect(results.getByRole("option", { name: "Application field", exact: true })).toHaveCount(1);
await search.fill("not-found-tag");
await expect(results.getByRole("option")).toHaveCount(0);
await search.fill("antrag");
await results.getByRole("option", { name: "Service application", exact: true }).click();
await expect(search).toHaveValue("");
await expect(page.locator(".docs-page-main h2")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
test("help tag dropdown shares list filtering and all/none behavior", async ({ page }) => {
const errors = await installHelp(page);
await page.getByRole("button", { name: "Areas and tags", exact: true }).click();
const filter = page.getByRole("dialog", { name: "Areas and tags", exact: true });
await filter.getByRole("button", { name: /clear all|deselect all|select none/i }).click();
const results = page.getByRole("region", { name: "Matching help topics" });
await expect(results.getByRole("option")).toHaveCount(0);
await filter.getByRole("checkbox", { name: "Services", exact: true }).check();
await expect(results.getByRole("option")).toHaveCount(2);
await filter.getByRole("button", { name: "Select all", exact: true }).click();
await expect(results).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(filter).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -0,0 +1,215 @@
import { expect, test, type Page } from "@playwright/test";
type Policy = {
smtp_credentials?: { inherit?: boolean | null };
imap_credentials?: { inherit?: boolean | null };
allow_lower_level_limits?: Record<string, boolean>;
[key: string]: unknown;
};
async function installPolicy(page: Page, options: { parent?: Policy | null; local?: Policy; failFirst?: boolean; failReadOnce?: boolean; holdSave?: Promise<void> } = {}) {
let current = options.local ?? {};
const parent = options.parent === null ? null : { smtp_credentials: { inherit: false }, imap_credentials: { inherit: true }, ...options.parent };
const writes: Policy[] = [];
const unexpected: string[] = [];
let reads = 0;
function response() {
const effective: Policy = { allow_user_profiles: true, allow_group_profiles: true, allow_campaign_profiles: true, ...parent, ...current };
for (const protocol of ["smtp", "imap"] as const) {
const key = `${protocol}_credentials` as const;
const locked = parent?.allow_lower_level_limits?.[`${key}.inherit`] === false;
effective[key] = { inherit: (!locked ? current[key]?.inherit : null) ?? parent?.[key]?.inherit ?? true };
}
return { policy: current, parent_policy: parent, effective_policy: effective, effective_policy_sources: [
{ scope_type: "system", label: "System", path: "system", applied_fields: [], policy: parent ?? {} },
{ scope_type: "tenant", label: "Tenant", path: "tenant", applied_fields: [], policy: current }
] };
}
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (new URL(request.url()).pathname.startsWith("/api/v1/mail/policies/")) {
if (request.method() === "GET") {
reads += 1;
if (options.failReadOnce && reads === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy load failed" } }); return;
}
await route.fulfill({ json: response() }); return;
}
if (request.method() === "PUT") {
const submitted = request.postDataJSON().policy as Policy;
writes.push(submitted);
if (options.holdSave) await options.holdSave;
if (options.failFirst && writes.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy write failed" } }); return;
}
current = submitted;
await route.fulfill({ json: response() }); return;
}
}
unexpected.push(`${request.method()} ${new URL(request.url()).pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { writes, unexpected, get reads() { return reads; } };
}
function credentialRows(page: Page) {
const section = page.getByTestId("mail-credential-policy");
return {
section,
smtp: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="SMTP credential selection"]') }),
imap: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="IMAP credential selection"]') })
};
}
test("Mail credential policy exposes inherited/effective choices and persists stable override keys", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator(".policy-effective-value")).toContainText("Require explicit Mail credential");
// An inherited false does not itself lock the child: only the separate
// lower-level limit may prevent choosing an inherited default credential.
await smtp.locator("select").selectOption("profile");
await imap.locator("select").selectOption("explicit");
await smtp.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
const wildcard = page.locator(".mail-policy-pattern-row").filter({ hasText: "SMTP hostnames" });
await wildcard.getByRole("checkbox", { name: "Whitelist", exact: true }).focus();
await page.keyboard.press("Space");
const campaignRow = page.locator(".mail-policy-row").filter({ hasText: "Campaign-scoped profiles" });
await campaignRow.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].allow_lower_level_limits).toMatchObject({
"smtp_credentials.inherit": false, "whitelist.smtp_hosts": false, "allow_campaign_profiles": false
});
expect(Object.keys(fixture.writes[0].allow_lower_level_limits ?? {}).some((key) => key.startsWith("i18n:"))).toBe(false);
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.unexpected).toEqual([]);
});
test("ancestor credential locks cannot be bypassed or re-enabled and stale local overrides are cleared on save", async ({ page }) => {
const fixture = await installPolicy(page, {
parent: { allow_lower_level_limits: { "smtp_credentials.inherit": false } },
local: { smtp_credentials: { inherit: true }, allow_lower_level_limits: { "smtp_credentials.inherit": true } }
});
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap, section } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).not.toBeChecked();
await expect(section).toContainText("An ancestor has locked credential selection.");
await imap.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: null });
expect(fixture.writes[0].allow_lower_level_limits?.["smtp_credentials.inherit"]).toBeUndefined();
expect(fixture.unexpected).toEqual([]);
});
test("system defaults are concrete and campaign policy has no lower-level override controls", async ({ page }) => {
const fixture = await installPolicy(page, { parent: null });
await page.goto("/?mail-credential-policy&scope=system&language=en");
let { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(smtp.locator('option[value="inherit"]')).toHaveCount(0);
await smtp.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].allow_lower_level_limits).toHaveProperty("allow_campaign_profiles", true);
await page.goto("/?mail-credential-policy&scope=campaign&language=en");
({ smtp, imap } = credentialRows(page));
await expect(smtp.getByRole("checkbox")).toHaveCount(0);
await expect(imap.getByRole("checkbox")).toHaveCount(0);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].allow_lower_level_limits).toEqual({});
expect(fixture.unexpected).toEqual([]);
});
test("read-only and workflow-locked policy scopes cannot change credential selection", async ({ page }) => {
const fixture = await installPolicy(page);
for (const blocker of ["read-only", "locked"]) {
await page.goto(`/?mail-credential-policy&language=en&${blocker}`);
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toBeDisabled();
await expect(imap.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
}
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("successful policy saves remain successful when a dependent refresh fails", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en&refresh-failure");
await credentialRows(page).smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Mail policy was saved, but refreshing dependent data failed");
await expect(page.locator(".alert.danger")).toHaveCount(0);
await expect(page.locator(".alert.success")).toContainText("Mail profile policy saved");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect.poll(() => fixture.reads).toBeGreaterThan(1);
await expect(credentialRows(page).smtp.locator("select")).toHaveValue("profile");
expect(fixture.writes).toHaveLength(1);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy writes retain the draft and require an explicit retry", async ({ page }) => {
let finish!: () => void;
const fixture = await installPolicy(page, { failFirst: true, holdSave: new Promise<void>((resolve) => { finish = resolve; }) });
await page.goto("/?mail-credential-policy&language=en");
const { smtp } = credentialRows(page);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(smtp.locator("select")).toBeDisabled();
finish();
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy write failed");
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeEnabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1]).toEqual(fixture.writes[0]);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy loads cannot enable editing an unknown ancestor policy", async ({ page }) => {
const fixture = await installPolicy(page, { failReadOnce: true });
await page.goto("/?mail-credential-policy&language=en");
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy load failed");
await expect(credentialRows(page).smtp.locator("select")).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(credentialRows(page).smtp.locator("select")).toBeEnabled();
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("credential selection choices and explanations are available in German", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=de");
const section = page.getByTestId("mail-credential-policy");
await expect(section.getByRole("heading", { name: "Auswahl der Zugangsdaten", exact: true })).toBeVisible();
const smtp = section.getByRole("combobox", { name: "SMTP-Zugangsdaten auswählen", exact: true });
await expect(smtp).toBeEnabled();
await expect(smtp.locator('option[value="inherit"]')).toHaveText("Richtlinie vom übergeordneten Bereich erben");
await expect(smtp.locator('option[value="profile"]')).toHaveText("Standard-Zugangsdaten des Profils zulassen");
await expect(smtp.locator('option[value="explicit"]')).toHaveText("Ausdrückliche Mail-Zugangsdaten verlangen");
await expect(section).toContainText("Beide Optionen belassen Geheimnisse in Mail");
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});

Some files were not shown because too many files have changed in this diff Show More