Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d37aa527f | ||
|
|
6591aaa3fd | ||
|
|
dc1f244f17 | ||
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 | ||
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d |
@@ -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
|
||||
@@ -142,6 +142,7 @@ system:tenants:read
|
||||
system:tenants:create
|
||||
system:tenants:update
|
||||
system:tenants:suspend
|
||||
system:tenants:erase
|
||||
|
||||
system:accounts:read
|
||||
system:accounts:create
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared API client cache and authority boundaries
|
||||
|
||||
All optional WebUI modules use the Core API client. Its bounded in-memory caches
|
||||
are an optimization, never an authorization mechanism. The backend must check
|
||||
the current principal, tenant, and permissions even for conditional GETs.
|
||||
|
||||
- Identical simultaneous safe requests can share one network request. Requests
|
||||
with caller-owned cancellation are independent.
|
||||
- Responses allowing reuse have at most a 750 ms recent-response window.
|
||||
`no-store` and `Vary: *` responses are not retained. `no-cache` and zero-age
|
||||
responses require a server check; permitted ETags retain conditional GET
|
||||
support without bypassing authorization. This follows the relevant
|
||||
[HTTP cache-control semantics](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2).
|
||||
- Explicit `cache: "no-store"`, `"reload"`, or `"no-cache"` reads bypass older
|
||||
response data and supersede older requests for that resource. Reload is not a
|
||||
mutation. Owning read helpers must pass these options through pagination.
|
||||
- Writes invalidate caches before execution and again on settlement, including
|
||||
failures whose server outcome may be uncertain. Reads started before or
|
||||
during the write cannot seed reusable data after it finishes.
|
||||
- The shell calls `clearApiReadCache()` before explicit auth updates and when
|
||||
refreshing authoritative session data. API-settings changes, clearing the
|
||||
token, authentication expiry, and changes to the paired session/CSRF cookie
|
||||
also invalidate both stored and in-flight reuse. Cookie observation also
|
||||
covers sign-in/out in another tab; it does not read the HttpOnly session token.
|
||||
- Interactive sign-in and sign-out clear a previously saved automation key.
|
||||
Explicit key-based connection settings still select the key's identity;
|
||||
profile-only updates preserve settings identity to avoid reload loops.
|
||||
- Expired responses from superseded reads or downloads do not trigger a login
|
||||
prompt in a newer session.
|
||||
- Every completion (including 304) must still own its cache slot and generation
|
||||
before storing anything. An old caller may receive its own result, so feature
|
||||
components must continue guarding displayed state against obsolete requests.
|
||||
|
||||
Regression coverage: `npm run test:api-client-cache` uses the real client and
|
||||
isolated network fixtures. No live API or account data is involved.
|
||||
@@ -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.
|
||||
@@ -200,6 +200,19 @@ Feature providers remain responsible for their own semantics:
|
||||
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||
parser with different validation or secret-handling rules.
|
||||
|
||||
Core also defines the inverse, read-only dependency-inventory contract used
|
||||
before the installer changes one of those infrastructure capabilities. An
|
||||
enabled module registers
|
||||
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
|
||||
references to its persisted configuration or data, a lifecycle state, scope,
|
||||
numeric metrics, and a required operator action. Providers must not return
|
||||
secrets or use this read to migrate state. The Core collector validates provider
|
||||
identity and capability coverage, orders records deterministically, and marks
|
||||
the complete inventory failed when any provider raises or violates the
|
||||
contract. Ops is the authorized projection boundary; the installer remains the
|
||||
consumer and must match installation id, freshness, completion and impacted
|
||||
capability coverage before apply.
|
||||
|
||||
The admin wizard backend starts with these routes:
|
||||
|
||||
- `GET /api/v1/admin/configuration-packages/catalog`
|
||||
|
||||
@@ -58,6 +58,17 @@ than adding custom `F1` listeners:
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
- `PasswordField` passes its owner context and module through reveal/generate
|
||||
actions and the shared generator dialog. Credential consumers must supply an
|
||||
exact owner context; the generic component does not own credential policy.
|
||||
|
||||
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||
obvious cases conservatively; components may declare `data-help-risk`
|
||||
explicitly or mark a reviewed ordinary control with
|
||||
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||
unresolved high-risk debt.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
|
||||
@@ -1,8 +1,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
|
||||
|
||||
@@ -75,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The same inventory classifies controls whose labels, identities, component
|
||||
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||
retention. These controls require an exact context rather than relying only on
|
||||
page fallback. Reviewed false positives carry
|
||||
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||
resolved. Password fields and their generator dialog propagate the owning
|
||||
field's context so shared credential controls never invent a Core-owned topic.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
@@ -92,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
The same conformance suite mounts the production Forms Runtime self-service and
|
||||
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
|
||||
and mobile runs traverse native controls by keyboard, inspect accessible names
|
||||
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
|
||||
retain independent per-field assisted provenance. Physical assistive-technology
|
||||
spot checks remain release evidence rather than being represented as browser
|
||||
automation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
@@ -109,6 +128,8 @@ The check must report:
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
- no invalid high-risk help annotation or regression above the recorded
|
||||
exact-context debt ceiling.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
@@ -116,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.
|
||||
|
||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- provider-neutral tenant-erasure preview, step, idempotency, and
|
||||
reconciliation contracts in `govoplan_core.core.tenant_erasure`
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
- command/event envelope contract
|
||||
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
|
||||
single-tenant path so it invokes every registered provider for the target
|
||||
tenant, independent of ordinary list-page projections.
|
||||
|
||||
Governed populated-tenant erasure is separate from ordinary delete vetoes.
|
||||
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
|
||||
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
|
||||
backup dispositions, ordered destructive warnings, idempotent step execution,
|
||||
and reconciliation. The collector fails closed when a provider is invalid or
|
||||
fails. A module with nonzero tenant summary counts and no erasure capability is
|
||||
reported as unsupported and blocks execution; modules with neither contract
|
||||
are explicitly projected as outside tenant-persistence scope. Provider
|
||||
evidence contains counts and stable references only and must never contain
|
||||
secrets or erased subject data.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
@@ -1030,6 +1043,49 @@ Any future exception is extraction debt and must be temporary, documented in the
|
||||
script with a reason, and removed when a capability/API/event contract replaces
|
||||
it.
|
||||
|
||||
## Product Surface Contributions
|
||||
|
||||
`FrontendModule.product_surfaces` is the versioned product-composition contract
|
||||
for stable identities that may have one or more technical owners. A contribution
|
||||
declares contract version 1, a product identity, common label/icon/description,
|
||||
stable entry path, owner route and View surfaces, supported task/reader/admin/
|
||||
operator presentations, authorization requirements, capabilities, search
|
||||
sources, help contexts, documentation topics, migration aliases, and standard
|
||||
unavailable/degraded explanations.
|
||||
|
||||
Core validates every reference against the owning manifest. Contributors that
|
||||
share an identity must agree on its common product metadata and entry path;
|
||||
entry and alias paths cannot belong to another product identity. The WebUI
|
||||
composes valid owners by product id, filters them through authorization and the
|
||||
effective View, and resolves the stable entry or migration alias to the first
|
||||
available owner route. It emits `govoplan:product-surface-route-resolved` before
|
||||
the redirect so migration telemetry can observe alias use without making the
|
||||
technical module part of the ordinary label.
|
||||
|
||||
The shell projects every authorized, View-visible owner route with a product
|
||||
contribution into one stable product navigation item. The product label and
|
||||
entry path replace package topology in the primary rail; every contributing
|
||||
owner path still marks that item active. `All available tools` is a collapsed,
|
||||
permission-derived catalogue built independently of the active View, so a
|
||||
focused workflow cannot remove the explicit escape. It may reveal an
|
||||
authorized owner route that a View omitted, but never an unauthorized route.
|
||||
Navigation visibility preferences do not delete catalogue entries, and the
|
||||
original owner routes remain compatible deep links.
|
||||
|
||||
The initial promoted destinations are `work.items` at `/work`,
|
||||
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
|
||||
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
|
||||
and availability language are centralized in Core while Tasks, Calendar,
|
||||
Mail/Postbox, and Files retain route, command, search, help, documentation,
|
||||
authorization, and data ownership.
|
||||
|
||||
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
|
||||
ordinary state explains the attempted outcome, consequence, recovery path and
|
||||
responsible role. Exact module, capability, provider and correlation values may
|
||||
be supplied as a collapsed technical detail; they are not the primary error.
|
||||
The state is presentation only and never grants authority or changes provider
|
||||
health.
|
||||
|
||||
## Boundary Decision Register
|
||||
|
||||
These durable decisions close older exploratory core issues. Implementation
|
||||
@@ -1579,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a
|
||||
controlled deployment option rather than a replacement for release package
|
||||
builds.
|
||||
|
||||
A failed local WebUI package import receives one automatic retry after 250 ms.
|
||||
Descriptor validation still fails closed; it is not bypassed by the retry.
|
||||
If an enabled local module still cannot load, the signed-in shell warns that its
|
||||
screens and integrations may be unavailable and identifies the module. This is
|
||||
a loading failure, not an uninstall. Save other drafts before manually reloading
|
||||
the page; there is no automatic page reload or persistent retry loop. A verified
|
||||
remote fallback that successfully loads clears that module's warning. Packages
|
||||
absent from the optional build graph remain absent, and effective View filtering
|
||||
continues to control which loaded UI capabilities are exposed.
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
Maintenance mode is the required operating state for package install/uninstall
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
applying `ui-hide-help-hints` at the document root. When shown, the shared
|
||||
marker is a labelled, keyboard-focusable help control and exposes its tooltip
|
||||
on focus as well as pointer hover.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
|
||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
||||
|
||||
## Verification
|
||||
|
||||
Development startup explicitly prebundles the Excel reader's browser/universal
|
||||
entrypoints and the lazy rich-text editor's Tiptap dependencies. These are
|
||||
Core-installed vendor dependencies, not eager optional-module imports. This
|
||||
avoids first-time Campaign/Template navigation triggering a second dependency
|
||||
optimization and page reload. Module descriptors and pages remain lazy, and
|
||||
production bundle budgets remain unchanged. The Core interface-pattern check
|
||||
verifies this include list and keeps optional GovOPlaN modules excluded.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
@@ -69,3 +77,33 @@ npm run test:module-permutations
|
||||
The build gate also catches accidental eager imports: a page pulled into the
|
||||
entry closure consumes the initial budget, while an oversized page or module
|
||||
descriptor consumes the asynchronous chunk budget.
|
||||
|
||||
The startup shell imports appearance validation/application from the pure
|
||||
`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export,
|
||||
and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy
|
||||
Settings route. Importing a runtime helper from a module that also owns editor
|
||||
components can accidentally pull the entire editor into the startup chunk.
|
||||
Public helper exports remain compatible; theme application is still synchronous.
|
||||
The versioned default color document and its deep-clone helper live in
|
||||
`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides
|
||||
does not load editor defaults or construct a draft. The default values and public
|
||||
helper names are unchanged; the theme regression checks independent draft clones
|
||||
as well as synchronous validation, application, and reset.
|
||||
|
||||
`PasswordField` keeps ordinary input and reveal controls synchronous. Its
|
||||
optional `PasswordGeneratorDialog` is imported only after an enabled, editable
|
||||
generator is explicitly opened, not for every sign-in/password field. Loading
|
||||
and failures use the shared resource boundary; the underlying field remains
|
||||
usable. Closing or revoking generation while loading cannot apply a candidate.
|
||||
The secure browser RNG, generation policy, public exports, and explicit
|
||||
"Use password" confirmation remain unchanged. The isolated browser fixture
|
||||
does not import the Core barrel, so it can verify that the generator is not
|
||||
requested before opening it, along with cancel/use and focus restoration.
|
||||
|
||||
Deutsch: Normale Passworteingabe und Sichtbarkeitssteuerung bleiben unmittelbar
|
||||
verfügbar. Der optionale Generator wird erst beim bewussten Öffnen eines
|
||||
aktivierten, bearbeitbaren Felds geladen; Lade- und Fehlerzustände nutzen die
|
||||
gemeinsame Ressourcenanzeige. Ohne "Passwort verwenden" wird kein Kandidat
|
||||
übernommen. Sichere Browser-Zufallszahlen, Richtlinien und öffentliche
|
||||
Schnittstellen bleiben unverändert. Wird die Generierung während des Ladens
|
||||
deaktiviert, öffnet eine verspätete Antwort keinen Dialog.
|
||||
|
||||
@@ -2271,6 +2271,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.37"
|
||||
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)
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
|
||||
"infrastructure.dependency_inventory."
|
||||
)
|
||||
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||
CAPABILITY_STATES = frozenset(
|
||||
{
|
||||
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
|
||||
}
|
||||
)
|
||||
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_DEPENDENCY_STATES = frozenset(
|
||||
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
|
||||
)
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependency:
|
||||
"""A non-secret module-owned dependency on deployment infrastructure."""
|
||||
|
||||
capability_id: str
|
||||
module_id: str
|
||||
dependency_type: str
|
||||
dependency_ref: str
|
||||
state: str
|
||||
scope: str
|
||||
summary: str
|
||||
metrics: Mapping[str, int]
|
||||
required_action: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name, value, maximum in (
|
||||
("capability_id", self.capability_id, 120),
|
||||
("module_id", self.module_id, 120),
|
||||
("dependency_type", self.dependency_type, 120),
|
||||
("dependency_ref", self.dependency_ref, 240),
|
||||
("scope", self.scope, 120),
|
||||
("summary", self.summary, 1000),
|
||||
("required_action", self.required_action, 1000),
|
||||
):
|
||||
if (
|
||||
not value.strip()
|
||||
or len(value) > maximum
|
||||
or any(ord(char) < 32 for char in value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency {field_name} is invalid."
|
||||
)
|
||||
if self.state not in _DEPENDENCY_STATES:
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency state is unsupported: {self.state!r}."
|
||||
)
|
||||
if len(self.metrics) > 20 or any(
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or len(key) > 80
|
||||
or any(ord(char) < 32 for char in key)
|
||||
or type(value) is not int
|
||||
or value < 0
|
||||
for key, value in self.metrics.items()
|
||||
):
|
||||
raise ValueError("Infrastructure dependency metrics are invalid.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"capability_id": self.capability_id,
|
||||
"module_id": self.module_id,
|
||||
"dependency_type": self.dependency_type,
|
||||
"dependency_ref": self.dependency_ref,
|
||||
"state": self.state,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"metrics": dict(sorted(self.metrics.items())),
|
||||
"required_action": self.required_action,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InfrastructureDependencyProvider(Protocol):
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyProviderReport:
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
state: str
|
||||
dependency_count: int
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"capability_ids": list(self.capability_ids),
|
||||
"state": self.state,
|
||||
"dependency_count": self.dependency_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyInventory:
|
||||
installation_id: str
|
||||
generated_at: str
|
||||
complete: bool
|
||||
inspected_capability_ids: tuple[str, ...]
|
||||
providers: tuple[InfrastructureDependencyProviderReport, ...]
|
||||
dependencies: tuple[InfrastructureDependency, ...]
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"installation_id": self.installation_id,
|
||||
"generated_at": self.generated_at,
|
||||
"complete": self.complete,
|
||||
"inspected_capability_ids": list(self.inspected_capability_ids),
|
||||
"providers": [item.to_dict() for item in self.providers],
|
||||
"dependencies": [item.to_dict() for item in self.dependencies],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureCapability:
|
||||
id: str
|
||||
@@ -214,6 +331,134 @@ def deployment_capability_status(
|
||||
}
|
||||
|
||||
|
||||
def collect_infrastructure_dependency_inventory(
|
||||
registry: object,
|
||||
*,
|
||||
installation_id: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> InfrastructureDependencyInventory:
|
||||
"""Collect actual module-owned dependencies without importing module internals."""
|
||||
|
||||
normalized_installation_id = installation_id.strip()
|
||||
if not normalized_installation_id or len(normalized_installation_id) > 100:
|
||||
raise ValueError("Infrastructure dependency installation id is invalid.")
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Infrastructure dependency inventory requires a module registry.")
|
||||
|
||||
provider_names = tuple(
|
||||
name
|
||||
for name in capability_names()
|
||||
if isinstance(name, str)
|
||||
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
|
||||
)
|
||||
reports: list[InfrastructureDependencyProviderReport] = []
|
||||
dependencies: list[InfrastructureDependency] = []
|
||||
inspected_capability_ids: set[str] = set()
|
||||
complete = True
|
||||
|
||||
for provider_name in sorted(provider_names):
|
||||
expected_module_id = provider_name.removeprefix(
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
module_id = expected_module_id or "unknown"
|
||||
declared_ids: tuple[str, ...] = ()
|
||||
try:
|
||||
provider = capability(provider_name)
|
||||
if not isinstance(provider, InfrastructureDependencyProvider):
|
||||
raise TypeError("provider does not implement the inventory contract")
|
||||
module_id = provider.module_id.strip()
|
||||
declared_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
item.strip()
|
||||
for item in provider.capability_ids
|
||||
if isinstance(item, str) and item.strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if (
|
||||
module_id != expected_module_id
|
||||
or len(module_id) > 120
|
||||
or any(ord(char) < 32 for char in module_id)
|
||||
or not declared_ids
|
||||
or len(declared_ids) > 30
|
||||
or any(
|
||||
len(item) > 120 or any(ord(char) < 32 for char in item)
|
||||
for item in declared_ids
|
||||
)
|
||||
):
|
||||
raise ValueError("provider identity or capability declaration is invalid")
|
||||
provider_dependencies = tuple(provider.infrastructure_dependencies())
|
||||
if len(provider_dependencies) > 10_000:
|
||||
raise ValueError("provider dependency inventory is too large")
|
||||
seen_refs: set[tuple[str, str, str]] = set()
|
||||
for item in provider_dependencies:
|
||||
if not isinstance(item, InfrastructureDependency):
|
||||
raise TypeError("provider returned an invalid dependency")
|
||||
if item.module_id != module_id or item.capability_id not in declared_ids:
|
||||
raise ValueError("provider returned a dependency outside its declaration")
|
||||
identity = (
|
||||
item.capability_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
)
|
||||
if identity in seen_refs:
|
||||
raise ValueError("provider returned a duplicate dependency")
|
||||
seen_refs.add(identity)
|
||||
if len(dependencies) + len(provider_dependencies) > 10_000:
|
||||
raise ValueError("combined dependency inventory is too large")
|
||||
dependencies.extend(provider_dependencies)
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="complete",
|
||||
dependency_count=len(provider_dependencies),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="error",
|
||||
dependency_count=0,
|
||||
error=(
|
||||
f"{type(exc).__name__}: provider inventory could not be completed"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return InfrastructureDependencyInventory(
|
||||
installation_id=normalized_installation_id,
|
||||
generated_at=timestamp.astimezone(UTC).isoformat(),
|
||||
complete=complete,
|
||||
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
|
||||
providers=tuple(
|
||||
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
|
||||
),
|
||||
dependencies=tuple(
|
||||
sorted(
|
||||
dependencies,
|
||||
key=lambda item: (
|
||||
item.capability_id,
|
||||
item.module_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _capability(value: object) -> InfrastructureCapability:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
|
||||
__all__ = [
|
||||
"CAPABILITY_STATES",
|
||||
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
|
||||
"InfrastructureCapability",
|
||||
"InfrastructureCapabilityReceipt",
|
||||
"InfrastructureCapabilityReceiptError",
|
||||
"InfrastructureDependency",
|
||||
"InfrastructureDependencyInventory",
|
||||
"InfrastructureDependencyProvider",
|
||||
"InfrastructureDependencyProviderReport",
|
||||
"InfrastructurePostInstallTask",
|
||||
"collect_infrastructure_dependency_inventory",
|
||||
"deployment_capability_status",
|
||||
"infrastructure_capability_receipt_from_mapping",
|
||||
"load_infrastructure_capability_receipt",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
@@ -114,6 +115,55 @@ class ProductAreaContribution:
|
||||
order: int = 100
|
||||
|
||||
|
||||
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
|
||||
ProductAvailabilityReason = Literal[
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAvailabilityExplanation:
|
||||
"""Explain a product outcome without making package topology user-facing."""
|
||||
|
||||
reason: ProductAvailabilityReason
|
||||
title: str
|
||||
description: str
|
||||
resolution: str
|
||||
responsible_role: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductSurfaceContribution:
|
||||
"""Bind an owner route to a stable, cross-module product identity."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
entry_path: str
|
||||
route_path: str
|
||||
surface_ids: tuple[str, ...]
|
||||
unavailable: ProductAvailabilityExplanation
|
||||
description: str | None = None
|
||||
degraded: ProductAvailabilityExplanation | None = None
|
||||
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
|
||||
capability_ids: tuple[str, ...] = ()
|
||||
search_source_ids: tuple[str, ...] = ()
|
||||
help_context_ids: tuple[str, ...] = ()
|
||||
documentation_topic_ids: tuple[str, ...] = ()
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
aliases: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuickAccessTool:
|
||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||
@@ -153,6 +203,7 @@ class FrontendModule:
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||
|
||||
|
||||
@@ -358,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, ...]:
|
||||
@@ -687,3 +757,53 @@ class ModuleManifest:
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
|
||||
def with_documentation_structured_translations(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, Any]],
|
||||
) -> ModuleManifest:
|
||||
"""Merge module-owned structured documentation translations by topic id.
|
||||
|
||||
The helper keeps feature prose in its owning module while giving every
|
||||
manifest the same fail-closed merge behavior. Unknown topic ids and
|
||||
incomplete or shape-changing locale maps are rejected immediately.
|
||||
"""
|
||||
|
||||
locale = locale.strip()
|
||||
if not locale:
|
||||
raise ValueError("structured documentation locale must not be empty")
|
||||
|
||||
topics_by_id = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
|
||||
if unknown_topic_ids:
|
||||
raise ValueError(
|
||||
"structured documentation translations reference unknown topic ids: "
|
||||
+ ", ".join(unknown_topic_ids)
|
||||
)
|
||||
|
||||
localized_topics: list[DocumentationTopic] = []
|
||||
for topic in manifest.documentation:
|
||||
translation = translations.get(topic.id)
|
||||
if translation is None:
|
||||
localized_topics.append(topic)
|
||||
continue
|
||||
|
||||
structured_translations = dict(topic.structured_translations)
|
||||
structured_translations[locale] = translation
|
||||
localized_topic = replace(
|
||||
topic,
|
||||
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
structured_translations=structured_translations,
|
||||
)
|
||||
issues = documentation_structured_translation_issues(localized_topic)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"invalid {locale!r} structured documentation translation for "
|
||||
f"{topic.id!r}: {'; '.join(issues)}"
|
||||
)
|
||||
localized_topics.append(localized_topic)
|
||||
|
||||
return replace(manifest, documentation=tuple(localized_topics))
|
||||
|
||||
@@ -8,11 +8,22 @@ NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
|
||||
_MAX_ITEMS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationSeparator:
|
||||
id: str
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"id": self.id, "label": self.label}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationPreferences:
|
||||
order: tuple[str, ...] = ()
|
||||
hidden: tuple[str, ...] = ()
|
||||
locked: tuple[str, ...] = ()
|
||||
# None preserves inherited grouping; an empty tuple explicitly removes it.
|
||||
separators: tuple[NavigationSeparator, ...] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -20,6 +31,7 @@ class NavigationPreferences:
|
||||
"order": list(self.order),
|
||||
"hidden": list(self.hidden),
|
||||
"locked": list(self.locked),
|
||||
**({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +44,9 @@ class EffectiveNavigationItem:
|
||||
order_source: str
|
||||
visibility_source: str
|
||||
lock_source: str | None = None
|
||||
section: NavigationSeparator | None = None
|
||||
custom_layout: bool = False
|
||||
layout_source: str = "module"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -42,6 +57,9 @@ class EffectiveNavigationItem:
|
||||
"navigation_order_source": self.order_source,
|
||||
"navigation_visibility_source": self.visibility_source,
|
||||
"navigation_lock_source": self.lock_source,
|
||||
"navigation_section": self.section.as_dict() if self.section else None,
|
||||
"navigation_custom_layout": self.custom_layout,
|
||||
"navigation_layout_source": self.layout_source,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +81,7 @@ def navigation_preferences_from_mapping(
|
||||
order=_ids(raw.get("order")),
|
||||
hidden=_ids(raw.get("hidden")),
|
||||
locked=_ids(raw.get("locked")),
|
||||
separators=_separators(raw.get("separators")),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +114,9 @@ def resolve_navigation_preferences(
|
||||
visibility = {item_id: True for item_id in ordered}
|
||||
visibility_source = {item_id: "module" for item_id in ordered}
|
||||
locks: dict[str, str] = {}
|
||||
separators: dict[str, NavigationSeparator] = {}
|
||||
custom_layout = False
|
||||
layout_source = "module"
|
||||
|
||||
for source, preferences, may_lock in (
|
||||
("system", system, True),
|
||||
@@ -103,7 +125,13 @@ def resolve_navigation_preferences(
|
||||
):
|
||||
if preferences is None:
|
||||
continue
|
||||
requested_order = [item_id for item_id in preferences.order if item_id in available]
|
||||
if preferences.separators is not None:
|
||||
separators = {item.id: item for item in preferences.separators if item.id not in available}
|
||||
ordered = [item_id for item_id in ordered if item_id in available or item_id in separators]
|
||||
ordered.extend(item_id for item_id in separators if item_id not in ordered)
|
||||
custom_layout = True
|
||||
layout_source = source
|
||||
requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators))
|
||||
if requested_order:
|
||||
requested = set(requested_order)
|
||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
||||
@@ -127,8 +155,13 @@ def resolve_navigation_preferences(
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = source
|
||||
|
||||
return {
|
||||
item_id: EffectiveNavigationItem(
|
||||
result: dict[str, EffectiveNavigationItem] = {}
|
||||
section: NavigationSeparator | None = None
|
||||
for index, item_id in enumerate(ordered):
|
||||
if item_id in separators:
|
||||
section = separators[item_id]
|
||||
continue
|
||||
result[item_id] = EffectiveNavigationItem(
|
||||
id=item_id,
|
||||
order=index,
|
||||
visible=visibility[item_id],
|
||||
@@ -136,9 +169,29 @@ def resolve_navigation_preferences(
|
||||
order_source=order_source[item_id],
|
||||
visibility_source=visibility_source[item_id],
|
||||
lock_source=locks.get(item_id),
|
||||
section=section,
|
||||
custom_layout=custom_layout,
|
||||
layout_source=layout_source,
|
||||
)
|
||||
for index, item_id in enumerate(ordered)
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _separators(value: object) -> tuple[NavigationSeparator, ...] | None:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
items: dict[str, NavigationSeparator] = {}
|
||||
for raw in value[:_MAX_ITEMS]:
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
item_id = _clean_id(raw.get("id"))
|
||||
label = raw.get("label", "")
|
||||
if not item_id.startswith("separator:") or not isinstance(label, str):
|
||||
continue
|
||||
label = label.strip()[:120]
|
||||
if any(ord(character) < 32 for character in label):
|
||||
continue
|
||||
items[item_id] = NavigationSeparator(item_id, label)
|
||||
return tuple(items.values())
|
||||
|
||||
|
||||
def _ids(value: object) -> tuple[str, ...]:
|
||||
@@ -168,6 +221,7 @@ __all__ = [
|
||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||
"NAVIGATION_PREFERENCES_KEY",
|
||||
"NavigationPreferences",
|
||||
"NavigationSeparator",
|
||||
"navigation_preferences_from_mapping",
|
||||
"navigation_preferences_from_settings",
|
||||
"resolve_navigation_preferences",
|
||||
|
||||
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
|
||||
"navigation",
|
||||
"permission",
|
||||
"product_area",
|
||||
"product_surface",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.product_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{surface.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_surface",
|
||||
label=surface.label,
|
||||
path=surface.route_path,
|
||||
required_all=surface.required_all,
|
||||
required_any=surface.required_any,
|
||||
metadata={
|
||||
"contract_version": surface.contract_version,
|
||||
"product_surface_id": surface.id,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(
|
||||
surface.documentation_topic_ids
|
||||
),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable_reason": surface.unavailable.reason,
|
||||
"degraded_reason": (
|
||||
surface.degraded.reason
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
for tool in frontend.quick_access_tools:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
|
||||
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
|
||||
@@ -16,13 +16,16 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
|
||||
TenantSummaryBatchProvider,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
@@ -90,6 +93,9 @@ _WILDCARD_RE = re.compile(
|
||||
)
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
|
||||
_PRODUCT_SURFACE_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
@@ -974,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
|
||||
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
area_definitions: dict[str, tuple[str, str]] = {}
|
||||
surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
|
||||
product_paths: dict[str, str] = {}
|
||||
tool_owners: dict[str, str] = {}
|
||||
concrete_paths = {
|
||||
route.path: manifest.id
|
||||
for manifest in manifests
|
||||
if manifest.frontend is not None
|
||||
for route in (
|
||||
*manifest.frontend.routes,
|
||||
*manifest.frontend.settings_routes,
|
||||
*manifest.frontend.public_routes,
|
||||
)
|
||||
}
|
||||
for manifest in manifests:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
@@ -987,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
|
||||
f"Product area {area.id!r} has conflicting labels or icons"
|
||||
)
|
||||
area_definitions[area.id] = definition
|
||||
for surface in frontend.product_surfaces:
|
||||
definition = (
|
||||
surface.label,
|
||||
surface.icon,
|
||||
surface.entry_path,
|
||||
surface.description,
|
||||
)
|
||||
previous = surface_definitions.get(surface.id)
|
||||
if previous is not None and previous != definition:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has conflicting product identity metadata"
|
||||
)
|
||||
surface_definitions[surface.id] = definition
|
||||
for path in (surface.entry_path, *surface.aliases):
|
||||
concrete_owner = concrete_paths.get(path)
|
||||
if concrete_owner is not None:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} collides with a concrete route "
|
||||
f"owned by module {concrete_owner!r}"
|
||||
)
|
||||
previous_id = product_paths.get(path)
|
||||
if previous_id is not None and previous_id != surface.id:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} is shared by product surfaces "
|
||||
f"{previous_id!r} and {surface.id!r}"
|
||||
)
|
||||
product_paths[path] = surface.id
|
||||
for tool in frontend.quick_access_tools:
|
||||
previous_owner = tool_owners.get(tool.id)
|
||||
if previous_owner is not None:
|
||||
@@ -1474,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
||||
f"in module {manifest.id!r}"
|
||||
)
|
||||
seen_area_memberships.add(membership)
|
||||
seen_product_surfaces: set[str] = set()
|
||||
for surface in frontend.product_surfaces:
|
||||
_validate_product_surface(manifest, surface, known_surface_ids)
|
||||
if surface.id in seen_product_surfaces:
|
||||
raise RegistryError(
|
||||
f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
seen_product_surfaces.add(surface.id)
|
||||
seen_tools: set[str] = set()
|
||||
for tool in frontend.quick_access_tools:
|
||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||
@@ -1512,6 +1565,151 @@ def _validate_product_area(
|
||||
)
|
||||
|
||||
|
||||
def _validate_product_surface(
|
||||
manifest: ModuleManifest,
|
||||
surface: ProductSurfaceContribution,
|
||||
known_surface_ids: set[str],
|
||||
) -> None:
|
||||
module_id = manifest.id
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
if surface.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
|
||||
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
|
||||
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} uses unsupported contract version "
|
||||
f"{surface.contract_version!r}"
|
||||
)
|
||||
if not surface.label.strip() or not surface.icon.strip():
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} needs a label and icon"
|
||||
)
|
||||
for label, path in (
|
||||
("entry", surface.entry_path),
|
||||
("owner", surface.route_path),
|
||||
*(("alias", alias) for alias in surface.aliases),
|
||||
):
|
||||
if not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
|
||||
)
|
||||
if (
|
||||
surface.entry_path == surface.route_path
|
||||
or surface.entry_path in surface.aliases
|
||||
or surface.route_path in surface.aliases
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
|
||||
)
|
||||
if len(set(surface.aliases)) != len(surface.aliases):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} contains duplicate aliases"
|
||||
)
|
||||
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
|
||||
if surface.route_path not in route_paths:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown owner route "
|
||||
f"{surface.route_path!r}"
|
||||
)
|
||||
if not surface.surface_ids:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has no owner surfaces"
|
||||
)
|
||||
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
|
||||
if unknown_surfaces:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown surfaces: "
|
||||
+ ", ".join(sorted(unknown_surfaces))
|
||||
)
|
||||
allowed_presentations = {"task", "reader", "admin", "operator"}
|
||||
if (
|
||||
not surface.presentations
|
||||
or len(set(surface.presentations)) != len(surface.presentations)
|
||||
or set(surface.presentations) - allowed_presentations
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has invalid presentations"
|
||||
)
|
||||
declared_capabilities = {
|
||||
*manifest.required_capabilities,
|
||||
*manifest.optional_capabilities,
|
||||
*manifest.capability_factories,
|
||||
*(provider.name for provider in manifest.provides_interfaces),
|
||||
*(requirement.name for requirement in manifest.requires_interfaces),
|
||||
}
|
||||
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
|
||||
if unknown_capabilities:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undeclared capabilities: "
|
||||
+ ", ".join(sorted(unknown_capabilities))
|
||||
)
|
||||
search_source_ids = {source.id for source in manifest.search_sources}
|
||||
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
|
||||
if unknown_search_sources:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown search sources: "
|
||||
+ ", ".join(sorted(unknown_search_sources))
|
||||
)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
|
||||
if unknown_topics:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown documentation topics: "
|
||||
+ ", ".join(sorted(unknown_topics))
|
||||
)
|
||||
documented_help_contexts: set[str] = set()
|
||||
for topic in manifest.documentation:
|
||||
contexts = topic.metadata.get("help_contexts", ())
|
||||
if isinstance(contexts, (list, tuple, set, frozenset)):
|
||||
documented_help_contexts.update(
|
||||
context for context in contexts if isinstance(context, str)
|
||||
)
|
||||
unknown_help = set(surface.help_context_ids) - documented_help_contexts
|
||||
if unknown_help:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undocumented help contexts: "
|
||||
+ ", ".join(sorted(unknown_help))
|
||||
)
|
||||
_validate_product_availability_explanation(surface.id, surface.unavailable)
|
||||
if surface.degraded is not None:
|
||||
_validate_product_availability_explanation(surface.id, surface.degraded)
|
||||
|
||||
|
||||
def _validate_product_availability_explanation(
|
||||
surface_id: str,
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> None:
|
||||
allowed_reasons = {
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
}
|
||||
if explanation.reason not in allowed_reasons:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an invalid availability reason"
|
||||
)
|
||||
if any(
|
||||
not value.strip()
|
||||
for value in (
|
||||
explanation.title,
|
||||
explanation.description,
|
||||
explanation.resolution,
|
||||
)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an incomplete availability explanation"
|
||||
)
|
||||
|
||||
|
||||
def _validate_quick_access_tool(
|
||||
module_id: str,
|
||||
tool: QuickAccessTool,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
|
||||
|
||||
TenantErasureDisposition = Literal[
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
]
|
||||
TenantErasureStepKind = Literal[
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
]
|
||||
TenantErasureResultState = Literal[
|
||||
"completed",
|
||||
"pending",
|
||||
"blocked",
|
||||
"outcome_unknown",
|
||||
]
|
||||
|
||||
_DISPOSITIONS = frozenset(
|
||||
{
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
}
|
||||
)
|
||||
_STEP_KINDS = frozenset(
|
||||
{
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
}
|
||||
)
|
||||
_RESULT_STATES = frozenset(
|
||||
{"completed", "pending", "blocked", "outcome_unknown"}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: str, label: str, *, maximum: int) -> str:
|
||||
normalized = value.strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > maximum
|
||||
or any(ord(character) < 32 for character in normalized)
|
||||
):
|
||||
raise ValueError(f"Tenant erasure {label} is invalid.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _texts(
|
||||
values: tuple[str, ...],
|
||||
label: str,
|
||||
*,
|
||||
maximum_items: int = 100,
|
||||
maximum_length: int = 500,
|
||||
) -> tuple[str, ...]:
|
||||
if len(values) > maximum_items:
|
||||
raise ValueError(f"Tenant erasure {label} has too many entries.")
|
||||
normalized = tuple(
|
||||
_text(value, label, maximum=maximum_length) for value in values
|
||||
)
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError(f"Tenant erasure {label} contains duplicates.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
|
||||
if len(values) > 30:
|
||||
raise ValueError("Tenant erasure metrics has too many entries.")
|
||||
normalized: dict[str, int] = {}
|
||||
for key, value in values.items():
|
||||
normalized_key = _text(key, "metric key", maximum=80)
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError("Tenant erasure metric values must be non-negative integers.")
|
||||
normalized[normalized_key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureResource:
|
||||
resource_type: str
|
||||
count: int
|
||||
disposition: TenantErasureDisposition
|
||||
summary: str
|
||||
governance_ref: str | None = None
|
||||
external: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.resource_type, "resource type", maximum=120)
|
||||
_text(self.summary, "resource summary", maximum=1000)
|
||||
if type(self.count) is not int or self.count < 0:
|
||||
raise ValueError("Tenant erasure resource count is invalid.")
|
||||
if self.disposition not in _DISPOSITIONS:
|
||||
raise ValueError("Tenant erasure resource disposition is invalid.")
|
||||
if self.governance_ref is not None:
|
||||
_text(self.governance_ref, "governance reference", maximum=300)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"resource_type": self.resource_type,
|
||||
"count": self.count,
|
||||
"disposition": self.disposition,
|
||||
"summary": self.summary,
|
||||
"governance_ref": self.governance_ref,
|
||||
"external": self.external,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStep:
|
||||
step_id: str
|
||||
kind: TenantErasureStepKind
|
||||
summary: str
|
||||
destructive: bool
|
||||
irreversible: bool
|
||||
requires_reconciliation: bool = False
|
||||
depends_on: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.step_id, "step id", maximum=160)
|
||||
_text(self.summary, "step summary", maximum=1000)
|
||||
if self.kind not in _STEP_KINDS:
|
||||
raise ValueError("Tenant erasure step kind is invalid.")
|
||||
_texts(self.depends_on, "step dependencies", maximum_length=160)
|
||||
if self.step_id in self.depends_on:
|
||||
raise ValueError("Tenant erasure step cannot depend on itself.")
|
||||
if self.irreversible and not self.destructive:
|
||||
raise ValueError("An irreversible tenant erasure step must be destructive.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"step_id": self.step_id,
|
||||
"kind": self.kind,
|
||||
"summary": self.summary,
|
||||
"destructive": self.destructive,
|
||||
"irreversible": self.irreversible,
|
||||
"requires_reconciliation": self.requires_reconciliation,
|
||||
"depends_on": list(self.depends_on),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasurePreview:
|
||||
module_id: str
|
||||
complete: bool
|
||||
resources: tuple[TenantErasureResource, ...] = ()
|
||||
steps: tuple[TenantErasureStep, ...] = ()
|
||||
blockers: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
provider_revision: str = "1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.module_id, "module id", maximum=120)
|
||||
_text(self.provider_revision, "provider revision", maximum=120)
|
||||
_texts(self.blockers, "blockers", maximum_length=1000)
|
||||
_texts(self.warnings, "warnings", maximum_length=1000)
|
||||
if len(self.resources) > 500 or len(self.steps) > 500:
|
||||
raise ValueError("Tenant erasure preview is too large.")
|
||||
resource_types = [item.resource_type for item in self.resources]
|
||||
if len(resource_types) != len(set(resource_types)):
|
||||
raise ValueError("Tenant erasure preview repeats a resource type.")
|
||||
resources_requiring_action = tuple(
|
||||
item for item in self.resources if item.count > 0
|
||||
)
|
||||
if resources_requiring_action and not self.steps and not self.blockers:
|
||||
raise ValueError(
|
||||
"Tenant erasure resources require steps or an explicit blocker."
|
||||
)
|
||||
if any(
|
||||
item.count > 0 and item.disposition == "unavailable"
|
||||
for item in self.resources
|
||||
) and not self.blockers:
|
||||
raise ValueError(
|
||||
"Unavailable tenant erasure resources require an explicit blocker."
|
||||
)
|
||||
if not self.complete and not self.blockers:
|
||||
raise ValueError(
|
||||
"An incomplete tenant erasure preview requires an explicit blocker."
|
||||
)
|
||||
step_ids = [item.step_id for item in self.steps]
|
||||
if len(step_ids) != len(set(step_ids)):
|
||||
raise ValueError("Tenant erasure preview repeats a step id.")
|
||||
known_step_ids = set(step_ids)
|
||||
if any(
|
||||
dependency not in known_step_ids
|
||||
for step in self.steps
|
||||
for dependency in step.depends_on
|
||||
):
|
||||
raise ValueError("Tenant erasure step references an unknown dependency.")
|
||||
remaining = {
|
||||
step.step_id: set(step.depends_on)
|
||||
for step in self.steps
|
||||
}
|
||||
resolved: set[str] = set()
|
||||
while remaining:
|
||||
ready = sorted(
|
||||
step_id
|
||||
for step_id, dependencies in remaining.items()
|
||||
if dependencies.issubset(resolved)
|
||||
)
|
||||
if not ready:
|
||||
raise ValueError("Tenant erasure step dependencies contain a cycle.")
|
||||
resolved.update(ready)
|
||||
for step_id in ready:
|
||||
remaining.pop(step_id)
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and not self.blockers
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"provider_revision": self.provider_revision,
|
||||
"resources": [item.to_dict() for item in self.resources],
|
||||
"steps": [item.to_dict() for item in self.steps],
|
||||
"blockers": list(self.blockers),
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStepResult:
|
||||
state: TenantErasureResultState
|
||||
summary: str
|
||||
receipt_ref: str | None = None
|
||||
metrics: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.state not in _RESULT_STATES:
|
||||
raise ValueError("Tenant erasure result state is invalid.")
|
||||
_text(self.summary, "result summary", maximum=1000)
|
||||
if self.receipt_ref is not None:
|
||||
_text(self.receipt_ref, "receipt reference", maximum=500)
|
||||
_metrics(self.metrics)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"summary": self.summary,
|
||||
"receipt_ref": self.receipt_ref,
|
||||
"metrics": dict(sorted(_metrics(self.metrics).items())),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantErasureProvider(Protocol):
|
||||
module_id: str
|
||||
|
||||
def preview_tenant_erasure(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantErasurePreview:
|
||||
...
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureInventory:
|
||||
tenant_id: str
|
||||
generated_at: datetime
|
||||
complete: bool
|
||||
modules: tuple[TenantErasurePreview, ...]
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and all(item.allowed for item in self.modules)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
generated_at = self.generated_at
|
||||
if generated_at.tzinfo is None:
|
||||
generated_at = generated_at.replace(tzinfo=UTC)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tenant_id": self.tenant_id,
|
||||
"generated_at": generated_at.astimezone(UTC).isoformat(),
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"modules": [item.to_dict() for item in self.modules],
|
||||
}
|
||||
|
||||
|
||||
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Tenant erasure requires a module registry.")
|
||||
providers: dict[str, TenantErasureProvider] = {}
|
||||
for capability_name in sorted(capability_names()):
|
||||
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
|
||||
continue
|
||||
expected_module_id = capability_name.removeprefix(
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
provider = capability(capability_name)
|
||||
if not isinstance(provider, TenantErasureProvider):
|
||||
raise TypeError(
|
||||
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
|
||||
)
|
||||
module_id = _text(provider.module_id, "provider module id", maximum=120)
|
||||
if module_id != expected_module_id or module_id in providers:
|
||||
raise ValueError("Tenant erasure provider identity is invalid.")
|
||||
providers[module_id] = provider
|
||||
return providers
|
||||
|
||||
|
||||
def collect_tenant_erasure_inventory(
|
||||
registry: object,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
) -> TenantErasureInventory:
|
||||
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
|
||||
manifests = getattr(registry, "manifests", None)
|
||||
summary_providers = getattr(registry, "tenant_summary_providers", None)
|
||||
if not callable(manifests) or not callable(summary_providers):
|
||||
raise ValueError("Tenant erasure inventory requires a module registry.")
|
||||
provider_by_module = tenant_erasure_providers(registry)
|
||||
summary_by_module = dict(summary_providers())
|
||||
manifest_ids = {
|
||||
str(manifest.id)
|
||||
for manifest in manifests()
|
||||
if getattr(manifest, "id", None)
|
||||
}
|
||||
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
|
||||
previews: list[TenantErasurePreview] = []
|
||||
complete = True
|
||||
for module_id in sorted(module_ids):
|
||||
provider = provider_by_module.get(module_id)
|
||||
if provider is not None:
|
||||
try:
|
||||
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
|
||||
if not isinstance(preview, TenantErasurePreview):
|
||||
raise TypeError("provider returned an invalid preview")
|
||||
if preview.module_id != module_id:
|
||||
raise ValueError("provider returned another module's preview")
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: provider preview could not be completed",
|
||||
),
|
||||
)
|
||||
previews.append(preview)
|
||||
complete = complete and preview.complete
|
||||
continue
|
||||
summary_provider = summary_by_module.get(module_id)
|
||||
if summary_provider is None:
|
||||
previews.append(
|
||||
TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
warnings=(
|
||||
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
|
||||
),
|
||||
provider_revision="manifest-no-tenant-data",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
raw_counts = summary_provider(session, normalized_tenant_id)
|
||||
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
|
||||
resources = tuple(
|
||||
TenantErasureResource(
|
||||
resource_type=resource_type,
|
||||
count=count,
|
||||
disposition="unavailable" if count else "erase",
|
||||
summary=(
|
||||
"Tenant-owned data requires a module erasure provider."
|
||||
if count
|
||||
else "The module reported no tenant-owned records."
|
||||
),
|
||||
)
|
||||
for resource_type, count in sorted(counts.items())
|
||||
)
|
||||
blockers = (
|
||||
("Tenant-owned data exists but the module has no erasure provider.",)
|
||||
if any(counts.values())
|
||||
else ()
|
||||
)
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
resources=resources,
|
||||
blockers=blockers,
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: tenant summary could not be completed",
|
||||
),
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
previews.append(preview)
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return TenantErasureInventory(
|
||||
tenant_id=normalized_tenant_id,
|
||||
generated_at=timestamp,
|
||||
complete=complete,
|
||||
modules=tuple(previews),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
|
||||
"TenantErasureInventory",
|
||||
"TenantErasurePreview",
|
||||
"TenantErasureProvider",
|
||||
"TenantErasureResource",
|
||||
"TenantErasureStep",
|
||||
"TenantErasureStepResult",
|
||||
"collect_tenant_erasure_inventory",
|
||||
"tenant_erasure_providers",
|
||||
]
|
||||
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)
|
||||
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"system:tenants:create": "access:tenant:create",
|
||||
"system:tenants:update": "access:tenant:update",
|
||||
"system:tenants:suspend": "access:tenant:suspend",
|
||||
"system:tenants:erase": "access:tenant:erase",
|
||||
"system:accounts:read": "access:account:read",
|
||||
"system:accounts:create": "access:account:create",
|
||||
"system:accounts:update": "access:account:update",
|
||||
|
||||
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||
|
||||
@@ -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}"'
|
||||
|
||||
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||
@@ -198,6 +200,9 @@ def _nav_item_payload(
|
||||
"visible": scoped.visible,
|
||||
"locked": scoped.locked,
|
||||
"lock_source": scoped.lock_source,
|
||||
"section": scoped.section.as_dict() if scoped.section else None,
|
||||
"custom_layout": scoped.custom_layout,
|
||||
"layout_source": scoped.layout_source,
|
||||
}
|
||||
for scope in ("module", "system", "tenant")
|
||||
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
|
||||
@@ -254,6 +259,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _product_availability_payload(
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"reason": explanation.reason,
|
||||
"title": explanation.title,
|
||||
"description": explanation.description,
|
||||
"resolution": explanation.resolution,
|
||||
"responsible_role": explanation.responsible_role,
|
||||
}
|
||||
|
||||
|
||||
def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": surface.contract_version,
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"label": surface.label,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"route_path": surface.route_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(surface.documentation_topic_ids),
|
||||
"required_all": list(surface.required_all),
|
||||
"required_any": list(surface.required_any),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable": _product_availability_payload(surface.unavailable),
|
||||
"degraded": (
|
||||
_product_availability_payload(surface.degraded)
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||
return {
|
||||
"id": tool.id,
|
||||
@@ -372,6 +418,10 @@ def _frontend_payload(
|
||||
"product_areas": [
|
||||
_product_area_payload(area) for area in frontend.product_areas
|
||||
],
|
||||
"product_surfaces": [
|
||||
_product_surface_payload(surface)
|
||||
for surface in frontend.product_surfaces
|
||||
],
|
||||
"quick_access_tools": [
|
||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||
],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
with_documentation_structured_translations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
@@ -136,6 +137,45 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||
registry_for(incomplete_shape).validate()
|
||||
|
||||
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={"steps": ["Review", "Execute"]},
|
||||
)
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(topic,),
|
||||
)
|
||||
|
||||
localized = with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={
|
||||
topic.id: {"steps": ["Prüfen", "Ausführen"]},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized.documentation[0].structured_translations["de"]["steps"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "preserve list length"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={topic.id: {"steps": ["Prüfen"]}},
|
||||
)
|
||||
|
||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||
resolver = lambda _context, keys: { # noqa: E731
|
||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -9,12 +10,53 @@ from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
InfrastructureDependency,
|
||||
collect_infrastructure_dependency_inventory,
|
||||
deployment_capability_status,
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
load_infrastructure_capability_receipt,
|
||||
)
|
||||
|
||||
|
||||
class _InventoryProvider:
|
||||
module_id = "mail"
|
||||
capability_ids = ("mail.smtp",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
return (
|
||||
InfrastructureDependency(
|
||||
capability_id="mail.smtp",
|
||||
module_id="mail",
|
||||
dependency_type="smtp_endpoint",
|
||||
dependency_ref="mail-server:server-1",
|
||||
state="active",
|
||||
scope="system",
|
||||
summary="One active SMTP endpoint uses the deployment relay.",
|
||||
metrics={"credential_binding_count": 1},
|
||||
required_action="Rebind or retire the endpoint before removal.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FailingInventoryProvider:
|
||||
module_id = "files"
|
||||
capability_ids = ("files.storage",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
raise RuntimeError("database URL must not escape")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, providers: dict[str, object]) -> None:
|
||||
self.providers = providers
|
||||
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return tuple(self.providers)
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
return self.providers.get(name)
|
||||
|
||||
|
||||
def _receipt_payload() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
||||
def test_collects_non_secret_provider_dependency_inventory(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.mail": _InventoryProvider(),
|
||||
"unrelated.capability": object(),
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertTrue(inventory.complete)
|
||||
self.assertEqual(("mail.smtp",), inventory.inspected_capability_ids)
|
||||
self.assertEqual("mail-server:server-1", inventory.dependencies[0].dependency_ref)
|
||||
self.assertEqual("2026-08-24T12:00:00+00:00", inventory.generated_at)
|
||||
self.assertNotIn("database URL", json.dumps(inventory.to_dict()))
|
||||
|
||||
def test_provider_failure_makes_inventory_incomplete_without_leaking_error(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.files": (
|
||||
_FailingInventoryProvider()
|
||||
)
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
)
|
||||
|
||||
self.assertFalse(inventory.complete)
|
||||
self.assertEqual("error", inventory.providers[0].state)
|
||||
self.assertNotIn("database URL", str(inventory.providers[0].error))
|
||||
|
||||
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
||||
|
||||
|
||||
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()
|
||||
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["access:tenant:erase"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
scopes_grant_compatible(
|
||||
["system:tenants:write"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
@@ -1015,8 +1027,10 @@ finally:
|
||||
json={"mode": "destroy", "reason": "not supported"},
|
||||
)
|
||||
self.assertEqual(409, destructive.status_code, destructive.text)
|
||||
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
||||
self.assertIn("tenant_data_present", issue_codes)
|
||||
self.assertIn(
|
||||
"Direct destructive deletion is disabled",
|
||||
destructive.json()["detail"]["message"],
|
||||
)
|
||||
|
||||
with database.session() as session:
|
||||
empty_tenant = Tenant(
|
||||
@@ -1029,15 +1043,43 @@ finally:
|
||||
session.commit()
|
||||
empty_tenant_id = empty_tenant.id
|
||||
|
||||
destroyed = client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
||||
erasure_policy = client.patch(
|
||||
"/api/v1/admin/tenant-erasure-policy",
|
||||
headers=headers,
|
||||
json={"mode": "destroy", "reason": "empty tenant cleanup"},
|
||||
json={
|
||||
"production_profile": False,
|
||||
"required_approvals": 1,
|
||||
"preview_ttl_seconds": 900,
|
||||
"recent_authentication_seconds": 900,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, destroyed.status_code, destroyed.text)
|
||||
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
||||
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
||||
self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
|
||||
erasure_preview = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
|
||||
headers=headers,
|
||||
json={
|
||||
"idempotency_key": f"empty-destroy-{name}",
|
||||
"reason": "empty tenant cleanup",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
|
||||
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
|
||||
operation_id = erasure_preview.json()["id"]
|
||||
approved_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
|
||||
self.assertEqual("ready", approved_erasure.json()["state"])
|
||||
executed_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
|
||||
self.assertEqual("completed", executed_erasure.json()["state"])
|
||||
self.assertIsNone(executed_erasure.json()["reason"])
|
||||
|
||||
retired = client.request(
|
||||
"DELETE",
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
|
||||
from govoplan_core.core.navigation import (
|
||||
NavigationPreferences,
|
||||
NavigationSeparator,
|
||||
navigation_preferences_from_settings,
|
||||
resolve_navigation_preferences,
|
||||
update_navigation_preferences,
|
||||
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
|
||||
|
||||
|
||||
class NavigationPreferenceTests(unittest.TestCase):
|
||||
def test_separator_order_and_labels_round_trip_across_scopes(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Arbeit")
|
||||
preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,))
|
||||
stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences))
|
||||
self.assertEqual(preferences, stored)
|
||||
resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored)
|
||||
self.assertIsNone(resolved["dashboard"].section)
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual(separator, resolved["mail"].section)
|
||||
self.assertTrue(resolved["dashboard"].custom_layout)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertNotIn(separator.id, resolved) # Never an authorized destination.
|
||||
|
||||
def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None:
|
||||
system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",))
|
||||
user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),))
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user)
|
||||
self.assertEqual("user", resolved["files"].layout_source)
|
||||
self.assertEqual("Persönlich", resolved["files"].section.label)
|
||||
self.assertTrue(resolved["files"].visible)
|
||||
self.assertIsNone(resolved["mail"].section)
|
||||
flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=()))
|
||||
self.assertTrue(flat["files"].custom_layout)
|
||||
self.assertIsNone(flat["files"].section)
|
||||
self.assertTrue(flat["files"].locked)
|
||||
|
||||
def test_legacy_preferences_inherit_separator_layout(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Work")
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",)))
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertFalse(resolved["mail"].visible)
|
||||
|
||||
def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None:
|
||||
from pydantic import ValidationError
|
||||
from govoplan_core.api.v1.schemas import NavigationPreferencesPayload
|
||||
for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}):
|
||||
with self.subTest(separator=separator), self.assertRaises(ValidationError):
|
||||
NavigationPreferencesPayload.model_validate({"separators": [separator]})
|
||||
|
||||
def test_user_order_overrides_tenant_and_system_order(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("dashboard", "files", "mail", "campaign"),
|
||||
|
||||
@@ -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()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
|
||||
surface_ids=("example.route.main",),
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="work.examples",
|
||||
module_id="example",
|
||||
label="Examples",
|
||||
description="Review and update governed examples.",
|
||||
icon="list-checks",
|
||||
entry_path="/work/examples",
|
||||
route_path="/example",
|
||||
surface_ids=("example.route.main",),
|
||||
presentations=("task", "reader"),
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="Examples are unavailable",
|
||||
description="Your current responsibility does not include examples.",
|
||||
resolution="Ask the responsible administrator to review your assignment.",
|
||||
responsible_role="Access administrator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="example.summary",
|
||||
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend = response.json()["modules"][0]["frontend"]
|
||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||
product_surface = frontend["product_surfaces"][0]
|
||||
self.assertEqual("1", product_surface["contract_version"])
|
||||
self.assertEqual("work.examples", product_surface["id"])
|
||||
self.assertEqual("/work/examples", product_surface["entry_path"])
|
||||
self.assertEqual("/example", product_surface["route_path"])
|
||||
self.assertEqual(["task", "reader"], product_surface["presentations"])
|
||||
self.assertEqual(
|
||||
"authorization",
|
||||
product_surface["unavailable"]["reason"],
|
||||
)
|
||||
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||
self.assertEqual(
|
||||
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend["quick_access_tools"][0]["help_context_id"],
|
||||
)
|
||||
|
||||
def test_registry_rejects_product_surface_without_owner_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
frontend=FrontendModule(
|
||||
module_id=manifest.id,
|
||||
routes=frontend.routes,
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id=surface.id,
|
||||
module_id=surface.module_id,
|
||||
label=surface.label,
|
||||
description=surface.description,
|
||||
icon=surface.icon,
|
||||
entry_path=surface.entry_path,
|
||||
route_path="/missing",
|
||||
surface_ids=surface.surface_ids,
|
||||
unavailable=surface.unavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "unknown owner route"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = replace(
|
||||
manifest,
|
||||
frontend=replace(
|
||||
frontend,
|
||||
routes=(
|
||||
*frontend.routes,
|
||||
FrontendRoute(path="/shortcut", component="ShortcutPage"),
|
||||
),
|
||||
product_surfaces=(replace(surface, aliases=("/shortcut",)),),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "collides with a concrete route"):
|
||||
registry.validate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
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,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.tenant_erasure import (
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||
TenantErasurePreview,
|
||||
TenantErasureResource,
|
||||
TenantErasureStep,
|
||||
TenantErasureStepResult,
|
||||
collect_tenant_erasure_inventory,
|
||||
tenant_erasure_providers,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
module_id = "files"
|
||||
|
||||
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||
del session
|
||||
assert tenant_id == "tenant-1"
|
||||
return TenantErasurePreview(
|
||||
module_id=self.module_id,
|
||||
complete=True,
|
||||
resources=(
|
||||
TenantErasureResource(
|
||||
resource_type="file_blobs",
|
||||
count=2,
|
||||
disposition="erase",
|
||||
summary="Two tenant-owned file blobs will be erased.",
|
||||
),
|
||||
),
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="erase-blobs",
|
||||
kind="erase",
|
||||
summary="Erase tenant-owned file blobs.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
del session, tenant_id, step_id, idempotency_key
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary="Tenant file blobs erased.",
|
||||
metrics={"deleted": 2},
|
||||
)
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
return self.execute_tenant_erasure_step(
|
||||
session, tenant_id, step_id, idempotency_key
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
|
||||
self._provider = provider
|
||||
self._counts = counts
|
||||
|
||||
def manifests(self):
|
||||
return (
|
||||
SimpleNamespace(id="core"),
|
||||
SimpleNamespace(id="files"),
|
||||
SimpleNamespace(id="wiki"),
|
||||
)
|
||||
|
||||
def capability_names(self):
|
||||
if self._provider is None:
|
||||
return ()
|
||||
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
||||
|
||||
def capability(self, name: str):
|
||||
assert name.endswith("files")
|
||||
return self._provider
|
||||
|
||||
def tenant_summary_providers(self):
|
||||
if self._counts is None:
|
||||
return {}
|
||||
return {"files": lambda _session, _tenant_id: self._counts}
|
||||
|
||||
|
||||
def test_contract_rejects_unsafe_irreversible_step() -> None:
|
||||
with pytest.raises(ValueError, match="must be destructive"):
|
||||
TenantErasureStep(
|
||||
step_id="unsafe",
|
||||
kind="erase",
|
||||
summary="Invalid step.",
|
||||
destructive=False,
|
||||
irreversible=True,
|
||||
)
|
||||
|
||||
|
||||
def test_contract_rejects_cyclic_step_dependencies() -> None:
|
||||
with pytest.raises(ValueError, match="contain a cycle"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="first",
|
||||
kind="erase",
|
||||
summary="First.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
depends_on=("second",),
|
||||
),
|
||||
TenantErasureStep(
|
||||
step_id="second",
|
||||
kind="verify",
|
||||
summary="Second.",
|
||||
destructive=False,
|
||||
irreversible=False,
|
||||
depends_on=("first",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
|
||||
resource = TenantErasureResource(
|
||||
resource_type="files",
|
||||
count=1,
|
||||
disposition="erase",
|
||||
summary="One file exists.",
|
||||
)
|
||||
with pytest.raises(ValueError, match="steps or an explicit blocker"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
resources=(resource,),
|
||||
)
|
||||
|
||||
|
||||
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(provider=_Provider()),
|
||||
object(),
|
||||
"tenant-1",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert inventory.complete
|
||||
assert inventory.allowed
|
||||
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
|
||||
assert inventory.modules[1].steps[0].irreversible
|
||||
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
|
||||
|
||||
|
||||
def test_summary_fallback_blocks_when_data_exists() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(counts={"file_blobs": 3}),
|
||||
object(),
|
||||
"tenant-1",
|
||||
)
|
||||
|
||||
files = next(item for item in inventory.modules if item.module_id == "files")
|
||||
assert inventory.complete
|
||||
assert not inventory.allowed
|
||||
assert files.resources[0].disposition == "unavailable"
|
||||
assert files.blockers == (
|
||||
"Tenant-owned data exists but the module has no erasure provider.",
|
||||
)
|
||||
|
||||
|
||||
def test_provider_identity_must_match_capability_suffix() -> None:
|
||||
provider = _Provider()
|
||||
provider.module_id = "mail"
|
||||
|
||||
with pytest.raises(ValueError, match="identity"):
|
||||
tenant_erasure_providers(_Registry(provider=provider))
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"initialJs": {
|
||||
"rawBytes": 524288,
|
||||
"gzipBytes": 163840
|
||||
"gzipBytes": 164128
|
||||
},
|
||||
"asyncChunk": {
|
||||
"rawBytes": 393216,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
|
||||
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function AddressExplorerScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
|
||||
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
|
||||
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
|
||||
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
|
||||
"addresses:address_book:write", "addresses:address_book:delete",
|
||||
"addresses:address_list:write", "addresses:address_list:delete",
|
||||
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<AddressBookPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,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,40 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||
import 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";
|
||||
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||
import ActionToolbar from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
@@ -25,14 +59,26 @@ import StatePanel from "../src/components/StatePanel";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import WysiwygEditor from "../src/components/WysiwygEditor";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import HelpMenu from "../src/layout/HelpMenu";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types";
|
||||
import { projectProductNavigation } from "../src/platform/productSurfaces";
|
||||
import type {
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
EffectiveViewProjection,
|
||||
PlatformNavItem,
|
||||
PlatformWebModule,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessToolMetadata
|
||||
} from "../src/types";
|
||||
|
||||
export default function ConformanceApp() {
|
||||
const location = useLocation();
|
||||
@@ -40,6 +86,68 @@ 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 />;
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/forms/public/")) {
|
||||
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname === "/forms-runtime") {
|
||||
return <FormsRuntimePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname.startsWith("/forms-runtime/")) {
|
||||
return <FormInstancePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
@@ -172,6 +280,68 @@ export default function ConformanceApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function WysiwygLifecycleScenario({ source }: { source: boolean }) {
|
||||
const initialHtml = source
|
||||
? '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>'
|
||||
: "<p>Legacy <i>template</i></p>";
|
||||
const [value, setValue] = useState(initialHtml);
|
||||
const [changeCount, setChangeCount] = useState(0);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [mounted, setMounted] = useState(true);
|
||||
|
||||
return <main className="conformance-root">
|
||||
<h1>Rich-text lifecycle fixture</h1>
|
||||
<Button onClick={() => setDisabled((current) => !current)}>Toggle read-only</Button>
|
||||
<Button onClick={() => setMounted((current) => !current)}>Toggle editor mount</Button>
|
||||
<Button onClick={() => setValue(initialHtml.replace("Legacy", "Reloaded"))}>Load another value</Button>
|
||||
<output data-testid="wysiwyg-change-count">{changeCount}</output>
|
||||
<pre data-testid="wysiwyg-controlled-value">{value}</pre>
|
||||
{mounted && <WysiwygEditor
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
ariaLabel="Rich-text fixture content"
|
||||
labels={{ visual: "Visual fixture mode", source: "Source fixture mode" }}
|
||||
onChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
setChangeCount((current) => current + 1);
|
||||
}}
|
||||
/>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function ProductNavigationScenario() {
|
||||
const projection = useMemo(
|
||||
() => projectProductNavigation(
|
||||
PRODUCT_NAV_ITEMS,
|
||||
PRODUCT_NAV_MODULES,
|
||||
PRODUCT_NAV_AUTH
|
||||
),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<div className="app-shell" data-conformance-id="product-navigation">
|
||||
<IconRail
|
||||
navItems={projection.primaryItems}
|
||||
allToolItems={projection.allToolItems}
|
||||
productAreas={PRODUCT_NAV_AREAS}
|
||||
/>
|
||||
<main className="main-area">
|
||||
<PageLayout
|
||||
archetype="overview"
|
||||
mode="embedded"
|
||||
title="Anwohnerparkausweis bearbeiten"
|
||||
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
|
||||
>
|
||||
<StatePanel
|
||||
title="Vorgang ist bereit"
|
||||
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
|
||||
/>
|
||||
</PageLayout>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpConformanceScenario() {
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="help-heading">
|
||||
@@ -290,6 +460,132 @@ const CONFORMANCE_AUTH = {
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"tasks:item:read",
|
||||
"calendar:event:read",
|
||||
"mail:mailbox:read",
|
||||
"postbox:message:read",
|
||||
"files:file:read"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
|
||||
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
|
||||
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
|
||||
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
|
||||
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
|
||||
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
|
||||
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
|
||||
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
|
||||
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
|
||||
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
|
||||
productModule("tasks", productSurface({
|
||||
id: "work.items",
|
||||
moduleId: "tasks",
|
||||
label: "i18n:govoplan-core.product_surface.work",
|
||||
description: "i18n:govoplan-core.product_surface.work_description",
|
||||
iconName: "list-checks",
|
||||
entryPath: "/work",
|
||||
routePath: "/tasks",
|
||||
surfaceIds: ["tasks.nav.tasks"],
|
||||
anyOf: ["tasks:item:read"]
|
||||
})),
|
||||
productModule("files", productSurface({
|
||||
id: "records.files",
|
||||
moduleId: "files",
|
||||
label: "i18n:govoplan-core.product_surface.files",
|
||||
description: "i18n:govoplan-core.product_surface.files_description",
|
||||
iconName: "folder",
|
||||
entryPath: "/documents",
|
||||
routePath: "/files",
|
||||
surfaceIds: ["files.nav.files"],
|
||||
anyOf: ["files:file:read"]
|
||||
})),
|
||||
productModule("mail", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "mail",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/mail",
|
||||
surfaceIds: ["mail.nav.mail"],
|
||||
anyOf: ["mail:mailbox:read"],
|
||||
aliases: ["/inbox"]
|
||||
})),
|
||||
productModule("postbox", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "postbox",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/postbox",
|
||||
surfaceIds: ["postbox.nav.postbox"],
|
||||
anyOf: ["postbox:message:read"],
|
||||
aliases: ["/inbox"],
|
||||
order: 20
|
||||
})),
|
||||
productModule("calendar", productSurface({
|
||||
id: "meetings.calendar",
|
||||
moduleId: "calendar",
|
||||
label: "i18n:govoplan-core.product_surface.calendar",
|
||||
description: "i18n:govoplan-core.product_surface.calendar_description",
|
||||
iconName: "calendar",
|
||||
entryPath: "/agenda",
|
||||
routePath: "/calendar",
|
||||
surfaceIds: ["calendar.nav.calendar"],
|
||||
anyOf: ["calendar:event:read"]
|
||||
}))
|
||||
];
|
||||
|
||||
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
|
||||
return { id, label: id, version: "test", productSurfaces: [surface] };
|
||||
}
|
||||
|
||||
function productSurface(
|
||||
partial: Pick<ProductSurfaceContribution,
|
||||
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
|
||||
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
|
||||
): ProductSurfaceContribution {
|
||||
return {
|
||||
contractVersion: "1",
|
||||
presentations: ["task", "reader"],
|
||||
capabilityIds: [],
|
||||
searchSourceIds: [],
|
||||
helpContextIds: [],
|
||||
documentationTopicIds: [],
|
||||
allOf: [],
|
||||
aliases: [],
|
||||
order: 10,
|
||||
unavailable: {
|
||||
reason: "authorization",
|
||||
title: "Not available",
|
||||
description: "The destination is not available for this responsibility.",
|
||||
resolution: "Ask the access administrator to review the assignment."
|
||||
},
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
const FORMS_RUNTIME_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"forms_runtime:submission:assist",
|
||||
"forms_runtime:submission:participate",
|
||||
"forms_runtime:workspace:read",
|
||||
"forms_runtime:workspace:write"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const CONFORMANCE_SETTINGS: ApiSettings = {
|
||||
apiBaseUrl: "",
|
||||
apiKey: "",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Exercise the real shared credential editor with Mail's optional capability.
|
||||
// API fixtures supply public metadata only; this never loads or saves secrets.
|
||||
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
|
||||
import type { ApiSettings, PlatformWebModule } from "../src/types";
|
||||
|
||||
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const modules: PlatformWebModule[] = [{
|
||||
id: "mail", label: "Mail", version: "1",
|
||||
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
|
||||
}];
|
||||
|
||||
export default function CredentialReferencesScenario() {
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
+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>,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user