Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d37aa527f | ||
|
|
6591aaa3fd | ||
|
|
dc1f244f17 | ||
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 |
@@ -138,6 +138,7 @@ dist
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.component-test-build-*/
|
||||
.file-drop-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
|
||||
@@ -26,18 +26,27 @@ cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
For WebUI checks:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./devkit doctor --repo core
|
||||
./devkit check --profile ui --repo core --dry-run
|
||||
./devkit check --profile ui --repo core
|
||||
```
|
||||
|
||||
For an individually selected component batch (one compilation):
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-components
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-capabilities
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||
npm run test:components -- mail-components page-layout
|
||||
npm run test:module-capabilities
|
||||
npm run test:module-permutations
|
||||
```
|
||||
|
||||
Run the consolidated focused check when a change touches module discovery, optional integrations, shared mail components, or mailbox listing:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/checks/check-focused.sh
|
||||
./devkit check --profile full
|
||||
```
|
||||
|
||||
## Working Rules
|
||||
|
||||
@@ -33,6 +33,8 @@ Canonical policy documents live in `docs/`:
|
||||
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
||||
- [INTEGRITY_PERFORMANCE_CONTRACT.md](docs/INTEGRITY_PERFORMANCE_CONTRACT.md)
|
||||
- [TABULAR_SOURCE_CONTRACT.md](docs/TABULAR_SOURCE_CONTRACT.md)
|
||||
- [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.md)
|
||||
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -26,7 +26,9 @@ from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
config = context.config
|
||||
database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
# Alembic stores options through ConfigParser: escape only its interpolation
|
||||
# syntax so URL-encoded credentials/socket paths reach SQLAlchemy unchanged.
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
# Migrations can run inside the long-lived application process when module
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Disposable resource-bounded operations
|
||||
|
||||
`security.bounded_process.run_bounded_operation` runs a trusted, importable,
|
||||
module-level `bytes -> bytes` function in a fresh interpreter. Core owns the
|
||||
process lifecycle, not the business parser. Owners retain authorization,
|
||||
sessions, provider reads, idempotency and persistence in the parent and pass
|
||||
only explicit bounded data. Never accept the operation, module or source path
|
||||
from a client. Use `security.worker_payload` for typed values; it does not use
|
||||
pickle, arbitrary constructors or JSON object hooks.
|
||||
|
||||
The runner requires POSIX process groups, `waitid(WNOWAIT)` and resource limits.
|
||||
Unsupported controls fail closed; there is no in-process fallback. The child
|
||||
uses `-I -B`, a fixed minimal environment, `/` as working directory, closed
|
||||
inherited descriptors and a new process session. Limits are installed before
|
||||
the owning module is imported. Installed dependencies must support isolated
|
||||
Python imports; development `PYTHONPATH` alone is insufficient.
|
||||
|
||||
`ProcessLimits` specifies wall-clock seconds (including child startup), CPU
|
||||
seconds, virtual address space, input/output pipe bytes and maximum regular-file
|
||||
size. Defaults are 10 seconds wall/CPU, 256 MiB address space, 8 MiB input and
|
||||
output, and no regular-file output. Wall/CPU limits are at most 600 seconds,
|
||||
memory 64 MiB–8 GiB, pipe limits 1 byte–256 MiB, and file size 0–2 GiB. Owners
|
||||
must document their tighter functional limits; a transport cap does not replace
|
||||
row, archive expansion, item-count or artifact limits.
|
||||
|
||||
Admission is non-queuing. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` defaults to 1
|
||||
(range 1–16) and applies across these operations **within each API/worker
|
||||
process**. Multiply capacity and memory budgets by the number of API/worker
|
||||
processes when sizing an installation. This is not a fleet-wide semaphore,
|
||||
cgroup quota, filesystem/network sandbox or permission to run arbitrary code.
|
||||
`RLIMIT_FSIZE` is per file, not a total disk quota. Owners creating staged files
|
||||
must enforce cumulative quotas and clean up their own private directories.
|
||||
|
||||
Owners preparing bounded local snapshots can enter
|
||||
`bounded_operation_admission()` before preparation and pass its token as
|
||||
`admission=` to the runner. This reuses shared capacity rather than reserving a
|
||||
second slot. Tokens belong to their active context, thread and process; expired,
|
||||
cross-thread and overlapping reuse fail. Preparation exceptions release the
|
||||
slot without launching a child. Never hold admission while waiting for a user;
|
||||
parent-side preparation still requires explicit I/O and byte bounds.
|
||||
|
||||
The parent concurrently drains stdout/stderr while writing input. Output is
|
||||
bounded during reading, stderr is discarded and capped at 64 KiB, and raw child
|
||||
tracebacks are never returned. Every success, exception, timeout, cancellation
|
||||
and callback failure kills the owned process group before reaping its leader,
|
||||
including descendants which close their inherited pipes. A module-level child
|
||||
handler must return bytes; it must not print logs/progress to stdout.
|
||||
|
||||
The optional `cancelled` callback runs in the parent at most roughly every
|
||||
50 ms while waiting. It must be fast and must not return an awaitable. It may
|
||||
also service a module-owned bounded progress protocol; exceptions terminate
|
||||
the child and propagate. There is no fabricated progress for killed work.
|
||||
`ProcessBudgetError.code` distinguishes busy, cancelled, timeout, CPU, memory,
|
||||
input/output limits, unavailable controls and worker failure. Owners map these
|
||||
to their existing structured diagnostics and recovery semantics.
|
||||
|
||||
The private typed-data codec supports null, booleans, strings, bytes, integers,
|
||||
floats, Decimal, UUID, date/time/datetime, lists, tuples and string-keyed maps.
|
||||
It rejects unsupported objects, malformed/trailing bytes, duplicate keys,
|
||||
excess depth (64) and node counts (1,000,000). Operation DTOs remain owner
|
||||
contracts and require owner validation. Do not persist this private wire format
|
||||
or use it as a public API.
|
||||
|
||||
Tests use real child processes for catastrophic regex, memory exhaustion,
|
||||
noisy output, exact limits, cancellation, closed-pipe hangs and descendant
|
||||
cleanup. These are local process regression tests, not production concurrent
|
||||
load certification. Operators still need target Linux/cgroup, cancellation,
|
||||
worker-count, memory and disk-quota evidence before raising concurrency.
|
||||
|
||||
## Deutsche Betriebszusammenfassung
|
||||
|
||||
Rechenintensive, vertrauenswürdige Moduloperationen laufen in einem frischen
|
||||
Prozess mit harten Laufzeit-, CPU-, Speicher- und Ausgabegrenzen. Berechtigungen,
|
||||
Sitzungen, Zugangsdaten und Datenbankänderungen bleiben im Hauptprozess. Fehlende
|
||||
Betriebssystemkontrollen führen zu einer Diagnose, nicht zu ungeschützter
|
||||
Ausführung. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` begrenzt die gemeinsame
|
||||
Zulassung je API-/Worker-Prozess, standardmäßig auf 1. Mehrere Prozesse haben
|
||||
jeweils eigene Grenzen; systemweite Speicher- und Festplattenquoten müssen
|
||||
Betreiber zusätzlich konfigurieren und auf der Zielinstallation prüfen. Die
|
||||
Schnittstelle ist keine Sandbox für beliebigen Code. Modul-Dokumentation nennt
|
||||
die jeweiligen fachlichen Grenzen, Fortschritts- und Wiederholungsregeln.
|
||||
@@ -1,8 +1,31 @@
|
||||
# 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.
|
||||
|
||||
The containing card removes its body padding; tables stay at `width: 100%` and
|
||||
`max-width: 100%`. Do not cancel padding with negative margins or an expanded
|
||||
`calc()` width: the table's maximum width correctly clamps that expansion, and
|
||||
the result is a visible gap. Existing cards with only a DataGrid, admin table
|
||||
surface, or connection tree inherit the same zero-inset geometry, directly or
|
||||
through a `LoadingFrame`. Its loading overlay does not count as content and
|
||||
stays within the table body. Mixed prose/form content keeps the usual insets;
|
||||
choose `bodyLayout="table"` explicitly when combining a full-width table with
|
||||
separately padded notices. Ordinary standalone tables retain their border.
|
||||
|
||||
Deutsch: Tabellenkarten entfernen den Innenabstand am Karteninhalt. Die Tabelle
|
||||
bleibt bei 100 Prozent Breite; negative Ränder und verbreiternde `calc()`-Werte
|
||||
sind nicht erforderlich. Das gilt auch während des Ladens. Karten mit Text
|
||||
oder Formularen behalten ihren Innenabstand. Für eine randlose Tabelle neben
|
||||
einem separat gepolsterten Hinweis ist `bodyLayout="table"` ausdrücklich zu
|
||||
setzen; alleinstehende Tabellen behalten ihren Rahmen.
|
||||
|
||||
`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 +40,77 @@ 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.
|
||||
- `preferredMaxWidth` is a presentation-only automatic-fit ceiling. It retains
|
||||
sensible initial proportions without limiting direct user resizing or
|
||||
right-side resize compensation. A declared `maxWidth` still wins as the
|
||||
manual limit. Cover may exceed preferred ceilings after other automatic
|
||||
sizing targets are exhausted, preserving the no-blank-filler contract.
|
||||
- Do not give expandable text columns arbitrary `maxWidth` values merely to
|
||||
control their initial appearance. At that limit, a resize handle can shrink
|
||||
but cannot grow, regardless of whether the next column is resizable. Prefer
|
||||
weighted `minmax(..., Nfr)` declarations with `preferredMaxWidth` when needed;
|
||||
ordinary cover-mode growth may
|
||||
create horizontal overflow without changing fixed neighbors. Campaign's
|
||||
Recipient(s), Delivery and configurable recipient-field columns follow this
|
||||
contract. The existing signature format is unchanged for other columns;
|
||||
introducing a preferred ceiling invalidates only that grid's previous width
|
||||
contract, while sort/filter preferences remain intact.
|
||||
- `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.
|
||||
Eine feste Maximalbreite stoppt das Vergrößern unabhängig von Nachbarspalten.
|
||||
`preferredMaxWidth` begrenzt dagegen nur die automatische Anfangsaufteilung,
|
||||
nicht persönliche Breiten oder den Ausgleich beim Ziehen. Empfänger,
|
||||
Zustellung und konfigurierte Empfängerfelder verwenden diese Darstellungsgrenze.
|
||||
Bei Bedarf verbreitert sich die Tabelle und bleibt scrollbar; persönliche
|
||||
Breiten ändern keine Kampagnendaten. Am rechten Scrollrand kann eine breite
|
||||
linke Spalte wieder verkleinert werden, weil rechte Textspalten über ihre
|
||||
bevorzugte Anfangsbreite hinaus Platz aufnehmen dürfen.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
@@ -39,7 +133,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 +145,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 +169,11 @@ once and recomputed from the declared column layout.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
|
||||
`webui/conformance/tests/data-grid-layout.spec.ts` exercises the real rendered
|
||||
grid with deliberately undersized action preferences, constrained containers,
|
||||
horizontal scrolling, changing/empty action sets, keyboard and pointer resizing,
|
||||
Escape cancellation, remount persistence, responsive contraction and restoration,
|
||||
free/content mode, and constrained compensation. Run with
|
||||
`npm run test:conformance -- data-grid-layout.spec.ts`; its isolated test server
|
||||
is stopped automatically afterwards.
|
||||
|
||||
@@ -74,6 +74,19 @@ Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
backup/restore hooks for the module installer.
|
||||
|
||||
#### Ownership-history upgrade repair
|
||||
|
||||
Core revision `c58a2d7e9f10` repairs existing ownership-transfer tables that
|
||||
predate the `decisions` column. Such installations can otherwise return HTTP
|
||||
500 from `/api/v1/ownership/transfers`, including Campaign Settings. Apply the
|
||||
normal forward migrations after taking a backup; do not stamp a revision or
|
||||
recreate the table. The repair is available on both migration tracks, adds only
|
||||
the missing non-null JSON column, and initializes old rows with an empty list.
|
||||
It preserves owners, approvals, transfer states, revisions, timestamps, and any
|
||||
existing decision history. Historical decisions are not reconstructed or
|
||||
invented. Downgrading this repair retains the additive column and its evidence.
|
||||
Verify that ownership-transfer listing and Campaign Settings load after upgrade.
|
||||
|
||||
### PostgreSQL Production Target
|
||||
|
||||
PostgreSQL is the primary development and production target. SQLite remains
|
||||
@@ -408,6 +421,27 @@ To stop PostgreSQL and Redis when the launcher exits:
|
||||
GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh
|
||||
```
|
||||
|
||||
## Development WebUI Dependency Caches
|
||||
|
||||
The application and browser-conformance harness share installed JavaScript
|
||||
packages but must not share Vite's optimized-dependency cache. The application
|
||||
uses `webui/node_modules/.vite/govoplan-app`; the conformance harness uses
|
||||
`webui/node_modules/.vite/govoplan-conformance`. Keep these explicit sibling
|
||||
directories when adding development or test configurations. Setting a different
|
||||
Vite `root` alone does not isolate this cache.
|
||||
|
||||
A shared cache can make otherwise healthy Workflow, Dataflow or deferred editors
|
||||
show “The resource could not be loaded.” The browser then reports an asset such
|
||||
as `@xyflow_react.js` with HTTP 504 `Outdated Optimize Dep`, while the corresponding
|
||||
API still returns HTTP 200. This is not a missing workflow permission or a reason
|
||||
to rerun a pipeline. Preserve unsaved work, let the existing development server
|
||||
reload the corrected configuration (or restart that WebUI server), then reload
|
||||
the browser. Do not clear application data, change grants or restart delivery
|
||||
workers to repair a frontend dependency cache.
|
||||
|
||||
Run `npm run test:vite-cache-isolation` in `govoplan-core/webui` to verify the real
|
||||
resolved Vite configurations without starting servers or overwriting caches.
|
||||
|
||||
## Module Install/Uninstall Operations
|
||||
|
||||
Use Admin > System > Modules for planning. The running API server validates and
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
# Integrity-preserving performance contracts
|
||||
|
||||
These are implementation guarantees and regression-test boundaries, not a
|
||||
security certification or production load-test result. Feature-specific policies
|
||||
and help remain in the owning modules' English and German documentation topics.
|
||||
|
||||
## Refreshes, edits and table rendering
|
||||
|
||||
The shell runs at most one module-load refresh per authority generation. Multiple
|
||||
invalidations coalesce into one trailing refresh; stale results and errors cannot
|
||||
replace a newer generation. Focus refreshes are throttled to five seconds and
|
||||
focus/visibility refreshes on hidden pages are suppressed; explicit module
|
||||
invalidations still trigger a read. Authentication/tenant reset disposes the
|
||||
previous controller. Authentication and authorization checks are not cached away.
|
||||
|
||||
Editable modules must reconcile a save against the submitted draft and accepted
|
||||
server revision: edits made while a request is pending remain dirty. Completion
|
||||
must be fenced by security-relevant authority and selection, not merely an auth
|
||||
object's reference identity. A harmless profile refresh must not discard an
|
||||
accepted newly created ID and invite a duplicate create. An old account's mutation
|
||||
continuation must not reload its catalogue into the current account's page.
|
||||
|
||||
DataGrid precomputes first-occurrence row indices once for client sorting and
|
||||
filtering. Duplicate object references, primitive values, `NaN` and sparse arrays
|
||||
retain `Array.indexOf` behavior. Comparator order, visible pagination, server-side
|
||||
pagination, sizing and resize rules are unchanged. Do not replace this with a
|
||||
last-occurrence map or change ordering as a side effect of an optimization.
|
||||
|
||||
## Conditional responses
|
||||
|
||||
The shared JSON GET middleware performs route handling, including authorization,
|
||||
before considering `If-None-Match`. It buffers only responses up to 1 MiB for a
|
||||
body-derived ETag. Known larger responses bypass buffering; an unknown-length
|
||||
stream crossing that limit replays its exact prefix and streams the remainder.
|
||||
No data is truncated and no large joined copy is created. The crossing chunk is
|
||||
already producer-owned: this is not a process-wide or route-output memory limit.
|
||||
Empty chunks do not accumulate. Large responses may no longer receive a
|
||||
middleware-generated ETag/304; explicit route ETags remain intact. Small-response
|
||||
cache semantics and credential/language/context `Vary` fields are retained.
|
||||
|
||||
## Shared helpers and concurrency
|
||||
|
||||
Central helpers replace exact live-code duplicates only. Actor precedence,
|
||||
whitespace handling and service-account differences remain explicit owner choices;
|
||||
historical migration code is not redirected to mutable runtime helpers. Connector
|
||||
search ACL token projection keeps its first-seen ordering and existing 500-token
|
||||
cap, stopping work once that cap is reached. Provider schemas are inferred in one
|
||||
pass without storing a second list of every column value.
|
||||
|
||||
The keyed-list three-way merge retains insertion anchors when unrelated fields
|
||||
change. Concurrent additions use deterministic ordering; contradictory anchors
|
||||
produce a collection-order conflict instead of silently relocating an item.
|
||||
No existing endpoint is newly opted into merge behavior by this change.
|
||||
|
||||
SQL JSON authorization predicates support the explicitly tested SQLite and
|
||||
PostgreSQL dialects, retain exact string membership and reject unsupported
|
||||
dialects. Apply tenant and authorization predicates before counting/pagination;
|
||||
never page a broader result first and filter away unauthorized records afterward.
|
||||
SQLite execution and PostgreSQL SQL compilation are not substitutes for a
|
||||
deployment's PostgreSQL concurrency and representative-data load tests.
|
||||
|
||||
## Migration connection URLs
|
||||
|
||||
Alembic preserves the configured database URL exactly in online and offline
|
||||
migration modes, including percent-encoded credentials and PostgreSQL Unix-socket
|
||||
paths. Escaping applies only at its ConfigParser boundary; operators must not
|
||||
double-escape `%` in `DATABASE_URL` or alter working connection credentials to
|
||||
work around interpolation errors. This does not change the target database,
|
||||
authentication, TLS policy, or migration contents.
|
||||
|
||||
## Deutsch: Integrität vor Geschwindigkeit
|
||||
|
||||
Der zentrale Modul-Refresh bündelt gleichzeitige Auslöser und verwirft veraltete
|
||||
Ergebnisse einschließlich Fehlermeldungen. Ein Wechsel von Anmeldung oder Mandant
|
||||
beendet die bisherige Generation. Fokusaktualisierungen sind auf einen Auslöser
|
||||
je fünf Sekunden begrenzt. Berechtigungsprüfungen bleiben erhalten.
|
||||
|
||||
Speicherantworten dürfen zwischenzeitliche Bearbeitungen nicht überschreiben.
|
||||
Ein unveränderter Berechtigungskontext mit einem neuen Profilobjekt darf eine
|
||||
bereits bestätigte neue ID oder Revision nicht verwerfen. Umgekehrt dürfen alte
|
||||
Anfragen nach einem Kontowechsel keine Daten in den neuen Kontext übernehmen.
|
||||
Die DataGrid-Optimierung erhält Reihenfolge, Filter-, Seiten- und Größenverhalten.
|
||||
|
||||
Die ETag-Middleware puffert höchstens 1 MiB Nutzdaten zuzüglich eines bereits vom
|
||||
Erzeuger gelieferten Grenz-Chunks. Größere Antworten werden vollständig weitergereicht,
|
||||
nicht abgeschnitten; automatisch erzeugte ETags können dabei entfallen.
|
||||
Autorisierung läuft auch bei bedingten Anfragen. Das ist keine allgemeine
|
||||
Speicherbegrenzung für Routen oder Prozesse.
|
||||
|
||||
Gemeinsame Helfer erhalten die bisherigen fachlichen Unterschiede. Listen-Merges
|
||||
bewahren Einfügepositionen oder melden widersprüchliche Reihenfolgen explizit als
|
||||
Konflikt. Datenbankseitige Autorisierung erfolgt vor Zählung und Seitenauswahl.
|
||||
Regressionstests belegen diese Verträge; reale Provider-, PostgreSQL- und Lasttests
|
||||
in einer repräsentativen Umgebung bleiben Teil der Betriebsfreigabe.
|
||||
|
||||
Alembic übernimmt die konfigurierte Datenbank-URL in Online- und Offline-Läufen
|
||||
unverändert, einschließlich prozentkodierter Zugangsdaten und PostgreSQL-
|
||||
Unix-Socket-Pfade. Die Maskierung erfolgt ausschließlich an der ConfigParser-
|
||||
Grenze; `%` in `DATABASE_URL` nicht doppelt maskieren und funktionierende
|
||||
Zugangsdaten nicht als Umgehung ändern. Zieldatenbank, Anmeldung, TLS-Vorgaben und
|
||||
Migrationsinhalte bleiben unverändert.
|
||||
@@ -17,7 +17,7 @@ domain modules own their compositions.
|
||||
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
|
||||
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
|
||||
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders guarded Reload in the right-aligned group immediately before Create/primary actions; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
|
||||
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
|
||||
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
|
||||
@@ -27,9 +27,46 @@ domain modules own their compositions.
|
||||
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
|
||||
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
|
||||
| Measured operation feedback | `LoadingFrame` over existing content | Use `indicator="none"` with native measured progress for long-running operations; `progress={null}` means unknown, never a synthetic percentage. Keep dialog content inert and close controls disabled until success or error. Existing consumers retain their loading indicator. | Files archive inspection/extraction, layout primitive tests, managed-archive browser conformance |
|
||||
|
||||
## Boundary
|
||||
|
||||
Side-rail customization uses the shared `NavigationPreferenceEditor` for system,
|
||||
tenant, personal, and View layouts. Modules and labelled separators share one
|
||||
ordered list, with pointer drag-and-drop, keyboard reordering, and explicit
|
||||
add/remove actions. Consumers retain persistence and dirty-state ownership;
|
||||
mounting the editor does not create a draft change. See
|
||||
`NAVIGATION_LAYOUT_CONTRACT.md` for inheritance, locked items, optional-module
|
||||
preservation, and collapsed-rail grouping.
|
||||
|
||||
Action columns use `TableActionGroup` or declare `columnType: "actions"` when
|
||||
their composition differs. DataGrid owns measured action minima, initial column
|
||||
allocation, persistent resizing, and local horizontal scrolling; consumers must
|
||||
not compensate with clipped overflow or copied fixed widths. See
|
||||
`DATAGRID_SIZING_CONTRACT.md`. Dialog forms use `DialogForm` and `FormGrid` inside
|
||||
the shared size-bounded dialog. Do not add a content minimum wider than the
|
||||
panel's padded interior. Genuinely wide content, such as a table, owns its own
|
||||
local scroller instead of making the entire dialog scroll horizontally.
|
||||
|
||||
In `FormGrid` and `FormLayout`, direct `FormField` and `ToggleSwitch` items
|
||||
align their controls at the row's lower edge. A single control inside a
|
||||
`GridItem` follows the same rule. Labels may wrap without shifting adjacent
|
||||
switches up into the label row. Do not add per-module top margins or empty
|
||||
labels; single-column layouts must not retain a phantom label spacer.
|
||||
|
||||
Credential editors resolve public reference labels when opened. A failed
|
||||
save displays its error inside the dialog and keeps the entered draft for an
|
||||
explicit retry. While a write is pending, repeated submission, edits, and
|
||||
dialog dismissal are disabled; no configured secret is read back from storage.
|
||||
|
||||
The shared rich-text editor emits content changes only for actual document
|
||||
edits. Mounting, read-only changes, loading a saved value, and switching between
|
||||
visual and source inspection must preserve the controlled HTML without marking
|
||||
the owning page dirty. This is especially important for legacy Campaign HTML:
|
||||
merely visiting Template must not normalize it or require a save on leaving.
|
||||
The WYSIWYG lifecycle browser conformance covers both visual and legacy-source
|
||||
initial content, as well as genuine typing.
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
|
||||
@@ -137,3 +137,15 @@ Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run test:conformance
|
||||
```
|
||||
|
||||
For shared component regressions, `npm run test:components -- page-layout
|
||||
documentation-help layout-primitives` compiles the component test configuration
|
||||
once and executes the selected suites. With no suite names it runs every shared
|
||||
component suite. Existing single-suite aliases such as `npm run test:page-layout`
|
||||
remain supported. Every invocation owns an ignored, uniquely named temporary
|
||||
output directory and removes it on completion, failure, or handled interruption;
|
||||
parallel invocations cannot delete each other's compiled tests. A forcibly killed
|
||||
process may leave its own ignored directory behind. The launcher itself is
|
||||
checked with `npm run test:component-runner` without compiling the application or
|
||||
starting a server. Browser conformance remains a separate verification layer;
|
||||
passing component tests does not constitute a complete module review.
|
||||
|
||||
@@ -1635,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a
|
||||
controlled deployment option rather than a replacement for release package
|
||||
builds.
|
||||
|
||||
A failed local WebUI package import receives one automatic retry after 250 ms.
|
||||
Descriptor validation still fails closed; it is not bypassed by the retry.
|
||||
If an enabled local module still cannot load, the signed-in shell warns that its
|
||||
screens and integrations may be unavailable and identifies the module. This is
|
||||
a loading failure, not an uninstall. Save other drafts before manually reloading
|
||||
the page; there is no automatic page reload or persistent retry loop. A verified
|
||||
remote fallback that successfully loads clears that module's warning. Packages
|
||||
absent from the optional build graph remain absent, and effective View filtering
|
||||
continues to control which loaded UI capabilities are exposed.
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
Maintenance mode is the required operating state for package install/uninstall
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
|
||||
## Standard layout / Standardanordnung
|
||||
|
||||
No configuration is required to enable sections. Installed modules contribute
|
||||
the standard product-area membership, labels, and order: Work; Services and
|
||||
cases; Records and documents; Communication; Meetings and decisions; Data and
|
||||
assurance; People and responsibility. The dashboard stays first; unclassified
|
||||
destinations remain available under More tools. Only nonempty, authorized and
|
||||
View-visible groups appear. Composed product entries keep their placement
|
||||
owner's directly declared area. If none exists, the navigation aliases of
|
||||
other authorized owners preserve area membership. These are live defaults,
|
||||
not a saved copy that needs replacing whenever optional modules change.
|
||||
|
||||
The editor explains whether the current level is inherited, custom grouped, or
|
||||
explicitly flat. To restore defaults, choose **Use inherited layout** and Save
|
||||
on the owning page: personal Settings inherits the tenant, tenant settings
|
||||
inherit the system, and system settings inherit the standard product areas.
|
||||
An explicitly flat personal, tenant, system, or View configuration is never
|
||||
silently replaced with standard groups. A View may also explicitly select flat
|
||||
navigation. Reset the responsible override rather than changing permissions.
|
||||
|
||||
Abschnitte sind ohne zusätzliche Konfiguration aktiv. Die Standardanordnung
|
||||
gliedert verfügbare Module in Arbeit; Leistungen und Vorgänge; Akten und
|
||||
Dokumente; Kommunikation; Termine und Entscheidungen; Daten und
|
||||
Qualitätssicherung; Personen und Verantwortung. Das Dashboard steht davor,
|
||||
nicht zugeordnete Ziele bleiben unter Weitere Werkzeuge erreichbar. Leere oder
|
||||
nicht zugängliche Gruppen erscheinen nicht. Der gemeinsame Editor zeigt an,
|
||||
ob eine geerbte, eigene gruppierte oder ausdrücklich ungegliederte Anordnung
|
||||
vorliegt. **Geerbte Anordnung verwenden** und anschließendes Speichern entfernt
|
||||
nur die Anpassung dieser Ebene: persönlich → Mandant → System →
|
||||
Standardanordnung. Bewusst gespeicherte ungegliederte Anordnungen bleiben
|
||||
erhalten. Diese Darstellung erteilt keine Berechtigungen und ändert keine Daten.
|
||||
|
||||
## 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.
|
||||
@@ -4,6 +4,12 @@ This document defines the binding composition grammar for headed GovOPlaN
|
||||
pages. Core owns the reusable anatomy; each module owns its domain actions,
|
||||
wording, authorization, consequences, and data state.
|
||||
|
||||
The cross-product [UI design principles](UI_DESIGN_PRINCIPLES.md) define the
|
||||
display-first editing model, heading-adjacent help, and module-review process.
|
||||
Normal overview/detail surfaces show readable facts; edit coherent settings in
|
||||
scoped dialogs. Use an explicit editor mode for a justified broad-editing task,
|
||||
not merely because a page also offers filters or contains an edit dialog (UI-02).
|
||||
|
||||
## Required Page Frame
|
||||
|
||||
- Use `PageLayout` for every headed standalone, workspace, or embedded page.
|
||||
@@ -69,11 +75,97 @@ 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 | Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
|
||||
Documentation is associated with text, not an action-group slot (UI-01). Put
|
||||
`DocumentationHelpLink` in the heading component's `titleHelp`, use `TextWithHelp`
|
||||
for existing contextual words, and retain `FormField`/`FieldLabel` documentation
|
||||
beside field labels. Full-canvas `WorkspaceActionBar` surfaces can supply `title`
|
||||
and `titleHelp` together on the leading side. Do not duplicate a heading or use
|
||||
`helpAction` for a detached documentation icon. See the shared principle for
|
||||
card/dialog accessibility and examples.
|
||||
|
||||
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.
|
||||
|
||||
An editor's **Cancel/Close** action is not the same as **Reset/Discard changes**.
|
||||
When it exits an editing mode, set `discardAction.behavior` to `"exit"`: it stays
|
||||
enabled for a clean draft, while Save remains disabled until there are changes.
|
||||
The default `"reset"` behavior still requires changes. Both stay blocked during
|
||||
an in-flight save; the owning page must use the shared discard confirmation before
|
||||
exiting a dirty draft. Never require a meaningless edit just to leave configuration.
|
||||
|
||||
Deutsch: Abbrechen oder Schließen beendet den Bearbeitungsmodus auch ohne
|
||||
Änderungen (`discardAction.behavior="exit"`). Zurücksetzen setzt dagegen einen
|
||||
geänderten Entwurf voraus. Beim Abbrechen eines geänderten Entwurfs vor dem
|
||||
Verwerfen nachfragen; eine laufende Speicherung bleibt geschützt.
|
||||
|
||||
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.
|
||||
|
||||
The shared stylesheet also recognizes existing table-only card bodies, either
|
||||
directly or through `LoadingFrame`; loading must not add an inset, expand the
|
||||
overlay outside the card, or change the table's measured width. Keep the table
|
||||
at 100% of its container. Do not reintroduce module-local negative margins,
|
||||
expanded `calc()` widths, or padding overrides to make a table fit. Mixed
|
||||
content cards retain their normal padding unless they explicitly declare the
|
||||
table layout. The browser conformance matrix checks all four card edges,
|
||||
loading/collapse transitions, scrolling, and row actions at desktop and mobile
|
||||
widths.
|
||||
|
||||
Deutsch: Tabellen belegen den Karteninhalt ohne zusätzlichen Innenabstand,
|
||||
auch während des Ladens. Neue Tabellenkarten verwenden ausdrücklich
|
||||
`bodyLayout="table"`; ergänzende Hinweise erhalten ihren eigenen Innenabstand.
|
||||
Negative Ränder oder modulbezogene Breitenkorrekturen sind nicht nötig.
|
||||
Karten mit gemischtem Text- oder Formularinhalt behalten ihre normalen Abstände.
|
||||
|
||||
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
|
||||
@@ -86,7 +178,8 @@ to fill the slot.
|
||||
Editor bars always keep Discard and Save visible. Their required `state`
|
||||
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
|
||||
`conflict`, and the central component announces it through a live status label.
|
||||
Clean and saving states disable both persistence actions; invalid disables Save
|
||||
Clean states disable Save and reset-style Discard, but keep exit-style Cancel
|
||||
available; saving disables both persistence actions. Invalid disables Save
|
||||
while retaining Discard. Failed saves and conflicts keep the draft recoverable
|
||||
and allow an authorized retry after the module has shown the owning error or
|
||||
conflict evidence. A module may add a more specific validation, policy, or
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,3 +13,5 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||
Canonical documentation:
|
||||
|
||||
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
||||
|
||||
Implementation contract: [disposable resource-bounded operations](BOUNDED_PROCESS_CONTRACT.md).
|
||||
|
||||
@@ -19,3 +19,72 @@ Connector health and preview diagnostics must contain no credentials, endpoint
|
||||
userinfo, row values, or unbounded remote error bodies. A Datasource origin
|
||||
preserves this contract so registration and staging do not erase source mode,
|
||||
health, pushdown, or preview-limit evidence.
|
||||
|
||||
## Durable CSV imports and original evidence
|
||||
|
||||
`TabularCsvSource` optionally accompanies a durable `TabularSnapshotInput` or
|
||||
`DatasourceStageInput`. It carries the exact submitted Unicode text, delimiter,
|
||||
explicit value mode and parser profile. It is not part of ordinary catalogue,
|
||||
preview or stage DTOs. Transient inspection does not retain original content.
|
||||
|
||||
The `text` mode preserves cell strings, including whitespace, leading zeroes,
|
||||
decimal spelling, boolean-looking text and explicit empty cells. It rejects
|
||||
malformed quoting and rows with missing or extra cells. Header normalization is
|
||||
unchanged. The API default remains `legacy_typed` for existing integrations;
|
||||
interactive CSV imports offer text mode by default and an explicit legacy choice.
|
||||
JSON and existing stored snapshots are not reinterpreted. Core and Datasources
|
||||
retain distinct versioned legacy parser profiles where their historical coercion
|
||||
rules differ. Shared schema inference preserves first-seen column order, missing
|
||||
value nullability and the owning provider's type naming.
|
||||
|
||||
Owners verify that the source reparses to exactly the stored projection, including
|
||||
scalar types: `true`, `1` and `1.0` are not equivalent evidence. Raw input and row
|
||||
projections each have a 5,000,000-byte limit; row parsing is capped at 10,000 rows.
|
||||
The original text has its own UTF-8 SHA-256 and byte count, separate from the
|
||||
existing row fingerprint. Only a small allowlisted source summary enters metadata.
|
||||
Checksums detect drift; they are not digital signatures or protection against an
|
||||
attacker who can rewrite the database and all its evidence.
|
||||
|
||||
Original exports are explicit owner APIs, tenant scoped, integrity checked and
|
||||
`no-store`. Datasources additionally requires administrator scope, audits the
|
||||
export, and denies the whole original when current or historical governance
|
||||
restricts any row or field. Freezing verifies the prior summary before copying
|
||||
source evidence and retains prior policy restrictions, including referenced
|
||||
policy evaluations. At most 32 distinct governance snapshots may accompany one
|
||||
original; further incompatible history fails explicitly. Payload disposal also
|
||||
disposes retained original content. Connectors applies its own current read,
|
||||
tenant and lifecycle checks. See the owning module's documentation for endpoints.
|
||||
|
||||
Original UTF-8 text is not proof of pre-decoding file bytes, and does not undo
|
||||
CSV spreadsheet formula semantics. Exported content is deliberately unmodified;
|
||||
operators must treat it as untrusted input when opening it in a spreadsheet.
|
||||
New nullable columns require the owning modules' additive migrations. Historical
|
||||
rows remain unchanged and report original content unavailable, not reconstructed.
|
||||
Back up retained originals before any schema downgrade that removes those columns.
|
||||
|
||||
## Deutsch: CSV-Datentreue
|
||||
|
||||
Dauerhafte CSV-Importe können den unveränderten übermittelten Unicode-Text mit
|
||||
Trennzeichen, Parserprofil und explizitem Wertemodus aufbewahren. Der Textmodus
|
||||
erhält Zellwerte einschließlich Leerzeichen, führender Nullen und Dezimalschreibweise.
|
||||
Fehlerhafte Zeilen werden abgewiesen. Die API bleibt aus Kompatibilitätsgründen bei
|
||||
der bisherigen Typumwandlung als Standard; im Importdialog ist Text voreingestellt.
|
||||
Bestehende Daten und JSON-Importe werden nicht neu interpretiert.
|
||||
|
||||
Original und Zeilenprojektion werden getrennt begrenzt und geprüft; boolesche
|
||||
Werte, Ganzzahlen und Gleitkommazahlen sind keine austauschbaren Belege. Der
|
||||
Originaltext erscheint weder im Katalog noch in Vorschauantworten. Die expliziten
|
||||
Export-APIs prüfen Mandant, Berechtigungen, Lebenszyklus und gespeicherten Hash.
|
||||
Datasources verlangt zusätzlich Administrationsrechte, protokolliert Exporte und
|
||||
berücksichtigt aktuelle sowie historische Zeilen-, Feld- und Zugriffsrichtlinien.
|
||||
Eingeschränkte Originale werden vollständig gesperrt, nicht teilweise freigegeben.
|
||||
Eingefrorene Kopien übernehmen diese Einschränkungen; nach 32 unterschiedlichen
|
||||
Richtlinienständen wird eine weitere Kopie mit neuer Richtlinie explizit abgewiesen.
|
||||
Die Aufbewahrungsbereinigung entfernt auch das gespeicherte Original.
|
||||
|
||||
Die Grenzen betragen jeweils 5.000.000 UTF-8-Bytes für Original und Projektion
|
||||
sowie 10.000 Zeilen. Prüfsummen sind keine Signaturen. Ein CSV-Original bleibt beim
|
||||
Export unverändert und kann Tabellenkalkulationsformeln enthalten. Frühere
|
||||
Dateikodierungen lassen sich daraus nicht rekonstruieren. Additive Migrationen
|
||||
ändern keine historischen Zeilen; fehlende Originale werden nicht erfunden.
|
||||
Vor einem Schema-Downgrade sind aufbewahrte Originale zu sichern.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
# Product UI design principles
|
||||
|
||||
These rules govern every GovOPlaN module, including administration, tenant and
|
||||
user settings, public forms, widgets, dialogs and shared shell surfaces. Core
|
||||
owns the reusable components; modules own their meaning and authorized behavior.
|
||||
They complement the [page composition contract](PAGE_LAYOUT_USAGE_GUIDELINES.md).
|
||||
|
||||
Initial rule revision: **UI-2026-09-08**. Record this identifier in review evidence;
|
||||
subsequent rule changes need a dated change reference and a UI-09 propagation check.
|
||||
|
||||
## UI-01 — Help belongs to visible text
|
||||
|
||||
Place a small documentation book immediately after the heading or label whose
|
||||
meaning it explains. For example, the Dashboard book belongs to **Dashboard**,
|
||||
not the Reload, Edit or Save group. The same applies to page, pane, card and
|
||||
dialog titles, field labels, widget summaries and contextual explanations.
|
||||
Do not put an isolated documentation icon in an action bar or in an otherwise
|
||||
empty row. A navigation icon is not a contextual documentation link.
|
||||
|
||||
Use `titleHelp={<DocumentationHelpLink reference={...} />}` on `PageLayout`,
|
||||
`PageHeader`, `PageTitle`, `AdminPageLayout`, `Card` or `Dialog`. Full-canvas
|
||||
workspaces use the visible `title` and `titleHelp` of `WorkspaceActionBar` (or
|
||||
`PageActionBar`), separate from its action groups. Use `TextWithHelp` around
|
||||
existing text for smaller contexts; fields use `FormField`/`FieldLabel` and
|
||||
their documentation reference. Do not duplicate the heading to add help.
|
||||
|
||||
Dashboard widgets provide their owning `documentation` reference in
|
||||
`DashboardWidgetContribution`; Dashboard renders it beside the existing widget
|
||||
card title. Do not add a second widget heading or a footer-only documentation icon.
|
||||
|
||||
Keep the icon outside the heading's accessible name and outside other buttons
|
||||
or links. It needs an accessible purpose, keyboard focus and a usable hit area.
|
||||
Long German headings must wrap without colliding with actions. Collapsing a
|
||||
card or configuring a dashboard must not change which title the book explains.
|
||||
The existing optional Docs-module/hosted-documentation fallback remains intact;
|
||||
placement does not change permissions or require the Docs module.
|
||||
|
||||
## UI-02 — Display first; edit deliberately
|
||||
|
||||
Ordinary overview and detail pages show compact, readable data. Group related
|
||||
facts with shared description, summary and card components. Offer a clear Edit
|
||||
action that opens a scoped dialog for a coherent group of settings. Avoid
|
||||
presenting every possible input, select and save control simultaneously.
|
||||
|
||||
The Campaign dashboard is a priority application: show the effective settings
|
||||
and their state compactly, then edit related settings in focused dialogs. This
|
||||
is review work to implement, not a claim that all current screens already comply.
|
||||
|
||||
An explicit editing mode is a justified exception for genuinely broad editing,
|
||||
large tables, graphical designers or other tasks poorly served by a modal.
|
||||
Record why the exception helps the user. Keep reading and editing distinguishable;
|
||||
enter editing deliberately and preserve shared Save/Cancel, validation, conflict,
|
||||
permission and unsaved-draft behavior. Filters, search and selection do not need
|
||||
an edit dialog: they change the view rather than the underlying data.
|
||||
|
||||
Cancel/Close must work even without changes. Never save a draft merely because
|
||||
a dialog opens, selection changes or navigation happens. Commit only the intended
|
||||
fields; do not let unrelated incomplete settings block an independent edit.
|
||||
|
||||
## UI-03 — Predictable actions and consequences
|
||||
|
||||
Follow the shared semantic page/pane action contract: context on the left;
|
||||
Reload on refreshable surfaces followed by Create on the right. Editable drafts
|
||||
have the standard state, Cancel/Discard and Save controls. Keep positions stable
|
||||
across selection and loading changes, and explain unavailable applicable actions.
|
||||
Separate destructive actions visually and semantically. Reload is a read, not
|
||||
an implicit synchronization, import, send or other mutation.
|
||||
|
||||
## UI-04 — Shared geometry and readable collections
|
||||
|
||||
Use central page, card, table, form-grid and dialog primitives. Table cards use
|
||||
`bodyLayout="table"`, spanning the card body without negative margins or expanded
|
||||
`calc()` widths. Keep row actions reachable and deliberate column resizing
|
||||
predictable. Avoid redundant taglines, arbitrary gaps, fixed dialog widths and
|
||||
horizontal scrolling where content can wrap. Use actual pagination and shared
|
||||
list filters; every result and every validation detail must remain reachable.
|
||||
|
||||
## UI-05 — Stable feedback and loading
|
||||
|
||||
Preserve useful loaded content on refresh failure; distinguish initial loading,
|
||||
empty, unavailable, stale, blocked, failed and successful states. Use the shared
|
||||
scoped loading overlay. Long operations show truthful processed/total and outcome
|
||||
counts when known, not simulated progress or whole-page background reloads.
|
||||
Prevent duplicate submissions without trapping the user in a clean editing mode.
|
||||
|
||||
## UI-06 — Predictable navigation and selection
|
||||
|
||||
Use shared breadcrumbs, side rail and tree contracts. Tree disclosure controls
|
||||
expand/collapse; labels select. Track the clicked occurrence when one topic or
|
||||
record appears in several branches. Preserve context, filters and selection
|
||||
where appropriate, without carrying data across authorization or tenant scopes.
|
||||
|
||||
## UI-07 — Accessibility and German parity
|
||||
|
||||
Check keyboard order, visible focus, accessible names, dialog focus restoration,
|
||||
contrast, zoom and narrow layouts. Status must not rely on color alone. Use
|
||||
plain, task-oriented wording and complete German UI and module-owned help for
|
||||
the changed workflow. Long labels must not hide actions or force needless scroll.
|
||||
|
||||
## UI-08 — Integrity and permissions are not visual shortcuts
|
||||
|
||||
Preserve authorization, versioning, audit evidence, immutable historical data and
|
||||
safe retries. Do not remove a guard to make an action look available. Keep drafts
|
||||
recoverable after failures and conflicts. Loading, cancellation and dialog
|
||||
transitions must not silently write, discard or overwrite data. Never verify UI
|
||||
changes by sending real mail or changing production records without authorization.
|
||||
|
||||
## UI-09 — Review incrementally and propagate lessons
|
||||
|
||||
The [Meta UI-review epic](https://git.add-ideas.de/GovOPlaN/govoplan/issues/56)
|
||||
links one review ledger per module. Each starts pending, including modules
|
||||
touched by a focused shared-component pass. An implemented help-placement rule
|
||||
does not constitute a full module review.
|
||||
|
||||
Inventory the module's surfaces, inspect them with realistic data and permissions,
|
||||
record findings and decisions, implement bounded fixes, then attach automated
|
||||
and manual evidence. Distinguish **to do**, **implemented**, **verified**, and
|
||||
**accepted exception**. Record the applicable principle IDs and actual screen
|
||||
coverage; do not mark a module complete from a code search alone.
|
||||
|
||||
When a review reveals a reusable lesson, update this document and the central
|
||||
component/check first where possible. Record the rule change in Meta and create
|
||||
a follow-up or reopen affected already-reviewed modules. The review ledger must
|
||||
show which rule revision each module has actually verified. Prefer finishing
|
||||
the active review slice before expanding functionality, while recording unrelated
|
||||
bugs and decision-dependent work without losing them.
|
||||
|
||||
## Kurzfassung auf Deutsch
|
||||
|
||||
- **UI-01:** Das Dokumentationsbuch steht direkt rechts neben der zugehörigen
|
||||
Überschrift oder Beschriftung, nicht zwischen Aktionsschaltflächen.
|
||||
- **UI-02:** Daten zunächst kompakt anzeigen; zusammengehörige Einstellungen
|
||||
gezielt in einem Dialog bearbeiten. Ein ausdrücklich aktivierter
|
||||
Bearbeitungsmodus, etwa für große Tabellen, ist eine begründete Ausnahme.
|
||||
Abbrechen funktioniert auch ohne Änderungen; Änderungen werden bewusst gespeichert.
|
||||
- **UI-03–UI-08:** Einheitliche Aktionen und Abstände, erreichbare Tabelleninhalte,
|
||||
verständlicher Fortschritt, vorhersehbare Navigation, Barrierefreiheit und
|
||||
deutsche Texte dürfen Berechtigungen oder Datenintegrität nicht schwächen.
|
||||
- **UI-09:** Alle Module werden einzeln geprüft. Neue Gestaltungsregeln werden
|
||||
auch in bereits geprüften Modulen nachgezogen und mit Nachweisen dokumentiert.
|
||||
|
||||
## Automated coverage and its limits
|
||||
|
||||
`tools/checks/check-heading-help.mjs` in Meta checks JSX documentation-link
|
||||
placement throughout the workspace, including aliases and simple local variables.
|
||||
Core component and browser tests check heading association, accessible names,
|
||||
focus, collapse/loading transitions and narrow/wide layouts. Existing layout,
|
||||
action, dialog and DataGrid checks continue to apply. These tests protect shared
|
||||
contracts; they do not certify every module's usability or replace the individual
|
||||
screen reviews and their documented exceptions.
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
||||
|
||||
## Verification
|
||||
|
||||
Development startup explicitly prebundles the Excel reader's browser/universal
|
||||
entrypoints and the lazy rich-text editor's Tiptap dependencies. These are
|
||||
Core-installed vendor dependencies, not eager optional-module imports. This
|
||||
avoids first-time Campaign/Template navigation triggering a second dependency
|
||||
optimization and page reload. Module descriptors and pages remain lazy, and
|
||||
production bundle budgets remain unchanged. The Core interface-pattern check
|
||||
verifies this include list and keeps optional GovOPlaN modules excluded.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
@@ -69,3 +77,33 @@ npm run test:module-permutations
|
||||
The build gate also catches accidental eager imports: a page pulled into the
|
||||
entry closure consumes the initial budget, while an oversized page or module
|
||||
descriptor consumes the asynchronous chunk budget.
|
||||
|
||||
The startup shell imports appearance validation/application from the pure
|
||||
`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export,
|
||||
and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy
|
||||
Settings route. Importing a runtime helper from a module that also owns editor
|
||||
components can accidentally pull the entire editor into the startup chunk.
|
||||
Public helper exports remain compatible; theme application is still synchronous.
|
||||
The versioned default color document and its deep-clone helper live in
|
||||
`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides
|
||||
does not load editor defaults or construct a draft. The default values and public
|
||||
helper names are unchanged; the theme regression checks independent draft clones
|
||||
as well as synchronous validation, application, and reset.
|
||||
|
||||
`PasswordField` keeps ordinary input and reveal controls synchronous. Its
|
||||
optional `PasswordGeneratorDialog` is imported only after an enabled, editable
|
||||
generator is explicitly opened, not for every sign-in/password field. Loading
|
||||
and failures use the shared resource boundary; the underlying field remains
|
||||
usable. Closing or revoking generation while loading cannot apply a candidate.
|
||||
The secure browser RNG, generation policy, public exports, and explicit
|
||||
"Use password" confirmation remain unchanged. The isolated browser fixture
|
||||
does not import the Core barrel, so it can verify that the generator is not
|
||||
requested before opening it, along with cancel/use and focus restoration.
|
||||
|
||||
Deutsch: Normale Passworteingabe und Sichtbarkeitssteuerung bleiben unmittelbar
|
||||
verfügbar. Der optionale Generator wird erst beim bewussten Öffnen eines
|
||||
aktivierten, bearbeitbaren Felds geladen; Lade- und Fehlerzustände nutzen die
|
||||
gemeinsame Ressourcenanzeige. Ohne "Passwort verwenden" wird kein Kandidat
|
||||
übernommen. Sichere Browser-Zufallszahlen, Richtlinien und öffentliche
|
||||
Schnittstellen bleiben unverändert. Wird die Generierung während des Ladens
|
||||
deaktiviert, öffnet eine verspätete Antwort keinen Dialog.
|
||||
|
||||
@@ -2271,6 +2271,995 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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-quick-access",
|
||||
"revision": "9a4e6c2d8f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revision": "9e2a5c8f1b4d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "9e6b3f8a2c7d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e701"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "a3d7f1c5e9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"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-mail",
|
||||
"revision": "b5d6e7f8091a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "b9e2f5a8c3d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"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-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "d2a4c6e8f0b1"
|
||||
},
|
||||
{
|
||||
"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-datasources",
|
||||
"revision": "e2b8d4a0f6c3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revision": "e7b9c1d3f5a7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "e9a2c5f8b1d4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "f3c7a9d2e6b1"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"e9a2c5f8b1d4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"d2a4c6e8f0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"c58a2d7e9f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"a3d7f1c5e9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"e2b8d4a0f6c3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"a2b3c4d5e701"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"b5d6e7f8091a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"9e6b3f8a2c7d"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-09-08T10:17:38Z",
|
||||
"release": "0.1.46",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.44"
|
||||
version = "0.1.46"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class NavigationSeparatorPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255)
|
||||
label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$")
|
||||
|
||||
|
||||
class NavigationPreferencesPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel):
|
||||
order: list[str] = Field(default_factory=list, max_length=256)
|
||||
hidden: list[str] = Field(default_factory=list, max_length=256)
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
@@ -168,6 +176,8 @@ class UserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
preferred_language: str | None = None
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||
@@ -182,6 +192,8 @@ class AuthSessionUserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
|
||||
|
||||
class AuthSessionResponse(BaseModel):
|
||||
|
||||
@@ -780,8 +780,9 @@ def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
|
||||
SMTP failures are persisted but are not retried implicitly. A worker-loss
|
||||
redelivery is safe because the delivery service converts an unfinished
|
||||
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
|
||||
redelivery leaves an active delivery claim unchanged instead of transmitting
|
||||
again. Explicit fenced recovery requires stopped-owner evidence before an
|
||||
abandoned attempt can become ``outcome_unknown`` for reconciliation.
|
||||
"""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
@@ -436,10 +437,33 @@ def _merge_list(
|
||||
result_by_id[identity] = merged.value
|
||||
|
||||
order_source = local_order if local_reordered and not current_reordered else current_order
|
||||
merged_order = [identity for identity in order_source if identity in result_by_id]
|
||||
for identity in identities:
|
||||
if identity in result_by_id and identity not in merged_order:
|
||||
merged_order.append(identity)
|
||||
secondary_order = current_order if order_source is local_order else local_order
|
||||
# Keep the chosen side's order and both sides' insertion anchors. Appending
|
||||
# missing IDs would silently relocate an insertion during a disjoint edit.
|
||||
# An incompatible reorder/insertion cycle is an explicit conflict.
|
||||
edges: dict[str, set[str]] = {identity: set() for identity in result_by_id}
|
||||
incoming = dict.fromkeys(result_by_id, 0)
|
||||
for order, insertions_only in ((order_source, False), (secondary_order, True)):
|
||||
selected = [identity for identity in order if identity in result_by_id]
|
||||
for left, right in zip(selected, selected[1:]):
|
||||
if insertions_only and left in base_by_id and right in base_by_id:
|
||||
continue
|
||||
if right not in edges[left]:
|
||||
edges[left].add(right)
|
||||
incoming[right] += 1
|
||||
priority = {identity: index for index, identity in enumerate(identities)}
|
||||
ready = [(priority[identity], identity) for identity, count in incoming.items() if count == 0]
|
||||
heapq.heapify(ready)
|
||||
merged_order: list[str] = []
|
||||
while ready:
|
||||
_, identity = heapq.heappop(ready)
|
||||
merged_order.append(identity)
|
||||
for following in edges[identity]:
|
||||
incoming[following] -= 1
|
||||
if incoming[following] == 0:
|
||||
heapq.heappush(ready, (priority[following], following))
|
||||
if len(merged_order) != len(result_by_id):
|
||||
return _conflict(path, "collection_reorder", base, local, current)
|
||||
return ThreeWayMergeResult(
|
||||
value=[result_by_id[identity] for identity in merged_order],
|
||||
conflicts=conflicts,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("system:settings:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="A bounded 0–500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("admin:policies:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
label="Enabled modules",
|
||||
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
rollback_history_required=True,
|
||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_archive_encryption_policy",
|
||||
label="Campaign archive encryption policy",
|
||||
owner_module="policy",
|
||||
scope="system",
|
||||
storage="policy_overrides",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write", "admin:policies:write"),
|
||||
validation_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="campaign_archive_encryption_policy.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="privacy_retention_policy",
|
||||
label="Privacy retention policy",
|
||||
|
||||
@@ -14,6 +14,7 @@ from govoplan_core.core.tabular_sources import (
|
||||
DEFAULT_PREVIEW_BYTES,
|
||||
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularCsvSource,
|
||||
TabularPushdown,
|
||||
TabularSourceHealth,
|
||||
TabularSourceMode,
|
||||
@@ -369,6 +370,7 @@ class DatasourceStageInput:
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance | None = None
|
||||
csv_source: TabularCsvSource | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -409,6 +409,25 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, str]],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
"""Merge owner-supplied text translations without moving feature content."""
|
||||
localized: list[DocumentationTopic] = []
|
||||
for topic in topics:
|
||||
translated = translations.get(topic.id)
|
||||
if translated is None:
|
||||
localized.append(topic)
|
||||
continue
|
||||
values = {name: dict(value) for name, value in topic.translations.items()}
|
||||
values[locale] = {**values.get(locale, {}), **translated}
|
||||
localized.append(replace(topic, translations=values))
|
||||
return tuple(localized)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Pure principal attribution mechanics, not authorization or tenant resolution.
|
||||
|
||||
The two existing contracts intentionally differ in precedence and whitespace.
|
||||
Callers retain their own service-account, scope and resource-access decisions.
|
||||
"""
|
||||
|
||||
|
||||
def principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||
"""Account-first legacy IDs, unique in encounter order; retain nonblank text."""
|
||||
user = getattr(principal, "user", None)
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
getattr(user, "id", None),
|
||||
)
|
||||
if str(value or "").strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def principal_user_first_actor(principal: object) -> str | None:
|
||||
"""First nonblank user/account/identity/membership ID, with trimmed text."""
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -17,6 +21,77 @@ DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
|
||||
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
|
||||
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
|
||||
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
CsvValueMode = Literal["legacy_typed", "text"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularCsvSource:
|
||||
"""Original upload text, retained only with an explicitly durable import.
|
||||
|
||||
This is never catalogue metadata or transient preview retention. Owners
|
||||
enforce access, size limits, lifecycle and export authorization separately.
|
||||
"""
|
||||
|
||||
text: str
|
||||
delimiter: str = ","
|
||||
value_mode: CsvValueMode = "legacy_typed"
|
||||
parser_profile: str = "core.csv.v1"
|
||||
|
||||
|
||||
def csv_source_payload(source: TabularCsvSource, *, max_bytes: int = 5_000_000) -> dict[str, object]:
|
||||
encoded = _csv_utf8_bytes(source.text)
|
||||
if len(encoded) > max_bytes:
|
||||
raise TabularSourceValidationError(f"Original CSV input is limited to {max_bytes:,} UTF-8 bytes.")
|
||||
if source.value_mode not in {"text", "legacy_typed"} or len(source.delimiter) != 1:
|
||||
raise TabularSourceValidationError("Invalid CSV source parsing options.")
|
||||
return {
|
||||
"text": source.text,
|
||||
"delimiter": source.delimiter,
|
||||
"value_mode": source.value_mode,
|
||||
"parser_profile": source.parser_profile,
|
||||
"sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
"byte_count": len(encoded),
|
||||
}
|
||||
|
||||
|
||||
def csv_source_summary(payload: Mapping[str, object]) -> dict[str, object]:
|
||||
"""Allowlist the small, non-content evidence safe for catalogue DTOs."""
|
||||
result = {key: payload[key] for key in ("delimiter", "value_mode", "parser_profile", "sha256", "byte_count")}
|
||||
if "governance_history" in payload:
|
||||
result["governance_sha256"] = hashlib.sha256(json.dumps(payload["governance_history"], sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()
|
||||
return result
|
||||
|
||||
|
||||
def verified_csv_source_text(payload: Mapping[str, object], *, expected_summary: Mapping[str, object] | None = None) -> str:
|
||||
text = payload.get("text")
|
||||
if not isinstance(text, str):
|
||||
raise TabularSourceUnavailableError("Original CSV source text is unavailable.")
|
||||
try:
|
||||
encoded = text.encode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise TabularSourceUnavailableError("Original CSV source encoding is invalid.") from exc
|
||||
if len(encoded) != payload.get("byte_count") or hashlib.sha256(encoded).hexdigest() != payload.get("sha256"):
|
||||
raise TabularSourceUnavailableError("Original CSV source integrity verification failed.")
|
||||
if expected_summary is not None:
|
||||
try:
|
||||
actual = json.dumps(csv_source_summary(payload), sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
expected = json.dumps(expected_summary, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise TabularSourceUnavailableError("Original CSV source evidence is invalid.") from exc
|
||||
if actual != expected:
|
||||
raise TabularSourceUnavailableError("Original CSV source no longer matches its recorded evidence.")
|
||||
return text
|
||||
|
||||
|
||||
def csv_projection_matches(expected: Sequence[Mapping[str, object]], actual: Sequence[Mapping[str, object]]) -> bool:
|
||||
"""CSV cells are scalar: booleans, integers and floats are not interchangeable."""
|
||||
return len(expected) == len(actual) and all(
|
||||
left.keys() == right.keys() and all(
|
||||
type(value) is type(right[name]) and value == right[name]
|
||||
for name, value in left.items()
|
||||
)
|
||||
for left, right in zip(expected, actual, strict=True)
|
||||
)
|
||||
|
||||
|
||||
class TabularSourceError(ValueError):
|
||||
@@ -39,29 +114,44 @@ class TabularSourceUnavailableError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
def _csv_utf8_bytes(text: str) -> bytes:
|
||||
try:
|
||||
return text.encode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise TabularSourceValidationError("CSV input must be valid Unicode encodable as UTF-8.") from exc
|
||||
|
||||
|
||||
def parse_tabular_csv(
|
||||
csv_text: str,
|
||||
*,
|
||||
delimiter: str = ",",
|
||||
max_rows: int = 10_000,
|
||||
max_bytes: int = 5_000_000,
|
||||
value_mode: CsvValueMode = "legacy_typed",
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||
"""Parse CSV with an explicit lexical-text or backward-compatible typed mode."""
|
||||
|
||||
if len(delimiter) != 1:
|
||||
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||
if value_mode not in {"legacy_typed", "text"}:
|
||||
raise TabularSourceValidationError("Unsupported CSV value mode.")
|
||||
if len(_csv_utf8_bytes(csv_text)) > max_bytes:
|
||||
raise TabularSourceValidationError(f"CSV input is limited to {max_bytes:,} UTF-8 bytes.")
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter, strict=value_mode == "text")
|
||||
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||
rows: list[dict[str, object]] = []
|
||||
for row in reader:
|
||||
_validate_csv_row_shape(row)
|
||||
if _csv_row_is_empty(row, original_headers):
|
||||
if value_mode == "text" and (None in row or any(row.get(header) is None for header in original_headers)):
|
||||
raise TabularSourceValidationError("CSV text rows must have exactly the number of values defined by the header.")
|
||||
if value_mode == "legacy_typed" and _csv_row_is_empty(row, original_headers):
|
||||
continue
|
||||
if len(rows) >= max_rows:
|
||||
raise TabularSourceValidationError(
|
||||
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||
)
|
||||
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||
rows.append(_csv_row(row, original_headers, normalized_headers, value_mode=value_mode))
|
||||
return tuple(rows)
|
||||
except csv.Error as exc:
|
||||
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||
@@ -106,9 +196,11 @@ def _csv_row(
|
||||
row: Mapping[str | None, str | list[str] | None],
|
||||
original_headers: Sequence[str],
|
||||
normalized_headers: Sequence[str],
|
||||
*,
|
||||
value_mode: CsvValueMode = "legacy_typed",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||
normalized: (value if value_mode == "text" else _csv_scalar(value if isinstance(value, str) else None))
|
||||
for original, normalized in zip(
|
||||
original_headers,
|
||||
normalized_headers,
|
||||
@@ -128,9 +220,15 @@ def _csv_scalar(value: str | None) -> object:
|
||||
if lowered in {"true", "false"}:
|
||||
return lowered == "true"
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||
return int(text)
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError as exc:
|
||||
raise TabularSourceValidationError("CSV integer exceeds the conversion limit; use text mode to preserve it.") from exc
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||
return float(text)
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
raise TabularSourceValidationError("CSV numeric value exceeds the finite number range; use text mode to preserve it.")
|
||||
return value
|
||||
return text
|
||||
|
||||
|
||||
@@ -141,6 +239,48 @@ class TabularColumn:
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
def tabular_type_name(value: object, *, casefold_unknown: bool = False) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, (float, Decimal)):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
name = type(value).__name__
|
||||
return name.casefold() if casefold_unknown else name.lower()
|
||||
|
||||
|
||||
def infer_tabular_schema(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
type_name: Callable[[object], str] = tabular_type_name,
|
||||
) -> tuple[TabularColumn, ...]:
|
||||
"""Infer first-seen columns in one pass without retaining column values.
|
||||
|
||||
The classifier is explicit so legacy providers can preserve their exact
|
||||
type naming. Missing keys and explicit None both make a column nullable.
|
||||
"""
|
||||
states: dict[str, tuple[str | None, int]] = {}
|
||||
for row in rows:
|
||||
for name, value in row.items():
|
||||
kind, concrete = states.get(name, (None, 0))
|
||||
if value is not None:
|
||||
value_kind = type_name(value)
|
||||
kind = value_kind if kind is None else kind if kind == value_kind else "mixed"
|
||||
concrete += 1
|
||||
states[name] = (kind, concrete)
|
||||
return tuple(
|
||||
TabularColumn(name=name, data_type=kind if kind is not None else "unknown", nullable=concrete != len(rows))
|
||||
for name, (kind, concrete) in states.items()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularPushdown:
|
||||
projections: bool = False
|
||||
@@ -221,6 +361,7 @@ class TabularSnapshotInput:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
description: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
csv_source: TabularCsvSource | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -296,6 +437,10 @@ __all__ = [
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||
"DEFAULT_PREVIEW_BYTES",
|
||||
"DEFAULT_PREVIEW_TIMEOUT_MS",
|
||||
"CsvValueMode",
|
||||
"TabularCsvSource",
|
||||
"tabular_type_name",
|
||||
"infer_tabular_schema",
|
||||
"TabularColumn",
|
||||
"TabularPreviewDiagnostic",
|
||||
"TabularPushdown",
|
||||
@@ -313,6 +458,10 @@ __all__ = [
|
||||
"TabularSourceUnavailableError",
|
||||
"TabularSourceValidationError",
|
||||
"parse_tabular_csv",
|
||||
"csv_source_payload",
|
||||
"csv_source_summary",
|
||||
"verified_csv_source_text",
|
||||
"csv_projection_matches",
|
||||
"tabular_snapshot_writer",
|
||||
"tabular_source_provider",
|
||||
]
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
"""Exact, bound-parameter JSON permission predicates for SQLite/PostgreSQL.
|
||||
|
||||
These primitives only match strings (never coerced numbers/booleans) and
|
||||
objects inside actual arrays. Owners still define tenant, subject, permission,
|
||||
purpose and current-state policy. Unsupported dialects fail at compilation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy import Boolean, literal
|
||||
from sqlalchemy.exc import CompileError
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql.functions import FunctionElement
|
||||
|
||||
|
||||
class _ArrayString(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
class _ObjectStrings(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
class _ArrayObjectStrings(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
def json_array_contains_string(column, value: str):
|
||||
if type(value) is not str:
|
||||
raise TypeError("JSON string membership requires a string value.")
|
||||
return _ArrayString(column, literal(value))
|
||||
|
||||
|
||||
def _field_arguments(fields: Mapping[str, str]):
|
||||
if not isinstance(fields, Mapping) or not 1 <= len(fields) <= 16:
|
||||
raise ValueError("JSON object matching requires between 1 and 16 string fields.")
|
||||
arguments = []
|
||||
for key, value in sorted(fields.items()):
|
||||
if type(key) is not str or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", key) is None:
|
||||
raise ValueError("JSON object field names must be simple identifiers.")
|
||||
if type(value) is not str:
|
||||
raise TypeError("JSON object matching requires string values.")
|
||||
arguments.extend((literal(key), literal(value)))
|
||||
return arguments
|
||||
|
||||
|
||||
def json_object_matches_strings(column, fields: Mapping[str, str]):
|
||||
return _ObjectStrings(column, *_field_arguments(fields))
|
||||
|
||||
|
||||
def json_array_contains_object_strings(column, fields: Mapping[str, str]):
|
||||
return _ArrayObjectStrings(column, *_field_arguments(fields))
|
||||
|
||||
|
||||
@compiles(_ArrayString)
|
||||
@compiles(_ObjectStrings)
|
||||
@compiles(_ArrayObjectStrings)
|
||||
def _unsupported(element, compiler, **kwargs):
|
||||
raise CompileError("Exact JSON permission predicates support only SQLite and PostgreSQL.")
|
||||
|
||||
|
||||
def _parts(element, compiler, kwargs):
|
||||
return [compiler.process(item, **kwargs) for item in element.clauses]
|
||||
|
||||
|
||||
def _sqlite_array(value):
|
||||
return f"CASE WHEN json_type({value}) = 'array' THEN {value} ELSE '[]' END"
|
||||
|
||||
|
||||
def _postgres_array(value):
|
||||
value = f"CAST({value} AS JSON)"
|
||||
return f"CASE WHEN json_typeof({value}) = 'array' THEN {value} ELSE '[]'::json END"
|
||||
|
||||
|
||||
def _sqlite_fields(value, fields):
|
||||
terms = []
|
||||
for index in range(0, len(fields), 2):
|
||||
key, expected = fields[index:index + 2]
|
||||
path = f"('$.' || {key})"
|
||||
terms.extend((f"json_type({value}, {path}) = 'text'", f"json_extract({value}, {path}) = {expected}"))
|
||||
return " AND ".join(terms)
|
||||
|
||||
|
||||
def _postgres_fields(value, fields):
|
||||
terms = []
|
||||
for index in range(0, len(fields), 2):
|
||||
key, expected = fields[index:index + 2]
|
||||
terms.extend((f"json_typeof(({value}) -> {key}) = 'string'", f"(({value}) ->> {key}) = {expected}"))
|
||||
return " AND ".join(terms)
|
||||
|
||||
|
||||
@compiles(_ArrayString, "sqlite")
|
||||
def _array_string_sqlite(element, compiler, **kwargs):
|
||||
value, expected = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_string "
|
||||
f"WHERE gp_json_string.type = 'text' AND gp_json_string.value = {expected})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ArrayString, "postgresql")
|
||||
def _array_string_postgres(element, compiler, **kwargs):
|
||||
value, expected = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_string(value) "
|
||||
f"WHERE json_typeof(gp_json_string.value) = 'string' "
|
||||
f"AND (gp_json_string.value #>> '{{}}') = {expected})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ObjectStrings, "sqlite")
|
||||
def _object_sqlite(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
value = f"CASE WHEN json_type({value}) = 'object' THEN {value} ELSE '{{}}' END"
|
||||
return f"({_sqlite_fields(value, fields)})"
|
||||
|
||||
|
||||
@compiles(_ObjectStrings, "postgresql")
|
||||
def _object_postgres(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
value = f"CAST({value} AS JSON)"
|
||||
return f"(json_typeof({value}) = 'object' AND {_postgres_fields(value, fields)})"
|
||||
|
||||
|
||||
@compiles(_ArrayObjectStrings, "sqlite")
|
||||
def _array_object_sqlite(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
item = "CASE WHEN gp_json_object.type = 'object' THEN gp_json_object.value ELSE '{}' END"
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_object "
|
||||
f"WHERE {_sqlite_fields(item, fields)})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ArrayObjectStrings, "postgresql")
|
||||
def _array_object_postgres(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_object(value) "
|
||||
f"WHERE json_typeof(gp_json_object.value) = 'object' "
|
||||
f"AND {_postgres_fields('gp_json_object.value', fields)})"
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Resource-bounded, disposable workers for trusted module-owned byte operations.
|
||||
|
||||
This is not an arbitrary-code sandbox. Callers supply a server-owned top-level
|
||||
function, never a client-selected module/path/callable. Sessions, credentials and
|
||||
authority remain in the parent; only explicit bounded bytes cross the pipe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessLimits:
|
||||
wall_seconds: float = 10.0
|
||||
cpu_seconds: int = 10
|
||||
memory_bytes: int = 256 * 1024 * 1024
|
||||
input_bytes: int = 8 * 1024 * 1024
|
||||
output_bytes: int = 8 * 1024 * 1024
|
||||
file_bytes: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not math.isfinite(self.wall_seconds) or not 0 < self.wall_seconds <= 600:
|
||||
raise ValueError("Worker wall time must be finite and within (0, 600] seconds.")
|
||||
for name, minimum, maximum in (
|
||||
("cpu_seconds", 1, 600),
|
||||
("memory_bytes", 64 * 1024 * 1024, 8 * 1024 * 1024 * 1024),
|
||||
("input_bytes", 1, 256 * 1024 * 1024),
|
||||
("output_bytes", 1, 256 * 1024 * 1024),
|
||||
("file_bytes", 0, 2 * 1024 * 1024 * 1024),
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(f"Invalid worker {name} limit.")
|
||||
|
||||
|
||||
class ProcessBudgetError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
messages = {
|
||||
"busy": "The isolated-work capacity is busy; retry later.",
|
||||
"cancelled": "Isolated work was cancelled.",
|
||||
"timeout": "Isolated work exceeded its wall-clock limit.",
|
||||
"cpu_limit": "Isolated work exceeded its CPU limit.",
|
||||
"memory_limit": "Isolated work exceeded its memory limit.",
|
||||
"input_limit": "Isolated work input exceeded its byte limit.",
|
||||
"output_limit": "Isolated work output exceeded its byte limit.",
|
||||
"unavailable": "Required isolated-worker resource controls are unavailable.",
|
||||
"worker_failed": "Isolated work could not complete safely.",
|
||||
}
|
||||
self.code = code
|
||||
super().__init__(messages[code])
|
||||
|
||||
|
||||
_gate = threading.Lock()
|
||||
_active = 0
|
||||
_STDERR_LIMIT = 64 * 1024
|
||||
|
||||
|
||||
def _reset_after_fork() -> None:
|
||||
global _gate, _active
|
||||
_gate, _active = threading.Lock(), 0
|
||||
|
||||
|
||||
if hasattr(os, "register_at_fork"):
|
||||
os.register_at_fork(after_in_child=_reset_after_fork)
|
||||
|
||||
|
||||
def _reserve() -> None:
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
global _active
|
||||
with _gate:
|
||||
if _active >= settings.isolated_process_concurrency:
|
||||
raise ProcessBudgetError("busy")
|
||||
_active += 1
|
||||
|
||||
|
||||
def _release() -> None:
|
||||
global _active
|
||||
with _gate:
|
||||
_active -= 1
|
||||
|
||||
|
||||
@dataclass(slots=True, eq=False)
|
||||
class _OperationAdmission:
|
||||
process_id: int
|
||||
thread_id: int
|
||||
active: bool = True
|
||||
running: bool = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bounded_operation_admission():
|
||||
"""Reserve shared capacity before bounded parent-side input preparation.
|
||||
|
||||
The yielded token may be reused sequentially in this thread only. Never
|
||||
expose it to clients or hold it while waiting for user input/network work.
|
||||
Parent preparation still needs its own byte/time/disk bounds.
|
||||
"""
|
||||
_reserve()
|
||||
admission = _OperationAdmission(os.getpid(), threading.get_ident())
|
||||
try:
|
||||
yield admission
|
||||
finally:
|
||||
admission.active = False
|
||||
# Fork resets the child's admission counter. An inherited context must
|
||||
# still expire its token, but cannot release the parent's reservation.
|
||||
if admission.process_id == os.getpid():
|
||||
_release()
|
||||
|
||||
|
||||
def _enter_admission(admission: _OperationAdmission) -> None:
|
||||
with _gate:
|
||||
if (
|
||||
not isinstance(admission, _OperationAdmission)
|
||||
or not admission.active or admission.running
|
||||
or admission.process_id != os.getpid()
|
||||
or admission.thread_id != threading.get_ident()
|
||||
):
|
||||
raise ValueError("Worker admission must be active, unused and owned by this thread/process.")
|
||||
admission.running = True
|
||||
|
||||
|
||||
def run_bounded_operation(
|
||||
operation: Callable[[bytes], bytes],
|
||||
payload: bytes,
|
||||
*,
|
||||
limits: ProcessLimits = ProcessLimits(),
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
admission: _OperationAdmission | None = None,
|
||||
) -> bytes:
|
||||
"""Run a trusted, importable module function without inheriting parent state.
|
||||
|
||||
Admission is non-queuing and per API/worker process. POSIX process groups,
|
||||
resource limits and waitid(WNOWAIT) are required; no in-process fallback.
|
||||
Output and stderr are drained incrementally, including while input is sent.
|
||||
Every exit path kills the owned process group before reaping its leader.
|
||||
"""
|
||||
if type(payload) is not bytes or len(payload) > limits.input_bytes:
|
||||
raise ProcessBudgetError("input_limit")
|
||||
if os.name != "posix" or not hasattr(os, "WNOWAIT") or not hasattr(os, "waitid"):
|
||||
raise ProcessBudgetError("unavailable")
|
||||
module = inspect.getmodule(operation)
|
||||
name = getattr(operation, "__name__", "")
|
||||
if (
|
||||
module is None or not name.isidentifier()
|
||||
or getattr(module, name, None) is not operation
|
||||
or not inspect.isfunction(operation) or not getattr(module, "__file__", None)
|
||||
):
|
||||
raise ValueError("Isolated operations must be server-owned module-level functions.")
|
||||
module_name = module.__name__
|
||||
if not all(part.isidentifier() for part in module_name.split(".")):
|
||||
raise ValueError("Invalid isolated operation module.")
|
||||
# Explicit source identity supports installed modules and editable development
|
||||
# checkouts without inheriting arbitrary PYTHONPATH or the parent's cwd.
|
||||
source = Path(module.__file__).resolve()
|
||||
source_root = source.parents[len(module_name.split(".")) - 1]
|
||||
if source.name == "__init__.py":
|
||||
source_root = source_root.parent
|
||||
_check_cancelled(cancelled)
|
||||
if admission is None:
|
||||
with bounded_operation_admission() as reserved:
|
||||
return run_bounded_operation(
|
||||
operation, payload, limits=limits, cancelled=cancelled, admission=reserved,
|
||||
)
|
||||
_enter_admission(admission)
|
||||
try:
|
||||
return _run(module_name, name, source_root, payload, limits, cancelled)
|
||||
finally:
|
||||
admission.running = False
|
||||
|
||||
|
||||
def _check_cancelled(cancelled: Callable[[], bool] | None) -> None:
|
||||
if cancelled is not None and cancelled():
|
||||
raise ProcessBudgetError("cancelled")
|
||||
|
||||
|
||||
def _run(
|
||||
module: str, name: str, source_root: Path, payload: bytes,
|
||||
limits: ProcessLimits, cancelled: Callable[[], bool] | None,
|
||||
) -> bytes:
|
||||
command = [
|
||||
sys.executable, "-I", "-B", "-m", "govoplan_core.security.process_worker",
|
||||
module, name, str(source_root), str(limits.cpu_seconds),
|
||||
str(limits.memory_bytes), str(limits.input_bytes), str(limits.output_bytes),
|
||||
str(limits.file_bytes),
|
||||
]
|
||||
deadline = time.monotonic() + limits.wall_seconds
|
||||
try:
|
||||
process = subprocess.Popen( # noqa: S603 - fixed interpreter/bootstrap, trusted operation.
|
||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
bufsize=0, close_fds=True, start_new_session=True, cwd="/",
|
||||
env={
|
||||
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TZ": "UTC",
|
||||
"OPENBLAS_NUM_THREADS": "1", "OMP_NUM_THREADS": "1",
|
||||
"MKL_NUM_THREADS": "1", "NUMEXPR_NUM_THREADS": "1",
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ProcessBudgetError("unavailable") from exc
|
||||
output = bytearray()
|
||||
sent = 0
|
||||
stderr_bytes = 0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
os.set_blocking(stream.fileno(), False)
|
||||
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
||||
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
||||
if payload:
|
||||
selector.register(process.stdin, selectors.EVENT_WRITE, "stdin")
|
||||
else:
|
||||
process.stdin.close()
|
||||
while True:
|
||||
_check_cancelled(cancelled)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ProcessBudgetError("timeout")
|
||||
# WNOWAIT keeps the owned group leader's PID reserved until the
|
||||
# group is killed, including descendants that close their pipes.
|
||||
exited = os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
||||
if exited is not None and not selector.get_map():
|
||||
break
|
||||
events = selector.select(min(remaining, 0.05))
|
||||
for key, _event in events:
|
||||
if key.data == "stdin":
|
||||
try:
|
||||
sent += os.write(key.fd, memoryview(payload)[sent:sent + 65536])
|
||||
except BrokenPipeError:
|
||||
sent = len(payload)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if sent == len(payload):
|
||||
selector.unregister(key.fileobj)
|
||||
key.fileobj.close()
|
||||
continue
|
||||
available = (
|
||||
limits.output_bytes - len(output)
|
||||
if key.data == "stdout" else _STDERR_LIMIT - stderr_bytes
|
||||
)
|
||||
try:
|
||||
chunk = os.read(key.fd, min(65536, available + 1))
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
elif len(chunk) > available:
|
||||
raise ProcessBudgetError("output_limit")
|
||||
elif key.data == "stdout":
|
||||
output.extend(chunk)
|
||||
else:
|
||||
# Never expose raw exception/log output to a client.
|
||||
stderr_bytes += len(chunk)
|
||||
exit_code = os.waitstatus_to_exitcode(
|
||||
(exited.si_status << 8) if exited.si_code == os.CLD_EXITED else exited.si_status
|
||||
)
|
||||
finally:
|
||||
# Do not communicate(): it would collect unbounded output during cleanup.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
stream.close()
|
||||
process.wait()
|
||||
if exit_code != 0:
|
||||
code = {
|
||||
71: "memory_limit", 72: "input_limit", 73: "output_limit",
|
||||
74: "unavailable", -signal.SIGXCPU: "cpu_limit",
|
||||
-signal.SIGXFSZ: "output_limit",
|
||||
}.get(exit_code, "worker_failed")
|
||||
raise ProcessBudgetError(code)
|
||||
return bytes(output)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Private child entry point; resource controls precede module-owned imports."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from io import BytesIO
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _limit(resource, kind: int, soft: int, hard: int | None = None) -> None:
|
||||
_old_soft, old_hard = resource.getrlimit(kind)
|
||||
selected_hard = soft if hard is None else hard
|
||||
if old_hard != resource.RLIM_INFINITY:
|
||||
selected_hard = min(selected_hard, old_hard)
|
||||
resource.setrlimit(kind, (min(soft, selected_hard), selected_hard))
|
||||
|
||||
|
||||
def _read_input(source, maximum: int) -> bytes | None:
|
||||
# read(maximum + 1) can reserve the entire configured cap for a tiny DTO.
|
||||
# Keep temporary reads small; BytesIO.getvalue() avoids a second full buffer
|
||||
# in CPython, and the input cap remains checked during collection.
|
||||
with BytesIO() as collected:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = source.read(min(65536, maximum - total + 1))
|
||||
if not chunk:
|
||||
return collected.getvalue()
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
return None
|
||||
collected.write(chunk)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
module, name, source_root, cpu, memory, input_limit, output_limit, file_limit = sys.argv[1:]
|
||||
try:
|
||||
import resource
|
||||
|
||||
_limit(resource, resource.RLIMIT_CORE, 0)
|
||||
_limit(resource, resource.RLIMIT_AS, int(memory))
|
||||
_limit(resource, resource.RLIMIT_CPU, int(cpu), int(cpu) + 1)
|
||||
_limit(resource, resource.RLIMIT_FSIZE, int(file_limit))
|
||||
_limit(resource, resource.RLIMIT_NOFILE, 64)
|
||||
os.umask(0o077)
|
||||
except (ImportError, AttributeError, OSError, ValueError):
|
||||
return 74
|
||||
try:
|
||||
payload = _read_input(sys.stdin.buffer, int(input_limit))
|
||||
if payload is None:
|
||||
return 72
|
||||
sys.path.insert(0, source_root)
|
||||
operation = getattr(importlib.import_module(module), name)
|
||||
result = operation(payload)
|
||||
if type(result) is not bytes:
|
||||
return 70
|
||||
if len(result) > int(output_limit):
|
||||
return 73
|
||||
sys.stdout.buffer.write(result)
|
||||
sys.stdout.buffer.flush()
|
||||
return 0
|
||||
except MemoryError:
|
||||
return 71
|
||||
except BaseException:
|
||||
return 70
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Avoid arbitrary module atexit hooks extending completion beyond the budget.
|
||||
os._exit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Bounded data-only binary transport for disposable workers.
|
||||
|
||||
Static tags and unsigned 32-bit scalar lengths/container counts are validated
|
||||
before allocation. No object hooks, imports, pickle, or JSON object graph is used.
|
||||
Operation owners still validate their own DTO after decoding these basic types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
import struct
|
||||
from uuid import UUID
|
||||
|
||||
_MAGIC = b"GWP\x01"
|
||||
_MAX_DEPTH = 64
|
||||
_MAX_NODES = 1_000_000
|
||||
_TEXT_CHUNK = 16 * 1024
|
||||
_U32 = struct.Struct(">I")
|
||||
_NULL, _FALSE, _TRUE = 0, 1, 2
|
||||
_STR, _BYTES, _INT, _FLOAT, _DECIMAL, _UUID = 3, 4, 5, 6, 7, 8
|
||||
_DATE, _DATETIME, _TIME, _TUPLE, _LIST, _DICT = 9, 10, 11, 12, 13, 14
|
||||
|
||||
|
||||
class WorkerPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limit(max_bytes: int) -> None:
|
||||
if type(max_bytes) is not int or not 0 <= max_bytes <= 0xFFFFFFFF:
|
||||
raise WorkerPayloadError("Invalid worker payload byte limit.")
|
||||
|
||||
|
||||
def encode_worker_payload(value: object, *, max_bytes: int = 32 * 1024 * 1024) -> bytes:
|
||||
_validate_limit(max_bytes)
|
||||
wire = bytearray()
|
||||
nodes = 0
|
||||
|
||||
def append(data: bytes) -> None:
|
||||
if len(data) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
wire.extend(data)
|
||||
|
||||
def scalar(tag: int, item: str | bytes) -> None:
|
||||
append(bytes((tag,)))
|
||||
length_position = len(wire)
|
||||
append(b"\x00\x00\x00\x00")
|
||||
value_position = len(wire)
|
||||
if type(item) is bytes:
|
||||
append(item)
|
||||
else:
|
||||
# UTF-8 needs at least one byte per code point. Never construct
|
||||
# a whole escaped or encoded copy before checking the byte cap.
|
||||
if len(item) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
for offset in range(0, len(item), _TEXT_CHUNK):
|
||||
append(item[offset : offset + _TEXT_CHUNK].encode("utf-8"))
|
||||
_U32.pack_into(wire, length_position, len(wire) - value_position)
|
||||
|
||||
def encode(item: object, depth: int = 0) -> None:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
kind = type(item)
|
||||
if item is None:
|
||||
append(bytes((_NULL,)))
|
||||
elif kind is bool:
|
||||
append(bytes((_TRUE if item else _FALSE,)))
|
||||
elif kind is str:
|
||||
scalar(_STR, item)
|
||||
elif kind is bytes:
|
||||
scalar(_BYTES, item)
|
||||
elif kind is int:
|
||||
length = (item.bit_length() + 8) // 8
|
||||
if length > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_INT, item.to_bytes(length, "big", signed=True))
|
||||
elif kind is float:
|
||||
scalar(_FLOAT, struct.pack(">d", item))
|
||||
elif kind is Decimal:
|
||||
# C Decimal uses at least 8 bytes per 19 coefficient digits.
|
||||
# This conservative bound includes inline digits and exponent text,
|
||||
# avoiding an unbounded str() or as_tuple() allocation in the parent.
|
||||
if item.__sizeof__() * 3 + 64 > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_DECIMAL, str(item))
|
||||
elif kind is UUID:
|
||||
scalar(_UUID, item.bytes)
|
||||
elif kind is date:
|
||||
scalar(_DATE, item.isoformat())
|
||||
elif kind is datetime:
|
||||
scalar(_DATETIME, item.isoformat())
|
||||
elif kind is time:
|
||||
scalar(_TIME, item.isoformat())
|
||||
elif kind in (tuple, list, dict):
|
||||
count = len(item)
|
||||
children = count * (2 if kind is dict else 1)
|
||||
if count > 0xFFFFFFFF or children > _MAX_NODES - nodes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
if children > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
append(bytes(({tuple: _TUPLE, list: _LIST, dict: _DICT}[kind],)))
|
||||
append(_U32.pack(count))
|
||||
if kind is dict:
|
||||
for key, child in item.items():
|
||||
if type(key) is not str:
|
||||
raise WorkerPayloadError("Worker mapping keys must be strings.")
|
||||
encode(key, depth + 1)
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
for child in item:
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
raise WorkerPayloadError("Unsupported worker payload value type.")
|
||||
|
||||
try:
|
||||
append(_MAGIC)
|
||||
encode(value)
|
||||
return bytes(wire)
|
||||
except (ValueError, OverflowError, RecursionError, DecimalException) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload value.") from exc
|
||||
|
||||
|
||||
def decode_worker_payload(
|
||||
payload: bytes, *, max_bytes: int = 32 * 1024 * 1024
|
||||
) -> object:
|
||||
_validate_limit(max_bytes)
|
||||
if type(payload) is not bytes or len(payload) > max_bytes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
if not payload.startswith(_MAGIC):
|
||||
raise WorkerPayloadError("Unknown worker payload format.")
|
||||
wire = memoryview(payload)
|
||||
cursor = len(_MAGIC)
|
||||
nodes = 0
|
||||
|
||||
def take(size: int) -> memoryview:
|
||||
nonlocal cursor
|
||||
if size > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Truncated worker payload.")
|
||||
start = cursor
|
||||
cursor += size
|
||||
return wire[start:cursor]
|
||||
|
||||
def decode(depth: int = 0) -> object:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
tag = take(1)[0]
|
||||
if tag == _NULL:
|
||||
return None
|
||||
if tag in (_FALSE, _TRUE):
|
||||
return tag == _TRUE
|
||||
if tag not in range(_STR, _DICT + 1):
|
||||
raise WorkerPayloadError("Unknown worker payload tag.")
|
||||
length = _U32.unpack(take(4))[0]
|
||||
if tag in (_TUPLE, _LIST, _DICT):
|
||||
children = length * (2 if tag == _DICT else 1)
|
||||
if children > _MAX_NODES - nodes or children > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Invalid worker container count.")
|
||||
if tag == _DICT:
|
||||
result = {}
|
||||
for _index in range(length):
|
||||
key = decode(depth + 1)
|
||||
if type(key) is not str or key in result:
|
||||
raise WorkerPayloadError(
|
||||
"Invalid or duplicate worker mapping key."
|
||||
)
|
||||
result[key] = decode(depth + 1)
|
||||
return result
|
||||
result = [decode(depth + 1) for _index in range(length)]
|
||||
return tuple(result) if tag == _TUPLE else result
|
||||
value = take(length)
|
||||
if tag == _BYTES:
|
||||
return bytes(value)
|
||||
if tag == _INT:
|
||||
if not length:
|
||||
raise WorkerPayloadError("Invalid worker integer.")
|
||||
return int.from_bytes(value, "big", signed=True)
|
||||
if tag == _FLOAT:
|
||||
if length != 8:
|
||||
raise WorkerPayloadError("Invalid worker float.")
|
||||
return struct.unpack(">d", value)[0]
|
||||
if tag == _UUID:
|
||||
if length != 16:
|
||||
raise WorkerPayloadError("Invalid worker UUID.")
|
||||
return UUID(bytes=bytes(value))
|
||||
text = str(value, "utf-8")
|
||||
decoders = {
|
||||
_STR: lambda item: item,
|
||||
_DECIMAL: Decimal,
|
||||
_DATE: date.fromisoformat,
|
||||
_DATETIME: datetime.fromisoformat,
|
||||
_TIME: time.fromisoformat,
|
||||
}
|
||||
return decoders[tag](text)
|
||||
|
||||
try:
|
||||
result = decode()
|
||||
if cursor != len(wire):
|
||||
raise WorkerPayloadError("Trailing worker payload data.")
|
||||
return result
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
RecursionError,
|
||||
OverflowError,
|
||||
DecimalException,
|
||||
) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload.") from exc
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
JSON_CACHE_CONTROL = "private, no-cache"
|
||||
# This bounds middleware-owned buffering, not the size of a route response.
|
||||
# Larger responses retain their streaming iterator and are never truncated.
|
||||
MAX_CONDITIONAL_JSON_BYTES = 1_048_576
|
||||
JSON_ETAG_VARY_HEADERS = (
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
@@ -26,15 +29,34 @@ async def conditional_json_get_middleware(
|
||||
|
||||
The middleware deliberately works after route handling. That keeps the
|
||||
contract platform-wide without requiring every module router to learn about
|
||||
conditional requests, while still limiting buffering to successful JSON GET
|
||||
responses.
|
||||
conditional requests. Only small successful JSON responses are buffered;
|
||||
larger responses stream unchanged. Authorization still runs on every GET.
|
||||
"""
|
||||
|
||||
response = await call_next(request)
|
||||
if not _eligible_for_conditional_json_get(request, response):
|
||||
return response
|
||||
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
response.headers["cache-control"] = _conditional_cache_control(response.headers.get("cache-control"))
|
||||
response.headers["vary"] = _merge_vary(response.headers.get("vary"), JSON_ETAG_VARY_HEADERS)
|
||||
content_length = response.headers.get("content-length", "")
|
||||
if content_length.isascii() and content_length.isdecimal() and int(content_length) > MAX_CONDITIONAL_JSON_BYTES:
|
||||
return response
|
||||
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
iterator = response.body_iterator
|
||||
async for chunk in iterator:
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size > MAX_CONDITIONAL_JSON_BYTES:
|
||||
# Include the crossing chunk exactly once, without draining the
|
||||
# rest of the producer or copying a potentially large chunk.
|
||||
response.body_iterator = _replay_prefix(chunks, iterator)
|
||||
return response
|
||||
body = b"".join(chunks)
|
||||
etag = response.headers.get("etag") or json_response_etag(body)
|
||||
headers = dict(response.headers)
|
||||
headers["etag"] = etag
|
||||
@@ -48,6 +70,14 @@ async def conditional_json_get_middleware(
|
||||
return Response(content=body, status_code=response.status_code, headers=headers, background=response.background)
|
||||
|
||||
|
||||
async def _replay_prefix(chunks: list[bytes], iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
chunks.clear()
|
||||
async for chunk in iterator:
|
||||
yield chunk
|
||||
|
||||
|
||||
def json_response_etag(body: bytes) -> str:
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
return f'W/"sha256-{digest}"'
|
||||
|
||||
@@ -200,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
|
||||
|
||||
@@ -40,6 +40,12 @@ class Settings(BaseSettings):
|
||||
le=1000,
|
||||
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
|
||||
)
|
||||
isolated_process_concurrency: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=16,
|
||||
alias="GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY",
|
||||
)
|
||||
module_live_apply_enabled: bool | None = Field(
|
||||
default=None,
|
||||
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||
@@ -164,6 +170,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")
|
||||
@@ -211,6 +220,11 @@ class Settings(BaseSettings):
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
# Enable only after the administrator-assisted identity-verification and
|
||||
# recovery-code handoff policy has been adopted for this installation.
|
||||
auth_local_password_recovery_enabled: bool = Field(
|
||||
default=False, alias="AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED"
|
||||
)
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Synthetic operations, imported only by isolated tests; no application effects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def echo(payload: bytes) -> bytes:
|
||||
return payload
|
||||
|
||||
|
||||
def wait(payload: bytes) -> bytes:
|
||||
time.sleep(float(payload))
|
||||
return b"done"
|
||||
|
||||
|
||||
def regex_stall(_payload: bytes) -> bytes:
|
||||
re.fullmatch(r"(a+)+$", "a" * 100 + "!")
|
||||
return b"unreachable"
|
||||
|
||||
|
||||
def allocate(_payload: bytes) -> bytes:
|
||||
return b"x" * (256 * 1024 * 1024)
|
||||
|
||||
|
||||
def too_much_stdout(_payload: bytes) -> bytes:
|
||||
while True:
|
||||
os.write(1, b"x" * 65536)
|
||||
|
||||
|
||||
def too_much_stderr(_payload: bytes) -> bytes:
|
||||
while True:
|
||||
os.write(2, b"sensitive synthetic log" * 4096)
|
||||
|
||||
|
||||
def fail(_payload: bytes) -> bytes:
|
||||
raise ValueError("private synthetic data must not become an error response")
|
||||
|
||||
|
||||
def close_pipes_then_wait(_payload: bytes) -> bytes:
|
||||
os.close(1)
|
||||
os.close(2)
|
||||
time.sleep(30)
|
||||
return b""
|
||||
|
||||
|
||||
def observe(_payload: bytes) -> bytes:
|
||||
import resource
|
||||
|
||||
return json.dumps({
|
||||
"pid": os.getpid(), "pgid": os.getpgrp(), "sid": os.getsid(0),
|
||||
"cpu": resource.getrlimit(resource.RLIMIT_CPU),
|
||||
"memory": resource.getrlimit(resource.RLIMIT_AS),
|
||||
"file": resource.getrlimit(resource.RLIMIT_FSIZE),
|
||||
"core": resource.getrlimit(resource.RLIMIT_CORE),
|
||||
"env": sorted(os.environ), "cwd": os.getcwd(),
|
||||
}).encode()
|
||||
|
||||
|
||||
def child_with_closed_pipes(_payload: bytes) -> bytes:
|
||||
child = subprocess.Popen(
|
||||
[sys.executable, "-I", "-c", "import time; time.sleep(30)"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return str(child.pid).encode()
|
||||
+81
-6
@@ -8,10 +8,11 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyzipper
|
||||
@@ -3545,13 +3546,17 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
||||
job_summary = jobs.json()["jobs"][0]
|
||||
self.assertEqual(job_summary["campaign_version_id"], version_id)
|
||||
self.assertNotIn("resolved_recipients", job_summary)
|
||||
self.assertEqual(job_summary["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||
self.assertNotIn("attachments", job_summary)
|
||||
detail = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(detail.status_code, 200, detail.text)
|
||||
job = detail.json()["job"]
|
||||
self.assertEqual(job_summary["resolved_recipients"], {
|
||||
kind: job["resolved_recipients"][kind] for kind in ("to", "cc", "bcc")
|
||||
})
|
||||
self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||
self.assertEqual(
|
||||
{
|
||||
@@ -4613,7 +4618,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||
from govoplan_campaign.backend.sending.jobs import _begin_job_delivery_recovery, send_campaign_job
|
||||
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||
|
||||
with SessionLocal() as session:
|
||||
jobs = (
|
||||
@@ -4645,7 +4652,42 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
with SessionLocal() as session:
|
||||
result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False)
|
||||
self.assertEqual(result.status, "outcome_unknown")
|
||||
# Observing SENDING is not proof that its owner has stopped.
|
||||
self.assertEqual(result.status, "already_sending")
|
||||
job = session.get(CampaignJob, uncertain_job_id)
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
recovery = _begin_job_delivery_recovery(
|
||||
job=job, context=SimpleNamespace(version=version), claim_token=job.claim_token,
|
||||
)
|
||||
self.assertTrue(recovery.operation_id)
|
||||
session.expire_all()
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||
).one()
|
||||
lease.holder_node_id = "smoke-stopped-worker"
|
||||
lease.holder_incarnation = "smoke-old-incarnation"
|
||||
lease.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
session.add(RuntimeNode(
|
||||
installation_id=process_runtime_identity().installation_id,
|
||||
node_id="smoke-stopped-worker", incarnation="smoke-old-incarnation",
|
||||
role="worker", software_version="test", composition_hash="c" * 64,
|
||||
state="stopped",
|
||||
))
|
||||
session.commit()
|
||||
# Read the committed representation, just as an independent HTTP
|
||||
# reader does (SQLite drops timezone objects during persistence).
|
||||
session.expire_all()
|
||||
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
|
||||
self.assertTrue(metadata["eligible"])
|
||||
|
||||
recovered = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}/recover-claim",
|
||||
headers=headers,
|
||||
json={"channel": "smtp", "expected_revision": metadata["revision"],
|
||||
"note": "Fixture worker is confirmed stopped; inspect provider evidence next."},
|
||||
)
|
||||
self.assertEqual(recovered.status_code, 200, recovered.text)
|
||||
self.assertTrue(recovered.json()["result"]["reconciliation_required"])
|
||||
|
||||
retry_unknown = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
||||
@@ -4672,7 +4714,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(page.json()["total"], 2)
|
||||
self.assertEqual(page.json()["pages"], 2)
|
||||
self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1)
|
||||
self.assertNotIn("resolved_recipients", page.json()["jobs"][0])
|
||||
self.assertIn("resolved_recipients", page.json()["jobs"][0])
|
||||
self.assertNotIn("attachments", page.json()["jobs"][0])
|
||||
|
||||
filtered_page = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs",
|
||||
@@ -4851,7 +4894,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(first_jobs.json()["total_unfiltered"], 1)
|
||||
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
|
||||
self.assertIn("reviewed", first_jobs.json()["jobs"][0])
|
||||
self.assertNotIn("resolved_recipients", first_jobs.json()["jobs"][0])
|
||||
self.assertEqual(first_jobs.json()["jobs"][0]["resolved_recipients"]["to"][0]["email"], "recipient-1@example.org")
|
||||
self.assertNotIn("attachments", first_jobs.json()["jobs"][0])
|
||||
|
||||
first_csv = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
|
||||
@@ -5271,6 +5315,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 +6934,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||
"hidden": ["mail.navigation.mail"],
|
||||
"locked": [],
|
||||
"separators": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime, time as daytime, timezone
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
from govoplan_core.security import bounded_process
|
||||
from govoplan_core.security.bounded_process import (
|
||||
ProcessBudgetError, ProcessLimits, bounded_operation_admission, run_bounded_operation,
|
||||
)
|
||||
from govoplan_core.security.worker_payload import (
|
||||
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.security.process_worker import _read_input
|
||||
from tests import bounded_process_fixtures as operations
|
||||
|
||||
|
||||
class BoundedProcessTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.processes = []
|
||||
original = subprocess.Popen
|
||||
|
||||
def start(*args, **kwargs):
|
||||
process = original(*args, **kwargs)
|
||||
self.processes.append(process)
|
||||
return process
|
||||
|
||||
self.patcher = patch.object(bounded_process.subprocess, "Popen", start)
|
||||
self.patcher.start()
|
||||
self.addCleanup(self.patcher.stop)
|
||||
self.addCleanup(self.assert_reaped)
|
||||
|
||||
def assert_reaped(self) -> None:
|
||||
for process in self.processes:
|
||||
self.assertIsNotNone(process.returncode)
|
||||
self.assertTrue(all(stream.closed for stream in (process.stdin, process.stdout, process.stderr)))
|
||||
with self.assertRaises(ChildProcessError):
|
||||
os.waitpid(process.pid, os.WNOHANG)
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
|
||||
def test_roundtrip_empty_and_pipe_sized_input_exact_output_limit(self) -> None:
|
||||
for payload in (b"", b"x" * 200_000):
|
||||
with self.subTest(length=len(payload)):
|
||||
result = run_bounded_operation(operations.echo, payload, limits=ProcessLimits(output_bytes=max(1, len(payload))))
|
||||
self.assertEqual(result, payload)
|
||||
|
||||
def test_controls_and_environment_are_applied_before_operation(self) -> None:
|
||||
with patch.dict(os.environ, {"DATABASE_URL": "synthetic-secret", "PYTHONPATH": "/untrusted", "SYNTHETIC_SECRET": "no"}):
|
||||
result = json.loads(run_bounded_operation(operations.observe, b"", limits=ProcessLimits(cpu_seconds=3)))
|
||||
self.assertEqual(result["pid"], result["pgid"])
|
||||
self.assertEqual(result["pid"], result["sid"])
|
||||
self.assertNotEqual(result["pid"], os.getpid())
|
||||
self.assertEqual(result["cpu"], [3, 4])
|
||||
self.assertEqual(result["memory"], [256 * 1024 * 1024] * 2)
|
||||
self.assertEqual(result["file"], [0, 0])
|
||||
self.assertEqual(result["core"], [0, 0])
|
||||
self.assertEqual(result["cwd"], "/")
|
||||
self.assertFalse({"DATABASE_URL", "PYTHONPATH", "SYNTHETIC_SECRET"} & set(result["env"]))
|
||||
|
||||
def test_real_regex_cpu_is_stopped_before_long_wall_limit(self) -> None:
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.regex_stall, b"", limits=ProcessLimits(cpu_seconds=1, wall_seconds=5))
|
||||
self.assertEqual(raised.exception.code, "cpu_limit")
|
||||
self.assertLess(time.monotonic() - started, 4)
|
||||
|
||||
def test_memory_failure_never_allocates_the_large_result_in_parent(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.allocate, b"", limits=ProcessLimits(memory_bytes=64 * 1024 * 1024))
|
||||
self.assertEqual(raised.exception.code, "memory_limit")
|
||||
|
||||
def test_noisy_stdout_and_stderr_are_bounded_during_execution(self) -> None:
|
||||
for operation in (operations.too_much_stdout, operations.too_much_stderr):
|
||||
with self.subTest(operation=operation.__name__):
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operation, b"", limits=ProcessLimits(output_bytes=1024, wall_seconds=3))
|
||||
self.assertEqual(raised.exception.code, "output_limit")
|
||||
|
||||
def test_sleep_and_closed_pipe_hangs_are_timed_out(self) -> None:
|
||||
for operation in (operations.wait, operations.close_pipes_then_wait):
|
||||
with self.subTest(operation=operation.__name__):
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operation, b"30", limits=ProcessLimits(wall_seconds=0.3))
|
||||
self.assertEqual(raised.exception.code, "timeout")
|
||||
|
||||
def test_cancellation_kills_and_reaps(self) -> None:
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.wait, b"30", cancelled=lambda: time.monotonic() - started > 0.2)
|
||||
self.assertEqual(raised.exception.code, "cancelled")
|
||||
|
||||
def test_cancellation_callback_exception_also_cleans_up(self) -> None:
|
||||
calls = 0
|
||||
|
||||
def cancel():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 2:
|
||||
raise KeyboardInterrupt
|
||||
return False
|
||||
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
run_bounded_operation(operations.wait, b"30", cancelled=cancel)
|
||||
|
||||
def test_failure_does_not_return_child_exception_or_partial_output(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.fail, b"")
|
||||
self.assertEqual(raised.exception.code, "worker_failed")
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
|
||||
def test_descendant_is_stopped_even_after_successful_leader_exit(self) -> None:
|
||||
pid = int(run_bounded_operation(operations.child_with_closed_pipes, b""))
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
state = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split()[0]
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return
|
||||
if state == "Z":
|
||||
return # stopped, awaiting the operating system's orphan reaper
|
||||
time.sleep(0.01)
|
||||
self.fail("Descendant is still running after its owned group was cleaned up.")
|
||||
|
||||
def test_admission_is_bounded_and_releases_capacity(self) -> None:
|
||||
entered = threading.Event()
|
||||
|
||||
def pending():
|
||||
return run_bounded_operation(operations.wait, b"0.4", cancelled=lambda: entered.set() and False)
|
||||
|
||||
with patch.object(settings, "isolated_process_concurrency", 1), ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future = executor.submit(pending)
|
||||
entered.wait(1)
|
||||
deadline = time.monotonic() + 1
|
||||
while bounded_process._active == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.001)
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.echo, b"denied")
|
||||
self.assertEqual(raised.exception.code, "busy")
|
||||
self.assertEqual(future.result(), b"done")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"after"), b"after")
|
||||
|
||||
def test_invalid_inputs_never_spawn_a_child(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError):
|
||||
run_bounded_operation(operations.echo, b"too large", limits=ProcessLimits(input_bytes=1))
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(lambda value: value, b"")
|
||||
self.assertEqual(self.processes, [])
|
||||
for changes in ({"wall_seconds": float("nan")}, {"wall_seconds": float("inf")}, {"cpu_seconds": True}, {"memory_bytes": 1}):
|
||||
with self.subTest(changes=changes), self.assertRaises(ValueError):
|
||||
replace(ProcessLimits(), **changes)
|
||||
|
||||
def test_explicit_admission_covers_preparation_and_can_be_reused_sequentially(self) -> None:
|
||||
with patch.object(settings, "isolated_process_concurrency", 1):
|
||||
with bounded_operation_admission() as admission:
|
||||
self.assertEqual(bounded_process._active, 1)
|
||||
with self.assertRaises(ProcessBudgetError) as busy:
|
||||
with bounded_operation_admission():
|
||||
self.fail("Preparation should not start without shared capacity.")
|
||||
self.assertEqual(busy.exception.code, "busy")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"first", admission=admission), b"first")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"second", admission=admission), b"second")
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(operations.echo, b"expired", admission=admission)
|
||||
|
||||
def test_explicit_admission_rejects_other_threads_and_overlapping_reuse(self) -> None:
|
||||
with bounded_operation_admission() as admission:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(run_bounded_operation, operations.echo, b"wrong thread", admission=admission)
|
||||
with self.assertRaises(ValueError):
|
||||
future.result()
|
||||
nested = False
|
||||
def poll():
|
||||
nonlocal nested
|
||||
if admission.running and not nested:
|
||||
nested = True
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(operations.echo, b"overlap", admission=admission)
|
||||
return False
|
||||
self.assertEqual(run_bounded_operation(operations.wait, b"0.1", admission=admission, cancelled=poll), b"done")
|
||||
self.assertTrue(nested)
|
||||
|
||||
def test_preparation_failure_releases_capacity_without_spawning(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "prepare"):
|
||||
with bounded_operation_admission():
|
||||
raise RuntimeError("prepare")
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
self.assertEqual(self.processes, [])
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "fork"), "Fork ownership requires POSIX fork")
|
||||
def test_inherited_admission_expires_without_releasing_child_capacity(self) -> None:
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
with bounded_operation_admission() as admission:
|
||||
child_pid = os.fork()
|
||||
if child_pid == 0:
|
||||
os.close(read_fd)
|
||||
if child_pid == 0:
|
||||
try:
|
||||
os.write(write_fd, json.dumps({
|
||||
"active": bounded_process._active,
|
||||
"token_active": admission.active,
|
||||
}).encode())
|
||||
finally:
|
||||
os.close(write_fd)
|
||||
os._exit(0)
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
observation = json.loads(os.read(read_fd, 256))
|
||||
_, status = os.waitpid(child_pid, 0)
|
||||
self.assertEqual(0, os.waitstatus_to_exitcode(status))
|
||||
self.assertEqual({"active": 0, "token_active": False}, observation)
|
||||
self.assertEqual(0, bounded_process._active)
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
|
||||
def test_worker_stdin_reads_are_incremental_instead_of_allocating_the_cap(self) -> None:
|
||||
class ObservedInput(BytesIO):
|
||||
def read(self, size=-1):
|
||||
self_test.assertLessEqual(size, 65536)
|
||||
return super().read(size)
|
||||
self_test = self
|
||||
self.assertEqual(_read_input(ObservedInput(b"tiny"), 64 * 1024 * 1024), b"tiny")
|
||||
self.assertEqual(_read_input(ObservedInput(b"x" * 100000), 100000), b"x" * 100000)
|
||||
self.assertIsNone(_read_input(ObservedInput(b"x" * 100001), 100000))
|
||||
|
||||
|
||||
class WorkerPayloadTests(unittest.TestCase):
|
||||
def test_roundtrip_explicit_types_and_user_keys_cannot_impersonate_tags(self) -> None:
|
||||
value = {"str": ["bytes", "not transport"], "values": (
|
||||
None, True, 3, 1.25, Decimal("1.2500"), b"\x00\xff", date(2026, 9, 8),
|
||||
datetime(2026, 9, 8, tzinfo=timezone.utc), daytime(12, 30), UUID(int=4),
|
||||
)}
|
||||
self.assertEqual(decode_worker_payload(encode_worker_payload(value)), value)
|
||||
|
||||
def test_rejects_arbitrary_objects_duplicate_keys_and_invalid_tags(self) -> None:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(object())
|
||||
key = encode_worker_payload("a")[4:]
|
||||
duplicate_keys = b"GWP\x01\x0e\x00\x00\x00\x02" + (key + b"\x00") * 2
|
||||
for wire in (b'["pickle","payload"]', duplicate_keys, b"GWP\x01\xff", b"GWP\x01\x03\x00\x00\x00\x01\xff"):
|
||||
with self.subTest(wire=wire), self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_transport_depth_and_byte_limits(self) -> None:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload("x" * 1000, max_bytes=100)
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(b" " * 1000, max_bytes=100)
|
||||
value = []
|
||||
for _index in range(66):
|
||||
value = [value]
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value)
|
||||
|
||||
def test_astral_unicode_uses_utf8_bytes_and_exact_byte_caps(self) -> None:
|
||||
value = "\U0001f30d" * 32_769
|
||||
limit = 4 + 5 + len(value) * 4
|
||||
wire = encode_worker_payload(value, max_bytes=limit)
|
||||
self.assertEqual(len(wire), limit)
|
||||
self.assertEqual(value, decode_worker_payload(wire, max_bytes=limit))
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value, max_bytes=limit - 1)
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire, max_bytes=limit - 1)
|
||||
|
||||
def test_unicode_limit_is_checked_without_whole_encoded_temporary(self) -> None:
|
||||
import tracemalloc
|
||||
|
||||
value = "\U0001f30d" * 900_000
|
||||
tracemalloc.start()
|
||||
try:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value, max_bytes=1_000_000)
|
||||
_current, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
self.assertLess(peak, 2_000_000)
|
||||
|
||||
def test_malformed_counts_depth_and_trailing_data_fail_before_children(self) -> None:
|
||||
import struct
|
||||
|
||||
deep = b"GWP\x01" + (b"\x0d" + struct.pack(">I", 1)) * 66 + b"\x00"
|
||||
for wire in (
|
||||
b"GWP\x01\x0d" + struct.pack(">I", 0xFFFFFFFF),
|
||||
b"GWP\x01\x0e" + struct.pack(">I", 0xFFFFFFFF),
|
||||
b"GWP\x01\x03" + struct.pack(">I", 0xFFFFFFFF),
|
||||
deep,
|
||||
encode_worker_payload(None) + b"\x00",
|
||||
b"GWP\x01\x05\x00\x00\x00\x00",
|
||||
b"GWP\x01\x06\x00\x00\x00\x01x",
|
||||
b"GWP\x01\x08\x00\x00\x00\x01x",
|
||||
):
|
||||
with self.subTest(wire=wire[:20]), self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_decoder_checks_node_budget_before_allocating_container(self) -> None:
|
||||
from govoplan_core.security import worker_payload
|
||||
|
||||
wire = encode_worker_payload([None] * 20)
|
||||
with patch.object(worker_payload, "_MAX_NODES", 10):
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_large_signed_integers_roundtrip_and_decimal_errors_are_normalized(self) -> None:
|
||||
for value in (0, -1, 127, 128, -128, -129, 1 << 20_000, -(1 << 20_000)):
|
||||
with self.subTest(bits=value.bit_length()):
|
||||
self.assertEqual(value, decode_worker_payload(encode_worker_payload(value)))
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(b"GWP\x01\x07\x00\x00\x00\x07invalid")
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(1 << 20_000, max_bytes=100)
|
||||
@@ -1,15 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
|
||||
|
||||
class ConditionalBufferTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unknown_length_overflow_replays_exact_bytes_without_eager_drain(self) -> None:
|
||||
chunks = [b'{"value":"', b'', b'a' * 32, b'b' * 32, b'c' * 32, b'"}']
|
||||
consumed = []
|
||||
|
||||
async def body():
|
||||
for chunk in chunks:
|
||||
consumed.append(chunk)
|
||||
yield chunk
|
||||
|
||||
background = BackgroundTask(lambda: None)
|
||||
response = StreamingResponse(body(), media_type="application/json", background=background)
|
||||
|
||||
async def route(request):
|
||||
return response
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": [(b'if-none-match', b'*')]})
|
||||
with patch("govoplan_core.server.conditional_requests.MAX_CONDITIONAL_JSON_BYTES", 64, create=True):
|
||||
result = await conditional_json_get_middleware(request, route)
|
||||
self.assertIs(result, response)
|
||||
self.assertEqual(4, len(consumed))
|
||||
self.assertEqual(200, result.status_code)
|
||||
self.assertNotIn("etag", result.headers)
|
||||
self.assertIn("private", result.headers["cache-control"])
|
||||
self.assertIn("Authorization", result.headers["vary"])
|
||||
self.assertIs(background, result.background)
|
||||
self.assertEqual(b"".join(chunks), b"".join([chunk async for chunk in result.body_iterator]))
|
||||
self.assertEqual(chunks, consumed)
|
||||
|
||||
async def test_known_large_body_is_not_consumed(self) -> None:
|
||||
consumed = []
|
||||
|
||||
async def body():
|
||||
consumed.append(True)
|
||||
yield b"x" * 65
|
||||
|
||||
response = StreamingResponse(body(), media_type="application/json", headers={"Content-Length": "65"})
|
||||
|
||||
async def route(request):
|
||||
return response
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": []})
|
||||
with patch("govoplan_core.server.conditional_requests.MAX_CONDITIONAL_JSON_BYTES", 64, create=True):
|
||||
result = await conditional_json_get_middleware(request, route)
|
||||
self.assertIs(result, response)
|
||||
self.assertEqual([], consumed)
|
||||
self.assertEqual("65", result.headers["content-length"])
|
||||
self.assertEqual(b"x" * 65, b"".join([chunk async for chunk in result.body_iterator]))
|
||||
|
||||
async def test_matching_small_response_still_runs_current_route_authorization(self) -> None:
|
||||
calls = []
|
||||
|
||||
async def route(request):
|
||||
calls.append(True)
|
||||
if len(calls) > 1:
|
||||
return Response(status_code=403)
|
||||
return StreamingResponse(iter([b'{"ok":true}']), media_type="application/json")
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": []})
|
||||
first = await conditional_json_get_middleware(request, route)
|
||||
conditional = Request({"type": "http", "method": "GET", "headers": [(b'if-none-match', first.headers['etag'].encode())]})
|
||||
second = await conditional_json_get_middleware(conditional, route)
|
||||
self.assertEqual(403, second.status_code)
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
|
||||
class ConditionalRequestTests(unittest.TestCase):
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import JSON, Column, Integer, MetaData, Table, create_engine, select
|
||||
from sqlalchemy.dialects import mysql, postgresql
|
||||
from sqlalchemy.exc import CompileError
|
||||
|
||||
from govoplan_core.db.json_predicates import (
|
||||
json_array_contains_object_strings,
|
||||
json_array_contains_string,
|
||||
json_object_matches_strings,
|
||||
)
|
||||
|
||||
|
||||
class JsonPredicateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
self.metadata = MetaData()
|
||||
self.records = Table("records", self.metadata, Column("id", Integer, primary_key=True), Column("value", JSON))
|
||||
self.metadata.create_all(self.engine)
|
||||
|
||||
def tearDown(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def matched(self, values, predicate):
|
||||
with self.engine.begin() as connection:
|
||||
connection.execute(self.records.insert(), [{"id": index, "value": value} for index, value in enumerate(values)])
|
||||
return list(connection.scalars(select(self.records.c.id).where(predicate).order_by(self.records.c.id)))
|
||||
|
||||
def test_string_membership_is_array_and_type_exact(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[["1"], [1], [True], [None], {"key": "1"}, "1", None, ["11"]],
|
||||
json_array_contains_string(self.records.c.value, "1"),
|
||||
))
|
||||
|
||||
def test_object_fields_are_exact_strings_not_coerced_or_substrings(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[{"kind": "account", "id": "1", "label": "Extra field allowed"},
|
||||
{"kind": "account", "id": 1}, {"kind": "account", "id": "11"},
|
||||
"not-json", None, ["account", "1"]],
|
||||
json_object_matches_strings(self.records.c.value, {"kind": "account", "id": "1"}),
|
||||
))
|
||||
|
||||
def test_array_objects_do_not_match_encoded_objects_or_scalar_elements(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[[{"kind": "account", "id": "1"}], ['{"kind":"account","id":"1"}'],
|
||||
[{"kind": "account", "id": 1}], ["not-json", None, True, 1],
|
||||
{"kind": "account", "id": "1"}, None],
|
||||
json_array_contains_object_strings(self.records.c.value, {"kind": "account", "id": "1"}),
|
||||
))
|
||||
|
||||
def test_values_remain_bound_for_both_dialects(self):
|
||||
value = "x' OR 1=1 --"
|
||||
predicates = [
|
||||
json_array_contains_string(self.records.c.value, value),
|
||||
json_object_matches_strings(self.records.c.value, {"id": value}),
|
||||
json_array_contains_object_strings(self.records.c.value, {"id": value}),
|
||||
]
|
||||
for predicate in predicates:
|
||||
for dialect in (self.engine.dialect, postgresql.dialect()):
|
||||
with self.subTest(predicate=type(predicate).__name__, dialect=dialect.name):
|
||||
compiled = select(self.records.c.id).where(predicate).compile(dialect=dialect)
|
||||
self.assertNotIn(value, str(compiled))
|
||||
self.assertIn(value, compiled.params.values())
|
||||
with self.assertRaises(CompileError):
|
||||
select(self.records.c.id).where(predicate).compile(dialect=mysql.dialect())
|
||||
|
||||
def test_invalid_field_names_and_non_string_matches_are_rejected(self):
|
||||
for fields in ({"id": 1}, {"not.a.field": "1"}, {}):
|
||||
with self.subTest(fields=fields), self.assertRaises((TypeError, ValueError)):
|
||||
json_array_contains_object_strings(self.records.c.value, fields)
|
||||
with self.assertRaises(TypeError):
|
||||
json_array_contains_string(self.records.c.value, True)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from alembic import context
|
||||
from alembic.config import Config
|
||||
|
||||
|
||||
class MigrationUrlConfigurationTests(unittest.TestCase):
|
||||
def test_database_urls_round_trip_exactly_in_online_and_offline_modes(self) -> None:
|
||||
urls = (
|
||||
"postgresql+psycopg://localhost/example?host=%2Ftmp%2Fexample",
|
||||
"postgresql+psycopg://synthetic%40user:synthetic%25%40pass@localhost/example",
|
||||
"sqlite:////tmp/synthetic%25-database.db",
|
||||
"sqlite:////tmp/synthetic%%-database.db",
|
||||
"sqlite:////tmp/%(here)s-literal.db",
|
||||
"sqlite:////tmp/synthetic-database.db",
|
||||
)
|
||||
environment = Path(__file__).resolve().parents[1] / "alembic" / "env.py"
|
||||
for url in urls:
|
||||
for offline in (False, True):
|
||||
for from_settings in (False, True):
|
||||
with self.subTest(url=url, offline=offline, from_settings=from_settings):
|
||||
config = Config()
|
||||
config.attributes.update(enabled_modules=(), manifest_factories=())
|
||||
if not from_settings:
|
||||
config.attributes["database_url"] = url
|
||||
engine = MagicMock()
|
||||
connection = engine.connect.return_value.__enter__.return_value
|
||||
with (
|
||||
patch.object(context, "config", config, create=True),
|
||||
patch.object(context, "is_offline_mode", return_value=offline),
|
||||
patch.object(context, "configure") as configure,
|
||||
patch.object(context, "begin_transaction"),
|
||||
patch.object(context, "run_migrations") as run_migrations,
|
||||
patch("sqlalchemy.engine_from_config", return_value=engine) as engine_from_config,
|
||||
patch(
|
||||
"govoplan_core.server.default_config.get_server_config",
|
||||
return_value=SimpleNamespace(enabled_modules=(), manifest_factories=()),
|
||||
),
|
||||
patch("govoplan_core.server.registry.build_platform_registry"),
|
||||
patch(
|
||||
"govoplan_core.core.migrations.migration_metadata_plan",
|
||||
return_value=SimpleNamespace(metadata=()),
|
||||
),
|
||||
patch("govoplan_core.settings.settings.database_url", url),
|
||||
):
|
||||
runpy.run_path(str(environment))
|
||||
|
||||
self.assertEqual(config.get_main_option("sqlalchemy.url"), url)
|
||||
self.assertEqual(config.get_section(config.config_ini_section)["sqlalchemy.url"], url)
|
||||
run_migrations.assert_called_once_with()
|
||||
if offline:
|
||||
engine_from_config.assert_not_called()
|
||||
self.assertEqual(configure.call_args.kwargs["url"], url)
|
||||
else:
|
||||
engine_from_config.assert_called_once()
|
||||
self.assertEqual(engine_from_config.call_args.args[0]["sqlalchemy.url"], url)
|
||||
self.assertIs(configure.call_args.kwargs["connection"], connection)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"),
|
||||
|
||||
@@ -27,6 +27,25 @@ class _RevisionFixture(Base):
|
||||
|
||||
|
||||
class OptimisticConcurrencyTests(unittest.TestCase):
|
||||
def test_keyed_insertions_keep_their_anchors_during_disjoint_edits(self) -> None:
|
||||
base = [{"id": "a", "value": 1}, {"id": "b", "value": 2}]
|
||||
local = [base[0], {"id": "x", "value": 3}, base[1]]
|
||||
current = [base[0], {"id": "b", "value": 20}]
|
||||
result = three_way_merge(base, local, current)
|
||||
self.assertTrue(result.merged)
|
||||
self.assertEqual(["a", "x", "b"], [item["id"] for item in result.value])
|
||||
self.assertEqual(20, result.value[-1]["value"])
|
||||
self.assertEqual(["a", "b"], [item["id"] for item in base])
|
||||
|
||||
def test_concurrent_insertions_are_stable_and_incompatible_anchors_conflict(self) -> None:
|
||||
base = [{"id": "a"}, {"id": "b"}]
|
||||
result = three_way_merge(base, [base[0], {"id": "x"}, base[1]], [base[0], {"id": "y"}, base[1]])
|
||||
self.assertTrue(result.merged)
|
||||
self.assertEqual(["a", "y", "x", "b"], [item["id"] for item in result.value])
|
||||
conflict = three_way_merge(base, [base[0], {"id": "x"}, base[1]], [base[1], base[0]])
|
||||
self.assertFalse(conflict.merged)
|
||||
self.assertEqual("collection_reorder", conflict.conflicts[0].kind)
|
||||
|
||||
def test_strong_etags_and_if_match_use_strong_comparison(self) -> None:
|
||||
etag = strong_resource_etag("campaign_version", "version-1", 3)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.principal_helpers import principal_actor_ids, principal_user_first_actor
|
||||
|
||||
|
||||
class PrincipalHelperTests(unittest.TestCase):
|
||||
def test_contracts_keep_their_distinct_precedence_and_whitespace(self) -> None:
|
||||
principal = SimpleNamespace(account_id=" account ", identity_id=" identity ", membership_id=" membership ", user=SimpleNamespace(id=" user "))
|
||||
self.assertEqual((" account ", " identity ", " membership ", " user "), principal_actor_ids(principal))
|
||||
self.assertEqual("user", principal_user_first_actor(principal))
|
||||
self.assertEqual(" account ", principal.account_id)
|
||||
self.assertEqual(" user ", principal.user.id)
|
||||
|
||||
def test_duplicate_ids_are_removed_in_stable_order_without_normalizing(self) -> None:
|
||||
principal = SimpleNamespace(account_id="same", identity_id=" same ", membership_id="same", user=SimpleNamespace(id="same"))
|
||||
self.assertEqual(("same", " same "), principal_actor_ids(principal))
|
||||
self.assertEqual("same", principal_user_first_actor(principal))
|
||||
|
||||
def test_missing_blank_and_service_only_principals_remain_unattributed(self) -> None:
|
||||
for principal in (
|
||||
object(), None, SimpleNamespace(), SimpleNamespace(service_account_id="service"),
|
||||
SimpleNamespace(account_id=0, identity_id=False, membership_id="\t", user=SimpleNamespace(id="\u00a0")),
|
||||
):
|
||||
with self.subTest(principal=principal):
|
||||
self.assertEqual((), principal_actor_ids(principal))
|
||||
self.assertIsNone(principal_user_first_actor(principal))
|
||||
|
||||
def test_fallback_and_existing_string_coercion_are_unchanged(self) -> None:
|
||||
for values, expected_ids, expected_actor in (
|
||||
({"account_id": 17, "identity_id": "identity"}, ("17", "identity"), "17"),
|
||||
({"identity_id": " identity ", "membership_id": "member"}, (" identity ", "member"), "identity"),
|
||||
({"membership_id": " member "}, (" member ",), "member"),
|
||||
({"user": SimpleNamespace(id=" user ")}, (" user ",), "user"),
|
||||
):
|
||||
with self.subTest(values=values):
|
||||
principal = SimpleNamespace(**values)
|
||||
self.assertEqual(expected_ids, principal_actor_ids(principal))
|
||||
self.assertEqual(expected_actor, principal_user_first_actor(principal))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, localize_documentation_topics
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
TabularColumn,
|
||||
TabularCsvSource,
|
||||
TabularSourceUnavailableError,
|
||||
TabularSourceValidationError,
|
||||
csv_source_payload,
|
||||
csv_source_summary,
|
||||
verified_csv_source_text,
|
||||
csv_projection_matches,
|
||||
infer_tabular_schema,
|
||||
parse_tabular_csv,
|
||||
tabular_type_name,
|
||||
)
|
||||
|
||||
|
||||
class SharedReviewMechanicsTests(unittest.TestCase):
|
||||
def test_invalid_csv_unicode_fails_explicitly_without_echoing_content(self) -> None:
|
||||
source = TabularCsvSource(text="value\nprivate-\ud800\n")
|
||||
for action in (
|
||||
lambda: csv_source_payload(source),
|
||||
lambda: parse_tabular_csv(source.text),
|
||||
):
|
||||
with (
|
||||
self.subTest(action=action),
|
||||
self.assertRaises(TabularSourceValidationError) as raised,
|
||||
):
|
||||
action()
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
with self.assertRaises(TabularSourceUnavailableError):
|
||||
verified_csv_source_text({"text": source.text})
|
||||
|
||||
def test_csv_huge_integer_has_explicit_validation_and_lossless_text_option(
|
||||
self,
|
||||
) -> None:
|
||||
import sys
|
||||
|
||||
maximum = sys.get_int_max_str_digits()
|
||||
if not maximum:
|
||||
self.skipTest("Interpreter integer conversion limit is disabled")
|
||||
text = "9" * (maximum + 1)
|
||||
with self.assertRaisesRegex(TabularSourceValidationError, "use text mode"):
|
||||
parse_tabular_csv("value\n" + text + "\n")
|
||||
self.assertEqual(
|
||||
({"value": text},),
|
||||
parse_tabular_csv("value\n" + text + "\n", value_mode="text"),
|
||||
)
|
||||
|
||||
def test_csv_projection_binding_distinguishes_boolean_integer_and_float(
|
||||
self,
|
||||
) -> None:
|
||||
for expected in (True, 1, 1.0):
|
||||
for actual in (True, 1, 1.0):
|
||||
with self.subTest(
|
||||
expected_type=type(expected), actual_type=type(actual)
|
||||
):
|
||||
self.assertEqual(
|
||||
type(expected) is type(actual),
|
||||
csv_projection_matches(
|
||||
({"value": expected},), ({"value": actual},)
|
||||
),
|
||||
)
|
||||
self.assertFalse(csv_projection_matches(({"value": 1},), ({"other": 1},)))
|
||||
|
||||
def test_translation_merge_preserves_owner_data_and_untranslated_identity(
|
||||
self,
|
||||
) -> None:
|
||||
translations = {"de": {"summary": "vorhanden"}, "fr": {"title": "Français"}}
|
||||
first = DocumentationTopic(
|
||||
id="one", title="One", summary="First", translations=translations
|
||||
)
|
||||
unchanged = DocumentationTopic(id="two", title="Two", summary="Second")
|
||||
localized = localize_documentation_topics(
|
||||
iter((first, unchanged)),
|
||||
locale="de",
|
||||
translations={"one": {"title": "Eins"}},
|
||||
)
|
||||
self.assertEqual(
|
||||
{"title": "Eins", "summary": "vorhanden"}, localized[0].translations["de"]
|
||||
)
|
||||
self.assertEqual({"title": "Français"}, localized[0].translations["fr"])
|
||||
self.assertIs(unchanged, localized[1])
|
||||
self.assertEqual({"summary": "vorhanden"}, first.translations["de"])
|
||||
self.assertIsNot(translations["fr"], localized[0].translations["fr"])
|
||||
|
||||
def test_schema_inference_matches_legacy_projection_for_sparse_mixed_rows(
|
||||
self,
|
||||
) -> None:
|
||||
generator = random.Random(298)
|
||||
values = [None, True, False, 1, 1.5, Decimal("1.00"), "001", [], {}]
|
||||
rows = [
|
||||
{
|
||||
f"field_{column}": generator.choice(values)
|
||||
for column in generator.sample(range(80), 20)
|
||||
}
|
||||
for _ in range(100)
|
||||
]
|
||||
names = list(dict.fromkeys(name for row in rows for name in row))
|
||||
expected = []
|
||||
for name in names:
|
||||
concrete = [row[name] for row in rows if row.get(name) is not None]
|
||||
types = {tabular_type_name(value) for value in concrete}
|
||||
kind = (
|
||||
"unknown"
|
||||
if not types
|
||||
else next(iter(types))
|
||||
if len(types) == 1
|
||||
else "mixed"
|
||||
)
|
||||
expected.append(
|
||||
TabularColumn(
|
||||
name=name, data_type=kind, nullable=len(concrete) != len(rows)
|
||||
)
|
||||
)
|
||||
self.assertEqual(tuple(expected), infer_tabular_schema(rows))
|
||||
self.assertEqual(
|
||||
(TabularColumn("only_null", "unknown", True),),
|
||||
infer_tabular_schema([{"only_null": None}]),
|
||||
)
|
||||
self.assertEqual((), infer_tabular_schema([]))
|
||||
unknown = type("ẞ", (), {})()
|
||||
self.assertEqual("ß", tabular_type_name(unknown))
|
||||
self.assertEqual("ss", tabular_type_name(unknown, casefold_unknown=True))
|
||||
|
||||
def test_csv_evidence_keeps_exact_text_but_summary_never_contains_content(
|
||||
self,
|
||||
) -> None:
|
||||
source = TabularCsvSource(
|
||||
text='\ufeffvalue\r\n" text "\r\n', value_mode="text"
|
||||
)
|
||||
payload = csv_source_payload(source)
|
||||
self.assertEqual(source.text, verified_csv_source_text(payload))
|
||||
self.assertNotIn("text", csv_source_summary(payload))
|
||||
self.assertEqual(len(source.text.encode("utf-8")), payload["byte_count"])
|
||||
with self.assertRaises(TabularSourceUnavailableError):
|
||||
verified_csv_source_text(
|
||||
{**payload, "text": source.text.replace("text", "edited")}
|
||||
)
|
||||
@@ -178,6 +178,29 @@ class TabularSourceContractTests(unittest.TestCase):
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv("id,name\n1,Ada,extra\n")
|
||||
|
||||
def test_text_csv_mode_preserves_lexical_values_and_explicit_empty_records(self) -> None:
|
||||
source = 'id,value\r\n9007199254740993," keep me "\r\ntrue,0.123456789012345678901234567890\r\n" ",""\r\n'
|
||||
self.assertEqual(
|
||||
(
|
||||
{"id": "9007199254740993", "value": " keep me "},
|
||||
{"id": "true", "value": "0.123456789012345678901234567890"},
|
||||
{"id": " ", "value": ""},
|
||||
),
|
||||
parse_tabular_csv(source, value_mode="text"),
|
||||
)
|
||||
self.assertEqual(({"value": " "},), parse_tabular_csv('value\n" "\n', value_mode="text"))
|
||||
|
||||
def test_text_csv_mode_rejects_shape_loss_and_applies_input_and_row_bounds(self) -> None:
|
||||
for source in ('id,name\n1\n', 'id,name\n1,Ada,\n'):
|
||||
with self.subTest(source=source), self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv(source, value_mode="text")
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\n""\n""\n', value_mode="text", max_rows=1)
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\nä\n', value_mode="text", max_bytes=8)
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\nx\n', value_mode="unknown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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,37 @@
|
||||
import { useState } from "react";
|
||||
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
|
||||
import RecipientDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/RecipientDataPage";
|
||||
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>
|
||||
{new URLSearchParams(window.location.search).has("recipient-data")
|
||||
? <RecipientDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} campaignId="campaign-attachments" />
|
||||
: <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>;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import ConnectionTree from "../src/components/ConnectionTree";
|
||||
import ContentSection from "../src/components/ContentSection";
|
||||
import LoadingFrame from "../src/components/LoadingFrame";
|
||||
import DataGrid, { type DataGridColumn } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
|
||||
type Row = { id: string; name: string; detail: string };
|
||||
const rows: Row[] = ["Alpha", "Beta", "Gamma"].map((name) => ({ id: name, name, detail: "A long configured value ".repeat(12) }));
|
||||
|
||||
/** The real shared surfaces, without module-local inset or width overrides. */
|
||||
export default function CardTableLayoutScenario() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inspected, setInspected] = useState("");
|
||||
const columns: DataGridColumn<Row>[] = [
|
||||
{ id: "name", header: "Name", minWidth: 160, value: (row) => row.name },
|
||||
{ id: "detail", header: "Details", minWidth: 220, value: (row) => row.detail },
|
||||
{ id: "actions", header: "Actions", columnType: "actions", sticky: "end", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setInspected(row.name) }
|
||||
]} /> }
|
||||
];
|
||||
const grid = (id: string) => <DataGrid id={`card-table-${id}`} rows={rows} columns={columns} getRowKey={(row) => row.id} />;
|
||||
const tree = () => <ConnectionTree rows={rows} columns={[{ id: "name", header: "Name", render: (row: Row) => row.name }]} getRowKey={(row) => row.id} />;
|
||||
return <main style={{ padding: 16, minWidth: 0, display: "grid", gap: 16, gridTemplateColumns: "minmax(0, 1fr)" }}>
|
||||
<Button onClick={() => setLoading((value) => !value)}>Toggle loading</Button>
|
||||
<output data-testid="card-table-inspected">{inspected}</output>
|
||||
<Card title="Direct table" data-testid="direct-table">{grid("direct")}</Card>
|
||||
<Card title="Collapsible table" data-testid="collapsible-table" collapsible persistCollapse={false}>{grid("collapsible")}</Card>
|
||||
<Card title="Loading table" data-testid="loading-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("loading")}</LoadingFrame></Card>
|
||||
<Card title="Admin table" data-testid="admin-table"><div className="admin-table-surface">{grid("admin")}</div></Card>
|
||||
<Card title="Loading admin table" data-testid="loading-admin-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><div className="admin-table-surface">{grid("loading-admin")}</div></LoadingFrame></Card>
|
||||
<Card title="Connection table" data-testid="connection-table">{tree()}</Card>
|
||||
<Card title="Loading connection table" data-testid="loading-connection-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{tree()}</LoadingFrame></Card>
|
||||
<Card title="Explicit table" data-testid="explicit-table" bodyLayout="table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("explicit")}</LoadingFrame></Card>
|
||||
<Card title="Table with context" data-testid="table-with-context" bodyLayout="table">
|
||||
<ContentSection density="default" spacing="none">Meaningful table context</ContentSection>
|
||||
{grid("context")}
|
||||
</Card>
|
||||
<Card title="Mixed content" data-testid="mixed-content"><p>Ordinary content keeps its inset.</p>{grid("mixed")}</Card>
|
||||
<Card title="Loading mixed content" data-testid="loading-mixed-content"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><p>Ordinary content keeps its inset.</p>{grid("loading-mixed")}</LoadingFrame></Card>
|
||||
<div data-testid="standalone-table">{grid("standalone")}</div>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||
import HeadingHelpScenario from "./HeadingHelpScenario";
|
||||
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 DashboardConfigurationScenario from "./DashboardConfigurationScenario";
|
||||
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 PasswordLifecycleScenario from "./PasswordLifecycleScenario";
|
||||
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";
|
||||
@@ -28,6 +59,7 @@ 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";
|
||||
@@ -54,6 +86,54 @@ export default function ConformanceApp() {
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
if (new URLSearchParams(location.search).has("heading-help")) return <HeadingHelpScenario />;
|
||||
|
||||
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
|
||||
|
||||
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("dashboard-configuration")) return <DashboardConfigurationScenario />;
|
||||
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 />;
|
||||
}
|
||||
@@ -200,6 +280,35 @@ 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(
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import DashboardPage from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardPage";
|
||||
import { dashboardModule } from "../../../govoplan-dashboard/webui/src/module";
|
||||
import { generatedTranslations } from "../../../govoplan-dashboard/webui/src/i18n/generatedTranslations";
|
||||
import { schedulingModule } from "../../../govoplan-scheduling/webui/src/module";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "dashboard-user", account_id: "dashboard-account", email: "dashboard@example.test" },
|
||||
tenant: { id: "dashboard-tenant", name: "Dashboard fixture", slug: "dashboard" },
|
||||
scopes: ["dashboard:dashboard:read"], roles: [], groups: [],
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function DashboardConfigurationScenario() {
|
||||
const widgetHelp = new URLSearchParams(location.search).has("widget-help");
|
||||
const modules = widgetHelp ? [dashboardModule, schedulingModule] : [dashboardModule];
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode={new URLSearchParams(location.search).get("language") ?? "en"}
|
||||
moduleTranslations={[generatedTranslations, ...(widgetHelp ? [schedulingModule.translations] : [])]}>
|
||||
<DashboardPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={widgetHelp ? { ...auth, scopes: [...auth.scopes, "scheduling:schedule:read"] } : auth} />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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";
|
||||
import CardTableLayoutScenario from "./CardTableLayoutScenario";
|
||||
|
||||
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() {
|
||||
if (new URLSearchParams(window.location.search).has("table-cards")) return <CardTableLayoutScenario />;
|
||||
return <ResizableDataGridScenario />;
|
||||
}
|
||||
|
||||
function ResizableDataGridScenario() {
|
||||
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
|
||||
const mixedColumns = new URLSearchParams(window.location.search).has("mixed-columns");
|
||||
const preferredLimits = new URLSearchParams(window.location.search).has("preferred-limits");
|
||||
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, preferredMaxWidth: preferredLimits ? 300 : undefined, resizable: true, value: (row) => row.name },
|
||||
...(mixedColumns ? [{ id: "fixed", header: "Fixed", width: 144, minWidth: 144, maxWidth: 144, value: () => "Fixed value" }] : []),
|
||||
{ id: "detail", header: "Details", width: 360, minWidth: 220, preferredMaxWidth: preferredLimits ? 360 : undefined, 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, mixedColumns, mode, preferredLimits]);
|
||||
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}${mixedColumns ? "-mixed" : ""}`}
|
||||
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>;
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import PageLayout from "../src/components/PageLayout";
|
||||
import PageActionBar from "../src/components/PageActionBar";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
|
||||
import TextWithHelp from "../src/components/help/TextWithHelp";
|
||||
import WidgetHeadingHelpScenario from "./WidgetHeadingHelpScenario";
|
||||
|
||||
export default function HeadingHelpScenario() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
if (new URLSearchParams(location.search).has("widgets")) return <WidgetHeadingHelpScenario />;
|
||||
const help = <DocumentationHelpLink reference={{ contextId: "dashboard" }} />;
|
||||
const longTitle = `Dashboard ${"DokumentationsüberschriftOhneLeerzeichen".repeat(5)}`;
|
||||
return <DocumentationHelpProvider localDocsAvailable={false}>
|
||||
<main className="conformance-root">
|
||||
<PageLayout archetype="overview" mode="embedded" title="Dashboard" titleHelp={help} headerLoading={loading}
|
||||
actions={<PageActionBar variant="overview" primaryActions={<Button onClick={() => setLoading(value => !value)}>Toggle loading</Button>} />}>
|
||||
<WorkspaceActionBar variant="workspace" title="Workspace" titleHelp={help} primaryActions={<Button>Refresh</Button>} />
|
||||
<Card title={longTitle} titleHelp={help} collapsible persistCollapse={false} data-testid="help-card">
|
||||
<p data-testid="help-card-content">Saved data</p>
|
||||
</Card>
|
||||
<TextWithHelp as="div" help={help}><h3>Section</h3></TextWithHelp>
|
||||
<div style={{ width: 120 }} data-testid="raw-text-help">
|
||||
<TextWithHelp help={help}>{"UnbrokenLabel".repeat(8)}</TextWithHelp>
|
||||
</div>
|
||||
<Button data-testid="open-help-dialog" onClick={() => setOpen(true)}>Edit settings</Button>
|
||||
<Dialog open={open} title={longTitle} titleHelp={help} onClose={() => setOpen(false)}
|
||||
footer={<Button data-testid="dialog-last-action">Save</Button>}>
|
||||
<label>Value<input defaultValue="Saved value" /></label>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
</main>
|
||||
</DocumentationHelpProvider>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
|
||||
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
export default function HelpCenterScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { MailProfileScope } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
export default function MailCredentialPolicyScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
|
||||
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
|
||||
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
|
||||
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
|
||||
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function MailFolderExplorerScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function MailToolbarScenario() {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const [tenant, setTenant] = useState("first");
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
|
||||
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Conformance-only composition: render the owning module, never a test copy.
|
||||
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fixtureAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
|
||||
const auth = downloadAllowed ? fixtureAuth : {
|
||||
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
|
||||
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
|
||||
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
|
||||
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
|
||||
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
|
||||
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
|
||||
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-committee/webui/src/styles/committee.css";
|
||||
import "../../../govoplan-voting/webui/src/styles/voting.css";
|
||||
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
|
||||
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
|
||||
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
|
||||
import "../../../govoplan-idm/webui/src/styles/idm.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function ModuleLayoutScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
|
||||
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
|
||||
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
|
||||
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
|
||||
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
|
||||
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
|
||||
"idm:relationship:read", "idm:relationship:write"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const context = { settings, auth };
|
||||
const module = params.get("module-layouts");
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
|
||||
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
|
||||
{module === "committee" && <CommitteePage {...context} />}
|
||||
{module === "voting" && <VotingPage {...context} />}
|
||||
{module === "scheduling" && <SchedulingPage {...context} />}
|
||||
{module === "risk" && <RiskCompliancePage {...context} />}
|
||||
{module === "organizations" && <OrganizationsPage {...context} />}
|
||||
{module === "idm" && <TypedRelationshipsPanel {...context} />}
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import Button from "../src/components/Button";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import MultiSelectFilter from "../src/components/MultiSelectFilter";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
const options = [
|
||||
{ value: "first", label: "First tag" },
|
||||
{ value: "long", label: "X".repeat(80) },
|
||||
{ value: "last", label: "Last tag" }
|
||||
];
|
||||
|
||||
export default function MultiSelectFilterScenario() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selection, setSelection] = useState<string[] | null>(null);
|
||||
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en">
|
||||
<main className="content-pad">
|
||||
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
|
||||
<Button>Outside action</Button>
|
||||
{filter}
|
||||
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
|
||||
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
|
||||
panelStyle={{ transform: "translateZ(0)" }}
|
||||
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
|
||||
>
|
||||
<div style={{ overflow: "hidden", maxHeight: 100 }}>
|
||||
<p>The filter escapes this clipped and transformed dialog.</p>
|
||||
{filter}
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { Folder, LayoutDashboard, Mail } from "lucide-react";
|
||||
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
|
||||
const items: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
|
||||
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
|
||||
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
|
||||
];
|
||||
const areas: ProductAreaContribution[] = [
|
||||
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
|
||||
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
|
||||
];
|
||||
|
||||
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
|
||||
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
|
||||
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
|
||||
} : null);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
|
||||
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
|
||||
<main style={{ minWidth: 0, padding: 16 }}>
|
||||
<h1>Navigation editor fixture</h1>
|
||||
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
|
||||
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
|
||||
</main>
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
|
||||
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
|
||||
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
|
||||
scopes: ["notifications:notification:read"], roles: [], groups: [],
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||
};
|
||||
|
||||
export default function NotificationFilterScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("filter-user");
|
||||
useEffect(() => {
|
||||
const changeAccount = () => setAccount("other-user");
|
||||
window.addEventListener("conformance-notification-account", changeAccount);
|
||||
return () => window.removeEventListener("conformance-notification-account", changeAccount);
|
||||
}, []);
|
||||
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
|
||||
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { StrictMode, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Button from "../src/components/Button";
|
||||
import FormField from "../src/components/FormField";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/layout.css";
|
||||
import "../src/styles/forms.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const GENERATOR_OPTIONS = { length: 24 };
|
||||
|
||||
function PasswordFieldScenario() {
|
||||
const [password, setPassword] = useState("fixture-unchanged-password");
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [changes, setChanges] = useState(0);
|
||||
|
||||
return (
|
||||
<main className="conformance-root">
|
||||
<h1>Optional password generator</h1>
|
||||
<FormField label="Editable password">
|
||||
<PasswordField
|
||||
data-testid="editable-password"
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
generator
|
||||
generatorOptions={GENERATOR_OPTIONS}
|
||||
helpContextId="access.authentication.password"
|
||||
helpModuleId="access"
|
||||
onValueChange={(value) => {
|
||||
setPassword(value);
|
||||
setChanges((count) => count + 1);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<output data-testid="password-changes">{changes}</output>
|
||||
<Button data-testid="toggle-generator-access" onClick={() => setDisabled((value) => !value)}>
|
||||
{disabled ? "Enable generation" : "Disable generation"}
|
||||
</Button>
|
||||
<FormField label="Sign-in password">
|
||||
<PasswordField value="fixture-sign-in-password" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Disabled password">
|
||||
<PasswordField generator disabled value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Read-only password">
|
||||
<PasswordField generator readOnly value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Deliberately no Core barrel or other fixture imports: they could eagerly load
|
||||
// the generator and mask a regression in this field's real lazy boundary.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"}>
|
||||
<PasswordFieldScenario />
|
||||
</PlatformLanguageProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import "../src/styles/auth-gate.css";
|
||||
import { useLocation } from "react-router";
|
||||
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
|
||||
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
|
||||
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
|
||||
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
|
||||
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
|
||||
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
|
||||
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
|
||||
import AuthActionGate from "../src/features/auth/AuthActionGate";
|
||||
import LoginModal from "../src/features/auth/LoginModal";
|
||||
import Button from "../src/components/Button";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
|
||||
|
||||
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const capability: AuthActionUiCapability = {
|
||||
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
|
||||
};
|
||||
const modules: PlatformWebModule[] = [{
|
||||
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
|
||||
helpContexts: [
|
||||
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
|
||||
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
|
||||
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
|
||||
]
|
||||
}];
|
||||
|
||||
export default function PasswordLifecycleScenario() {
|
||||
const parameters = new URLSearchParams(useLocation().search);
|
||||
const mode = parameters.get("mode") ?? "required";
|
||||
const language = parameters.get("language") ?? "en";
|
||||
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
|
||||
const owner = parameters.get("owner") !== "false";
|
||||
const [auth, setAuth] = useState<AuthInfo>({
|
||||
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
|
||||
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
|
||||
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
});
|
||||
const [updated, setUpdated] = useState("");
|
||||
const [open, setOpen] = useState(true);
|
||||
function update(next: AuthUpdate | null, token?: string) {
|
||||
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
|
||||
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
|
||||
}
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
|
||||
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
|
||||
<div data-testid="password-scenario">
|
||||
{mode === "required" || mode === "missing"
|
||||
? auth.user.required_auth_action
|
||||
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
|
||||
: <h1>Workspace available</h1>
|
||||
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
|
||||
: mode === "issue" ? open
|
||||
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
|
||||
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
|
||||
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
|
||||
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
|
||||
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
|
||||
<output data-testid="auth-update">{updated}</output>
|
||||
</div>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -1,11 +1,91 @@
|
||||
// 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 { apiFetch, apiPath } from "../src/api/client";
|
||||
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
|
||||
export { fetchAuthGroups } from "../src/api/auth";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
|
||||
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
|
||||
export type * from "../src/api/privacyRetention";
|
||||
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
|
||||
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 TextWithHelp } from "../src/components/help/TextWithHelp";
|
||||
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 { DashboardWidgetList, useDashboardWidgetData } from "../src/components/DashboardWidgetContent";
|
||||
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, formatAdminDateTime, joinLabels } 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";
|
||||
@@ -13,12 +93,14 @@ 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 } from "../src/components/ContentGrid";
|
||||
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 { 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,
|
||||
@@ -29,29 +111,37 @@ 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 } from "../src/platform/ModuleContext";
|
||||
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
|
||||
export { dashboardWidgetsForModules } from "../src/platform/modules";
|
||||
export {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
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>;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import DashboardGrid from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardGrid";
|
||||
import "../../../govoplan-dashboard/webui/src/styles/dashboard.css";
|
||||
import Button from "../src/components/Button";
|
||||
import { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
|
||||
import type { AuthInfo, DashboardWidgetContribution } from "../src/types";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "widget-user", account_id: "widget-account", email: "widget@example.test" },
|
||||
tenant: { id: "widget-tenant", name: "Widget fixture", slug: "widget" },
|
||||
scopes: [], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const widget: DashboardWidgetContribution = {
|
||||
id: "fixture.scheduling",
|
||||
title: "Scheduling requests",
|
||||
documentation: { topicId: "scheduling.find-and-decide-meeting-time", documentationType: "user" },
|
||||
render: () => <p>Existing requests</p>
|
||||
};
|
||||
const ignore = () => undefined;
|
||||
|
||||
export default function WidgetHeadingHelpScenario() {
|
||||
const [configuring, setConfiguring] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
return <DocumentationHelpProvider localDocsAvailable>
|
||||
<main className="conformance-root">
|
||||
<Button onClick={() => { setConfiguring(value => !value); setPreview(false); }}>Toggle configuration</Button>
|
||||
<Button onClick={() => { setConfiguring(true); setPreview(value => !value); }}>Toggle drag preview</Button>
|
||||
<DashboardGrid
|
||||
placements={[{ instanceId: "fixture-instance", widgetId: widget.id, size: "medium", columnStart: 1, configuration: {} }]}
|
||||
widgetById={new Map([[widget.id, widget]])}
|
||||
settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} modules={[]}
|
||||
effectiveView={null} refreshKey={0} configuring={configuring}
|
||||
dragItem={preview ? { kind: "placement", instanceId: "fixture-instance" } : null}
|
||||
dropTarget={preview ? { kind: "end", columnStart: 1 } : null}
|
||||
onDragStart={ignore} onDragEnd={ignore} onDragOver={ignore} onDragOverEnd={ignore}
|
||||
onDrop={ignore} onDropPreview={ignore} onDropAtEnd={ignore} onRemove={ignore} onConfigure={ignore}
|
||||
/>
|
||||
</main>
|
||||
</DocumentationHelpProvider>;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode="de"
|
||||
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>
|
||||
<Routes>
|
||||
|
||||
@@ -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,224 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, recipientData = false, fieldCount = 0) {
|
||||
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: {},
|
||||
...(recipientData ? { recipients: { allow_individual_to: true },
|
||||
fields: Array.from({ length: fieldCount }, (_, index) => ({ name: `field_${index + 1}`, label: `Field ${index + 1}`, type: "string", can_override: true })),
|
||||
entries: { defaults: {}, inline: [
|
||||
{ id: "recipient-1", name: "Fixture recipient", email: "recipient@example.test", channel_policy: "mail",
|
||||
print_target: { target: "Dispatch fixture", channel: "internal_mail" } }
|
||||
] } } : {}),
|
||||
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 mutations: string[] = [];
|
||||
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") mutations.push(`${request.method()} ${url.pathname}`);
|
||||
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${recipientData ? "&recipient-data" : ""}`);
|
||||
if (recipientData) await expect(page.locator('.recipient-profiles-table-surface .data-grid-body-cell[data-column-id="recipients"]')).toContainText("recipient@example.test");
|
||||
else await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
|
||||
return { writes, mutations, 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([]);
|
||||
});
|
||||
|
||||
test("global attachment labels grow across the fixed source column without changing campaign data", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const card = page.locator("#campaign-global-attachments");
|
||||
const label = card.locator('.data-grid-header-cell[data-column-id="label"]');
|
||||
const basePath = card.locator('.data-grid-header-cell[data-column-id="base_path"]');
|
||||
const pattern = card.locator('.data-grid-header-cell[data-column-id="file_filter"]');
|
||||
const measured = (column: typeof label) => column.evaluate((element) => element.getBoundingClientRect().width);
|
||||
await expect(label).toBeVisible();
|
||||
const handle = label.getByRole("separator");
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
const initialLabel = await measured(label);
|
||||
const initialBasePath = await measured(basePath);
|
||||
const initialPattern = await measured(pattern);
|
||||
const bounds = (await handle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 + 80, bounds.y + bounds.height / 2, { steps: 6 });
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
|
||||
await expect.poll(() => measured(basePath)).toBeCloseTo(initialBasePath, 0);
|
||||
await expect.poll(() => measured(pattern)).toBeCloseTo(initialPattern, 0);
|
||||
await handle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
|
||||
await page.reload();
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("recipient and delivery columns grow past the old caps across fixed neighbors and keep personal widths", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2048, height: 1100 });
|
||||
const fixture = await install(page, true);
|
||||
const grid = page.locator(".recipient-profiles-table-surface");
|
||||
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
|
||||
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
|
||||
const measured = (id: string) => column(id).evaluate((element) => element.getBoundingClientRect().width);
|
||||
const fixedWidths = { active: await measured("active"), attachments: await measured("attachments") };
|
||||
const expectedWidths: Record<string, number> = {};
|
||||
for (const [id, oldMaximum] of [["recipients", 640], ["delivery", 480]] as const) {
|
||||
const handle = column(id).getByRole("separator");
|
||||
await grid.locator(".data-grid-scroll-region").evaluate((element, columnId) => {
|
||||
const header = element.querySelector<HTMLElement>(`.data-grid-header-cell[data-column-id="${columnId}"]`)!;
|
||||
element.scrollLeft = Math.max(0, header.offsetLeft - element.clientWidth / 4);
|
||||
}, id);
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
const initial = await measured(id);
|
||||
const target = Math.max(oldMaximum + 80, initial + 80);
|
||||
const bounds = (await handle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 + target - initial, bounds.y + bounds.height / 2, { steps: 6 });
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
|
||||
await handle.press("Shift+ArrowRight");
|
||||
expectedWidths[id] = target + 40;
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
|
||||
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
|
||||
}
|
||||
expect(await grid.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
|
||||
await page.reload();
|
||||
for (const id of ["recipients", "delivery"]) await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
|
||||
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
|
||||
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("ultrawide recipient grids keep balanced initial widths and resize in both directions across fixed columns", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 3085, height: 1200 });
|
||||
const fixture = await install(page, true, 3);
|
||||
const grid = page.locator(".recipient-profiles-table-surface");
|
||||
const region = grid.locator(".data-grid-scroll-region");
|
||||
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
|
||||
const width = (id: string) => column(id).evaluate(element => element.getBoundingClientRect().width);
|
||||
await expect.poll(() => width("recipients")).toBeLessThanOrEqual(640.01);
|
||||
await expect.poll(() => width("delivery")).toBeLessThanOrEqual(480.01);
|
||||
const fixed = { active: await width("active"), attachments: await width("attachments") };
|
||||
const ready = async (id: string) => {
|
||||
const handle = column(id).getByRole("separator");
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
return handle;
|
||||
};
|
||||
const keyResize = async (id: string, grow: boolean, count = 1) => {
|
||||
const handle = await ready(id);
|
||||
for (let step = 0; step < count; step += 1) await handle.press(grow ? "Shift+ArrowRight" : "Shift+ArrowLeft");
|
||||
};
|
||||
|
||||
// All right-hand field columns must be adjustable beyond their old 360px
|
||||
// presentation caps, and shrinking must not silently snap back afterwards.
|
||||
for (const id of ["field-field_1", "field-field_2", "field-field_3"]) {
|
||||
const initial = await width(id);
|
||||
await keyResize(id, true, 6);
|
||||
await expect.poll(() => width(id)).toBeCloseTo(initial + 240, 0);
|
||||
await keyResize(id, false, 2);
|
||||
await expect.poll(() => width(id)).toBeCloseTo(initial + 160, 0);
|
||||
}
|
||||
const recipientStart = await width("recipients");
|
||||
await keyResize("recipients", true, 10);
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 400, 0);
|
||||
const recipientHandle = await ready("recipients");
|
||||
const bounds = (await recipientHandle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 - 160, bounds.y + bounds.height / 2, { steps: 8 });
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
|
||||
|
||||
const deliveryStart = await width("delivery");
|
||||
await keyResize("delivery", true, 3);
|
||||
await keyResize("delivery", false, 2);
|
||||
await expect.poll(() => width("delivery")).toBeCloseTo(deliveryStart + 40, 0);
|
||||
const savedWidths = await Promise.all(["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].map(width));
|
||||
await page.reload();
|
||||
for (const [index, id] of ["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].entries()) {
|
||||
await expect.poll(() => width(id)).toBeCloseTo(savedWidths[index], 0);
|
||||
}
|
||||
await (await ready("delivery")).focus();
|
||||
const deliveryBounds = (await column("delivery").boundingBox())!;
|
||||
const viewport = (await region.boundingBox())!;
|
||||
expect(deliveryBounds.x + deliveryBounds.width).toBeGreaterThan(viewport.x);
|
||||
expect(deliveryBounds.x).toBeLessThan(viewport.x + viewport.width);
|
||||
for (const id of ["active", "attachments"] as const) await expect.poll(() => width(id)).toBeCloseTo(fixed[id], 0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
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();
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user