Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6591aaa3fd | ||
|
|
dc1f244f17 | ||
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 | ||
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 |
@@ -33,6 +33,8 @@ Canonical policy documents live in `docs/`:
|
|||||||
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||||
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
||||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.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)
|
- [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||||
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.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
|
config = context.config
|
||||||
database_url = config.attributes.get("database_url") or settings.database_url
|
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:
|
if config.config_file_name is not None:
|
||||||
# Migrations can run inside the long-lived application process when module
|
# 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:create
|
||||||
system:tenants:update
|
system:tenants:update
|
||||||
system:tenants:suspend
|
system:tenants:suspend
|
||||||
|
system:tenants:erase
|
||||||
|
|
||||||
system:accounts:read
|
system:accounts:read
|
||||||
system:accounts:create
|
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
|
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||||
parser with different validation or secret-handling rules.
|
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:
|
The admin wizard backend starts with these routes:
|
||||||
|
|
||||||
- `GET /api/v1/admin/configuration-packages/catalog`
|
- `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
|
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||||
`workspace` mode and retain their own route-level help identity.
|
`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
|
Module routes, public routes, settings sections, and administration sections
|
||||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# DataGrid Sizing Contract
|
# DataGrid Sizing Contract
|
||||||
|
|
||||||
|
Auto-height grids reserve no empty vertical scrollbar gutter. The table fills
|
||||||
|
its card to the right edge; an actual constrained vertical scrollbar still
|
||||||
|
occupies its normal space. `Card bodyLayout="table"` provides an explicit
|
||||||
|
zero-inset surface, including with loading wrappers and padded notices. The
|
||||||
|
Organizations/IDM browser fixtures assert row geometry, not just outer shells.
|
||||||
|
|
||||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||||
its container has a measurable width. The same contract is used on initial
|
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
|
## Column Declarations
|
||||||
|
|
||||||
@@ -17,6 +23,54 @@ layout, container resize, persisted-layout restore, and pointer resize.
|
|||||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||||
that is necessary to keep the table flush with its container.
|
that is necessary to keep the table flush with its container.
|
||||||
|
- `columnType: "actions"` marks a custom action/control column. Canonical
|
||||||
|
`TableActionGroup` content is recognized automatically, even in existing
|
||||||
|
column declarations. Use `sticky: "end"` for the normal row-action surface.
|
||||||
|
|
||||||
|
## Action Visibility and Constrained Containers
|
||||||
|
|
||||||
|
Action tracks reserve the width of actual buttons, disabled-action wrappers,
|
||||||
|
reserved empty-state slots, gaps, and cell padding. A historic `width: 72`
|
||||||
|
preference therefore cannot clip a four-button action group. Ordinary data text
|
||||||
|
does not participate in this content measurement; long field values do not
|
||||||
|
silently widen all tracks. Changes to the rendered action set are remeasured.
|
||||||
|
|
||||||
|
`TableActionButton` remains a compact 36 px control, including the Add action
|
||||||
|
in an empty grid. Never stretch it with a last-column `.btn { width: 100% }`
|
||||||
|
rule. Its shared maximum width and fixed flex basis protect against broad
|
||||||
|
consumer button rules, which can otherwise feed stretched widths back into
|
||||||
|
action-track measurement and consume the data area.
|
||||||
|
|
||||||
|
When the full group needs more than half the scroll viewport, its measured
|
||||||
|
minimum is capped at half the viewport and the group wraps. Explicit hard
|
||||||
|
minima remain authoritative. Custom action groups should use wrapping-capable
|
||||||
|
flex layouts and semantic groups, preferably composing `TableActionGroup`.
|
||||||
|
|
||||||
|
The grid's physical width matches its pixel tracks, including horizontal
|
||||||
|
overflow, so right-sticky actions remain inside the correct scroll bounds.
|
||||||
|
If explicitly wide or persisted sticky tracks would obscure the readable data
|
||||||
|
area, horizontal stickiness is released until space returns. No columns or
|
||||||
|
actions are hidden: the labelled scroll region is focusable and supports native
|
||||||
|
keyboard scrolling. Vertical header stickiness remains available.
|
||||||
|
|
||||||
|
## Resizing Controls
|
||||||
|
|
||||||
|
Drag a resize handle with a mouse, pen, or touch pointer. Pointer capture keeps
|
||||||
|
the drag active when it leaves the handle. Escape or pointer cancellation
|
||||||
|
restores the layout before that drag; releasing the pointer commits it. Losing
|
||||||
|
window focus ends a drag without leaving the table stuck in resizing mode.
|
||||||
|
|
||||||
|
Each handle is a focusable vertical separator exposing its current and allowed
|
||||||
|
widths. Left/Right changes its width by 10 px; Shift+Left/Right uses 40 px. Enter
|
||||||
|
or a double-click resets that column's explicit override to the declared sizing
|
||||||
|
rules. Other columns retain their preferences, so cover/compensation constraints
|
||||||
|
still apply. These operations only change personal browser layout, never rows.
|
||||||
|
|
||||||
|
Deutsch: Spalten lassen sich mit Maus, Stift oder Touch ziehen. Escape verwirft
|
||||||
|
den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Breite
|
||||||
|
um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche
|
||||||
|
Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen;
|
||||||
|
breite Tabellen bleiben horizontal scrollbar.
|
||||||
|
|
||||||
## Layout Modes
|
## Layout Modes
|
||||||
|
|
||||||
@@ -39,7 +93,7 @@ the column remains stopped until the pointer crosses the same boundary again.
|
|||||||
Only the pixel layout resulting from an explicit user resize is persisted,
|
Only the pixel layout resulting from an explicit user resize is persisted,
|
||||||
together with the container width at which the user selected it.
|
together with the container width at which the user selected it.
|
||||||
Persisted widths are keyed by a signature containing column IDs, declared
|
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
|
behavior. A changed signature discards the old override and recomputes the
|
||||||
declared layout.
|
declared layout.
|
||||||
|
|
||||||
@@ -51,7 +105,10 @@ contracts, persisted tracks may shrink toward their hard minima. The layout
|
|||||||
retains only the amount of horizontal overflow deliberately created by the
|
retains only the amount of horizontal overflow deliberately created by the
|
||||||
user; an exact-cover layout therefore remains exact-cover at narrower widths.
|
user; an exact-cover layout therefore remains exact-cover at narrower widths.
|
||||||
Legacy snapshots from the former hard-pixel persistence contract are discarded
|
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
|
## Regression Matrix
|
||||||
|
|
||||||
@@ -72,3 +129,11 @@ once and recomputed from the declared column layout.
|
|||||||
|
|
||||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||||
shape and guards against reintroducing a synthetic buffer cell.
|
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
|
module retirement. For non-SQLite databases, configure deployment-specific
|
||||||
backup/restore hooks for the module installer.
|
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 Production Target
|
||||||
|
|
||||||
PostgreSQL is the primary development and production target. SQLite remains
|
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
|
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
|
## Module Install/Uninstall Operations
|
||||||
|
|
||||||
Use Admin > System > Modules for planning. The running API server validates and
|
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-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 |
|
| 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 |
|
| 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 |
|
| 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 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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
|
Files and Mail are the first two external consumers of the layered
|
||||||
server/credential/policy pattern. Their own repositories retain provider
|
server/credential/policy pattern. Their own repositories retain provider
|
||||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
|
|||||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||||
locales and reports `de` explicitly as the reference locale.
|
locales and reports `de` explicitly as the reference locale.
|
||||||
|
|
||||||
|
## Structured Documentation Localization
|
||||||
|
|
||||||
|
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||||
|
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||||
|
separate `structured_translation_version="1"` contract and provide a complete
|
||||||
|
same-shape value for each translated metadata key in
|
||||||
|
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||||
|
outcome, result and verification; reference fields; limitations, constraints,
|
||||||
|
consequences and consequence classes; and the other rendered explanation
|
||||||
|
fields declared by Core.
|
||||||
|
|
||||||
|
The registry rejects an unversioned translation, an unsupported contract
|
||||||
|
version, missing structured keys, changed object keys or list lengths, empty
|
||||||
|
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||||
|
permission scopes, and other technical leaves therefore remain structurally
|
||||||
|
bound to the source metadata. The Docs module overlays only a validated locale
|
||||||
|
at response time and reports the selected structured locale separately from the
|
||||||
|
title/body locale. Missing structured translations fall back to source content
|
||||||
|
and remain visible in public coverage until the owning module adopts the
|
||||||
|
contract.
|
||||||
|
|
||||||
## Help Resolution
|
## Help Resolution
|
||||||
|
|
||||||
Every focusable field and action receives a stable derived F1 identity from the
|
Every focusable field and action receives a stable derived F1 identity from the
|
||||||
@@ -54,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
|
|||||||
separate evidence and generic derived fallbacks remain in the richer-help
|
separate evidence and generic derived fallbacks remain in the richer-help
|
||||||
candidate queue.
|
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,
|
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:
|
not a list of controls on which F1 cannot work. It should prioritize:
|
||||||
|
|
||||||
@@ -71,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
|
|||||||
control. Module journeys should add their own exact high-risk mappings; they do
|
control. Module journeys should add their own exact high-risk mappings; they do
|
||||||
not need to reimplement the keyboard or dialog mechanics.
|
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
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -88,6 +128,8 @@ The check must report:
|
|||||||
- no duplicate stable IDs;
|
- no duplicate stable IDs;
|
||||||
- no undeclared public WebUI surface;
|
- no undeclared public WebUI surface;
|
||||||
- no stale runtime route or endpoint declaration.
|
- 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:
|
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
- bounded reference-option search provider contract
|
- bounded reference-option search provider contract
|
||||||
- single-tenant and optional batched tenant summary provider contracts
|
- single-tenant and optional batched tenant summary provider contracts
|
||||||
- tenant delete-veto provider contract
|
- 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
|
- WebUI module contribution contract
|
||||||
- navigation metadata contract
|
- navigation metadata contract
|
||||||
- command/event envelope 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
|
single-tenant path so it invokes every registered provider for the target
|
||||||
tenant, independent of ordinary list-page projections.
|
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
|
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
|
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
|
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
|
script with a reason, and removed when a capability/API/event contract replaces
|
||||||
it.
|
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
|
## Boundary Decision Register
|
||||||
|
|
||||||
These durable decisions close older exploratory core issues. Implementation
|
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
|
controlled deployment option rather than a replacement for release package
|
||||||
builds.
|
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
|
||||||
|
|
||||||
Maintenance mode is the required operating state for package install/uninstall
|
Maintenance mode is the required operating state for package install/uninstall
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Shared navigation layout contract
|
||||||
|
|
||||||
|
Core owns `NavigationPreferenceEditor`, ordered layout resolution, and rail
|
||||||
|
rendering. Admin, Tenancy, personal Settings and Views reuse this editor. They
|
||||||
|
own loading, authorization, Save, Reload and dirty-state guards; the editor
|
||||||
|
emits a draft only after a real edit. A drag onto the same position, keyboard
|
||||||
|
pickup/drop without movement, and opening inherited settings do not save or
|
||||||
|
create an override.
|
||||||
|
|
||||||
|
## Stored document and precedence
|
||||||
|
|
||||||
|
The version-1 navigation document retains `order`, `hidden` and `locked` and
|
||||||
|
adds optional `separators`, each containing a stable `separator:`-prefixed ID
|
||||||
|
and an optional plain-text label of at most 120 characters. Separator IDs and
|
||||||
|
module navigation IDs occupy the same `order` list. Separators are presentation
|
||||||
|
metadata and never become routes, modules, permissions or authorized surfaces.
|
||||||
|
|
||||||
|
Omitting `separators` or using null preserves inherited grouping. An explicit
|
||||||
|
empty array removes grouping. Resetting the entire navigation document to null
|
||||||
|
removes that scope's override. Existing order-only documents remain readable;
|
||||||
|
the editor materializes group markers into a draft only when edited. Unknown
|
||||||
|
optional-module order IDs remain stored when currently visible items move, so
|
||||||
|
uninstalling or temporarily disabling a module does not destroy its preference.
|
||||||
|
|
||||||
|
User order and visibility take precedence over tenant and system preferences.
|
||||||
|
System and tenant visibility locks accumulate; lower scopes cannot hide those
|
||||||
|
destinations, but may move them. Views may supply a navigation presentation
|
||||||
|
inside the already authorized and View-filtered destination set. An explicit
|
||||||
|
personal order/layout or visibility preference takes precedence over that
|
||||||
|
presentation, not over authorization or the View's surface restrictions.
|
||||||
|
Views cannot introduce locks. When multiple modules contribute one product
|
||||||
|
entry, it inherits the earliest effective rail position/section and all
|
||||||
|
authorized contributors' locks; this does not change operational route
|
||||||
|
selection. An owner alias in View layout refers to that composed entry.
|
||||||
|
|
||||||
|
## Interaction and reuse
|
||||||
|
|
||||||
|
Drag the handle to move either a module or separator before/after another row.
|
||||||
|
The handle also supports Space to pick up, arrow keys to move, Enter to drop,
|
||||||
|
and Escape to restore the pre-drag draft. Up/down buttons offer the same moves.
|
||||||
|
Add module restores an available hidden entry; Remove only hides navigation,
|
||||||
|
never uninstalls a module or deletes records. Add separator inserts a new
|
||||||
|
optional group label. Remove separator changes grouping only.
|
||||||
|
|
||||||
|
Expanded rails display group labels without divider lines. Collapsed rails
|
||||||
|
replace these labels with horizontal group dividers; empty groups are not
|
||||||
|
rendered. Both modes use the same resolved order.
|
||||||
|
The editor receives product-area metadata to show inherited grouping and uses
|
||||||
|
container-responsive rows rather than a fixed dialog/page width. Its English
|
||||||
|
and German labels load with the editor, not the initial shell bundle.
|
||||||
|
Give the ordered list a full-span `GridItem` when a settings page contains
|
||||||
|
multiple cards; do not squeeze the entire editor into an otherwise half-empty
|
||||||
|
two-column settings grid. Central spacing tokens provide real row padding and
|
||||||
|
separation at both wide and narrow sizes, covered by computed-style assertions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Core navigation unit tests and HTTP settings/profile tests cover persistence,
|
||||||
|
separator inheritance, explicit flat layouts, reset and locks. Module-capability
|
||||||
|
tests cover View aliases, composed destinations and personal precedence. The
|
||||||
|
browser conformance suite tests all four editor scopes, pointer/keyboard moves,
|
||||||
|
no-op cleanliness, unavailable-module preservation, collapsed dividers and a
|
||||||
|
German narrow read-only layout. Use the same shared component for future
|
||||||
|
navigation-definition surfaces rather than implementing another sortable list.
|
||||||
@@ -69,11 +69,61 @@ page or pane action bar.
|
|||||||
|
|
||||||
| Page kind | Leading group | Trailing group |
|
| Page kind | Leading group | Trailing group |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
|
| Overview | Context | Help, Reload when refreshable, then ordinary primary actions |
|
||||||
| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
|
| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right |
|
||||||
| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
|
| Detail | Object context | Help, Reload when refreshable, 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 |
|
| Editor | Context | Dirty state, Help, Reload if distinctly safe, 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 |
|
| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||||
|
|
||||||
|
Reload and Create belong to the same right-aligned group, in that order. A
|
||||||
|
collection-wide toolbar stays above its workspace, not inside the left tree or
|
||||||
|
conditionally inside an editor. Changing selection or opening an editor must
|
||||||
|
not remove it. Permission-blocked creation remains visible with an explanation.
|
||||||
|
On narrow screens the trailing group wraps while retaining right alignment and
|
||||||
|
the same DOM/keyboard order.
|
||||||
|
|
||||||
|
Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by
|
||||||
|
`LoadingFrame`. This removes body padding explicitly, without relying on the
|
||||||
|
number of children or negative margins. Place any meaningful explanation or
|
||||||
|
warning in a padded `ContentSection`; do not add a redundant tagline to every
|
||||||
|
table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments.
|
||||||
|
|
||||||
|
Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the
|
||||||
|
same checkbox body and Select all / Deselect all behavior. `null` means no
|
||||||
|
restriction, `[]` means no matches, and multiple values mean OR within a facet.
|
||||||
|
Apply remote filters before server pagination/limits and discard stale reads.
|
||||||
|
Do not replace this with rows of toggles or implement a second checkbox menu.
|
||||||
|
The dropdown's body portal escapes clipped containers. Inside Core dialogs it
|
||||||
|
joins the existing dialog stack: Tab/Shift+Tab stay in the filter, Space toggles
|
||||||
|
the focused checkbox, and Escape closes only the filter and restores its
|
||||||
|
trigger. Long option labels wrap without widening the popup.
|
||||||
|
|
||||||
|
Keep facet definitions, URL serialization and request cancellation in one owning
|
||||||
|
module adapter when the same search appears on a page and in an overlay. Do not
|
||||||
|
translate an explicit empty selection into an unrestricted backend query. Keep
|
||||||
|
legacy API meanings at the adapter boundary; retain scope and unrelated URL
|
||||||
|
parameters when clearing filters. A query, context, account or tenant change
|
||||||
|
invalidates both initial and cursor requests, including results still visible
|
||||||
|
during a debounce interval.
|
||||||
|
|
||||||
|
Explorer workspaces keep collection commands in a persistent header. Files
|
||||||
|
uses Reload, Create folder, then primary Upload; frequent selected-item actions
|
||||||
|
stay near the list. Group less-common selection and connection operations in
|
||||||
|
labelled domain dialogs using `Dialog`, `FormSection` and shared action bars,
|
||||||
|
with an explicit destructive section. Do not move an overloaded toolbar into
|
||||||
|
another ungrouped row. Mail's read-only workspace has one Reload for its current
|
||||||
|
profile, folder, index and preview; narrower refreshes belong in Mailbox tools.
|
||||||
|
Do not invent a New or Save button on a workspace that owns neither workflow.
|
||||||
|
Reload must not become import, synchronization, delivery or another mutation.
|
||||||
|
Explicit Reload reads must bypass short-lived client response reuse (for
|
||||||
|
example, pass `cache: "no-store"` through the owning read API), including each
|
||||||
|
page of a refreshed listing. Routine navigation may retain normal deduplication.
|
||||||
|
Conformance must observe a fresh request, not just an enabled Reload button.
|
||||||
|
|
||||||
|
Tree icons/disclosure controls expand and collapse; labels select. A module's
|
||||||
|
`ExplorerTree.onOpen` must not toggle expansion. Use occurrence-specific node
|
||||||
|
IDs when the same semantic record appears in multiple branches; selection and
|
||||||
|
ancestor expansion must follow the clicked occurrence, not every copy.
|
||||||
|
|
||||||
Reload means re-fetch or re-evaluate the current surface. A page declaring
|
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
|
`refreshable` must provide it, and a non-refreshable page must not use Reload as
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ package entry. Keep one committed full-product release lockfile at
|
|||||||
`webui/package.release.json` in a clean release workspace. Development
|
`webui/package.release.json` in a clean release workspace. Development
|
||||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
`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
|
Frontend module permutations are regression-tested through
|
||||||
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
||||||
committed lockfiles for every possible combination. If a smaller composition
|
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:
|
Canonical documentation:
|
||||||
|
|
||||||
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
- `/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
|
userinfo, row values, or unbounded remote error bodies. A Datasource origin
|
||||||
preserves this contract so registration and staging do not erase source mode,
|
preserves this contract so registration and staging do not erase source mode,
|
||||||
health, pushdown, or preview-limit evidence.
|
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
|
custom-override validation/application, and representative
|
||||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||||
production WebUI build.
|
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.
|
||||||
|
|||||||
@@ -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-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-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-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 |
|
| 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
|
## Confirmed Implementation Decisions
|
||||||
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
|
|||||||
not self-explanatory.
|
not self-explanatory.
|
||||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
`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
|
- Shared action-bearing components accept an optional disabled reason. In
|
||||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
|||||||
|
|
||||||
## Verification
|
## 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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core/webui
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
npm run build
|
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
|
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
|
entry closure consumes the initial budget, while an oversized page or module
|
||||||
descriptor consumes the asynchronous chunk budget.
|
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",
|
"release": "0.1.18",
|
||||||
"squash_policy": "reviewed-manual",
|
"squash_policy": "reviewed-manual",
|
||||||
"track": "release"
|
"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
|
"version": 1
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.36"
|
version = "0.1.46"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo):
|
|||||||
is_active: bool = True
|
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):
|
class NavigationPreferencesPayload(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel):
|
|||||||
order: list[str] = Field(default_factory=list, max_length=256)
|
order: list[str] = Field(default_factory=list, max_length=256)
|
||||||
hidden: 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)
|
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||||
|
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class AppearanceModeOverrides(BaseModel):
|
class AppearanceModeOverrides(BaseModel):
|
||||||
@@ -168,6 +176,8 @@ class UserInfo(BaseModel):
|
|||||||
tenant_display_name: str | None = None
|
tenant_display_name: str | None = None
|
||||||
is_tenant_admin: bool = False
|
is_tenant_admin: bool = False
|
||||||
password_reset_required: bool = False
|
password_reset_required: bool = False
|
||||||
|
required_auth_action: Literal["change_password"] | None = None
|
||||||
|
local_password: bool = False
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||||
@@ -182,6 +192,8 @@ class AuthSessionUserInfo(BaseModel):
|
|||||||
tenant_display_name: str | None = None
|
tenant_display_name: str | None = None
|
||||||
is_tenant_admin: bool = False
|
is_tenant_admin: bool = False
|
||||||
password_reset_required: bool = False
|
password_reset_required: bool = False
|
||||||
|
required_auth_action: Literal["change_password"] | None = None
|
||||||
|
local_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AuthSessionResponse(BaseModel):
|
class AuthSessionResponse(BaseModel):
|
||||||
|
|||||||
@@ -780,8 +780,9 @@ def send_email(self, job_id: str):
|
|||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
|
|
||||||
SMTP failures are persisted but are not retried implicitly. A worker-loss
|
SMTP failures are persisted but are not retried implicitly. A worker-loss
|
||||||
redelivery is safe because the delivery service converts an unfinished
|
redelivery leaves an active delivery claim unchanged instead of transmitting
|
||||||
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
|
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
|
from govoplan_core.db.session import get_database
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import heapq
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||||
@@ -436,10 +437,33 @@ def _merge_list(
|
|||||||
result_by_id[identity] = merged.value
|
result_by_id[identity] = merged.value
|
||||||
|
|
||||||
order_source = local_order if local_reordered and not current_reordered else current_order
|
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]
|
secondary_order = current_order if order_source is local_order else local_order
|
||||||
for identity in identities:
|
# Keep the chosen side's order and both sides' insertion anchors. Appending
|
||||||
if identity in result_by_id and identity not in merged_order:
|
# missing IDs would silently relocate an insertion during a disjoint edit.
|
||||||
merged_order.append(identity)
|
# 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(
|
return ThreeWayMergeResult(
|
||||||
value=[result_by_id[identity] for identity in merged_order],
|
value=[result_by_id[identity] for identity in merged_order],
|
||||||
conflicts=conflicts,
|
conflicts=conflicts,
|
||||||
|
|||||||
@@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _sanitize_value(key: str, value: object) -> object:
|
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)
|
field = classify_configuration_field(key)
|
||||||
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
||||||
return _redact_secrets(value)
|
return _redact_secrets(value)
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
|
|||||||
|
|
||||||
|
|
||||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
_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(
|
ConfigurationFieldSafety(
|
||||||
key="module_management.desired_enabled",
|
key="module_management.desired_enabled",
|
||||||
label="Enabled modules",
|
label="Enabled modules",
|
||||||
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
rollback_history_required=True,
|
rollback_history_required=True,
|
||||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
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(
|
ConfigurationFieldSafety(
|
||||||
key="privacy_retention_policy",
|
key="privacy_retention_policy",
|
||||||
label="Privacy retention policy",
|
label="Privacy retention policy",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from govoplan_core.core.tabular_sources import (
|
|||||||
DEFAULT_PREVIEW_BYTES,
|
DEFAULT_PREVIEW_BYTES,
|
||||||
DEFAULT_PREVIEW_TIMEOUT_MS,
|
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||||
TabularPreviewDiagnostic,
|
TabularPreviewDiagnostic,
|
||||||
|
TabularCsvSource,
|
||||||
TabularPushdown,
|
TabularPushdown,
|
||||||
TabularSourceHealth,
|
TabularSourceHealth,
|
||||||
TabularSourceMode,
|
TabularSourceMode,
|
||||||
@@ -369,6 +370,7 @@ class DatasourceStageInput:
|
|||||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
governance: DatasourceGovernance | None = None
|
governance: DatasourceGovernance | None = None
|
||||||
|
csv_source: TabularCsvSource | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||||
|
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
|
||||||
|
"infrastructure.dependency_inventory."
|
||||||
|
)
|
||||||
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||||
CAPABILITY_STATES = frozenset(
|
CAPABILITY_STATES = frozenset(
|
||||||
{
|
{
|
||||||
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
_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):
|
class InfrastructureCapabilityReceiptError(ValueError):
|
||||||
pass
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class InfrastructureCapability:
|
class InfrastructureCapability:
|
||||||
id: str
|
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:
|
def _capability(value: object) -> InfrastructureCapability:
|
||||||
if not isinstance(value, Mapping):
|
if not isinstance(value, Mapping):
|
||||||
raise InfrastructureCapabilityReceiptError(
|
raise InfrastructureCapabilityReceiptError(
|
||||||
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CAPABILITY_STATES",
|
"CAPABILITY_STATES",
|
||||||
"DEPLOYMENT_CAPABILITIES_ENV",
|
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||||
|
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
|
||||||
"InfrastructureCapability",
|
"InfrastructureCapability",
|
||||||
"InfrastructureCapabilityReceipt",
|
"InfrastructureCapabilityReceipt",
|
||||||
"InfrastructureCapabilityReceiptError",
|
"InfrastructureCapabilityReceiptError",
|
||||||
|
"InfrastructureDependency",
|
||||||
|
"InfrastructureDependencyInventory",
|
||||||
|
"InfrastructureDependencyProvider",
|
||||||
|
"InfrastructureDependencyProviderReport",
|
||||||
"InfrastructurePostInstallTask",
|
"InfrastructurePostInstallTask",
|
||||||
|
"collect_infrastructure_dependency_inventory",
|
||||||
"deployment_capability_status",
|
"deployment_capability_status",
|
||||||
"infrastructure_capability_receipt_from_mapping",
|
"infrastructure_capability_receipt_from_mapping",
|
||||||
"load_infrastructure_capability_receipt",
|
"load_infrastructure_capability_receipt",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
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 typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||||
|
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
PermissionLevel = Literal["system", "tenant"]
|
PermissionLevel = Literal["system", "tenant"]
|
||||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||||
@@ -114,6 +115,55 @@ class ProductAreaContribution:
|
|||||||
order: int = 100
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class QuickAccessTool:
|
class QuickAccessTool:
|
||||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||||
@@ -153,6 +203,7 @@ class FrontendModule:
|
|||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||||
|
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@@ -289,6 +340,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
|||||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
|
||||||
|
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"admin_explanation",
|
||||||
|
"consequence_classes",
|
||||||
|
"consequences",
|
||||||
|
"constraints",
|
||||||
|
"current_configuration",
|
||||||
|
"fields",
|
||||||
|
"limitations",
|
||||||
|
"operational_consequences",
|
||||||
|
"outcome",
|
||||||
|
"prerequisites",
|
||||||
|
"privacy_notes",
|
||||||
|
"purpose",
|
||||||
|
"result",
|
||||||
|
"steps",
|
||||||
|
"user_explanation",
|
||||||
|
"verification",
|
||||||
|
"when_used",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DocumentationLink:
|
class DocumentationLink:
|
||||||
label: str
|
label: str
|
||||||
@@ -324,12 +399,161 @@ class DocumentationTopic:
|
|||||||
configuration_keys: tuple[str, ...] = ()
|
configuration_keys: tuple[str, ...] = ()
|
||||||
i18n_key: str | None = None
|
i18n_key: str | None = None
|
||||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||||
|
structured_translation_version: str | None = None
|
||||||
|
structured_translations: Mapping[str, Mapping[str, Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
source_module_id: str | None = None
|
source_module_id: str | None = None
|
||||||
version_min: str | None = None
|
version_min: str | None = None
|
||||||
version_max_exclusive: str | None = None
|
version_max_exclusive: str | None = None
|
||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
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, ...]:
|
||||||
|
"""Return structured metadata keys whose values are public prose."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def localized_documentation_metadata(
|
||||||
|
topic: DocumentationTopic,
|
||||||
|
locale: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Overlay one validated structured translation onto source metadata."""
|
||||||
|
|
||||||
|
localized = dict(topic.metadata)
|
||||||
|
translation = topic.structured_translations.get(locale)
|
||||||
|
if translation:
|
||||||
|
localized.update(translation)
|
||||||
|
return localized
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_structured_translation_issues(
|
||||||
|
topic: DocumentationTopic,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Validate the opt-in, versioned structured-documentation translation."""
|
||||||
|
|
||||||
|
version = topic.structured_translation_version
|
||||||
|
translations = topic.structured_translations
|
||||||
|
if version is None:
|
||||||
|
if translations:
|
||||||
|
return (
|
||||||
|
"structured_translations require structured_translation_version",
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
|
||||||
|
return (
|
||||||
|
"unsupported structured_translation_version "
|
||||||
|
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
localizable_keys = set(localizable_documentation_metadata_keys(topic))
|
||||||
|
issues: list[str] = []
|
||||||
|
for locale, translation in translations.items():
|
||||||
|
if not locale.strip():
|
||||||
|
issues.append("structured translation locale must not be empty")
|
||||||
|
continue
|
||||||
|
translated_keys = set(translation)
|
||||||
|
for key in sorted(translated_keys - localizable_keys):
|
||||||
|
issues.append(
|
||||||
|
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
|
||||||
|
)
|
||||||
|
for key in sorted(localizable_keys - translated_keys):
|
||||||
|
issues.append(
|
||||||
|
f"structured translation {locale!r} is missing metadata key {key!r}"
|
||||||
|
)
|
||||||
|
for key in sorted(localizable_keys & translated_keys):
|
||||||
|
issues.extend(
|
||||||
|
_structured_translation_shape_issues(
|
||||||
|
topic.metadata[key],
|
||||||
|
translation[key],
|
||||||
|
path=f"{locale}.{key}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_translation_shape_issues(
|
||||||
|
source: object,
|
||||||
|
translated: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if isinstance(source, str):
|
||||||
|
if not isinstance(translated, str) or not translated.strip():
|
||||||
|
return (f"structured translation {path} must be a non-empty string",)
|
||||||
|
return ()
|
||||||
|
if isinstance(source, Mapping):
|
||||||
|
if not isinstance(translated, Mapping):
|
||||||
|
return (f"structured translation {path} must preserve object shape",)
|
||||||
|
issues: list[str] = []
|
||||||
|
source_keys = {str(key) for key in source}
|
||||||
|
translated_keys = {str(key) for key in translated}
|
||||||
|
if source_keys != translated_keys:
|
||||||
|
issues.append(
|
||||||
|
f"structured translation {path} must preserve object keys"
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
for key, value in source.items():
|
||||||
|
issues.extend(
|
||||||
|
_structured_translation_shape_issues(
|
||||||
|
value,
|
||||||
|
translated[key],
|
||||||
|
path=f"{path}.{key}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
if isinstance(source, Sequence) and not isinstance(
|
||||||
|
source, (str, bytes, bytearray)
|
||||||
|
):
|
||||||
|
if not isinstance(translated, Sequence) or isinstance(
|
||||||
|
translated, (str, bytes, bytearray)
|
||||||
|
):
|
||||||
|
return (f"structured translation {path} must preserve list shape",)
|
||||||
|
if len(source) != len(translated):
|
||||||
|
return (f"structured translation {path} must preserve list length",)
|
||||||
|
issues: list[str] = []
|
||||||
|
for index, (source_item, translated_item) in enumerate(
|
||||||
|
zip(source, translated, strict=True)
|
||||||
|
):
|
||||||
|
issues.extend(
|
||||||
|
_structured_translation_shape_issues(
|
||||||
|
source_item,
|
||||||
|
translated_item,
|
||||||
|
path=f"{path}[{index}]",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
if translated != source:
|
||||||
|
return (
|
||||||
|
f"structured translation {path} must preserve non-text value {source!r}",
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||||
|
|
||||||
@@ -533,3 +757,53 @@ class ModuleManifest:
|
|||||||
# runtime module ID changes.
|
# runtime module ID changes.
|
||||||
permission_namespace: str | None = None
|
permission_namespace: str | None = None
|
||||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
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
|
_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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class NavigationPreferences:
|
class NavigationPreferences:
|
||||||
order: tuple[str, ...] = ()
|
order: tuple[str, ...] = ()
|
||||||
hidden: tuple[str, ...] = ()
|
hidden: tuple[str, ...] = ()
|
||||||
locked: 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]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
@@ -20,6 +31,7 @@ class NavigationPreferences:
|
|||||||
"order": list(self.order),
|
"order": list(self.order),
|
||||||
"hidden": list(self.hidden),
|
"hidden": list(self.hidden),
|
||||||
"locked": list(self.locked),
|
"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
|
order_source: str
|
||||||
visibility_source: str
|
visibility_source: str
|
||||||
lock_source: str | None = None
|
lock_source: str | None = None
|
||||||
|
section: NavigationSeparator | None = None
|
||||||
|
custom_layout: bool = False
|
||||||
|
layout_source: str = "module"
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
@@ -42,6 +57,9 @@ class EffectiveNavigationItem:
|
|||||||
"navigation_order_source": self.order_source,
|
"navigation_order_source": self.order_source,
|
||||||
"navigation_visibility_source": self.visibility_source,
|
"navigation_visibility_source": self.visibility_source,
|
||||||
"navigation_lock_source": self.lock_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")),
|
order=_ids(raw.get("order")),
|
||||||
hidden=_ids(raw.get("hidden")),
|
hidden=_ids(raw.get("hidden")),
|
||||||
locked=_ids(raw.get("locked")),
|
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 = {item_id: True for item_id in ordered}
|
||||||
visibility_source = {item_id: "module" for item_id in ordered}
|
visibility_source = {item_id: "module" for item_id in ordered}
|
||||||
locks: dict[str, str] = {}
|
locks: dict[str, str] = {}
|
||||||
|
separators: dict[str, NavigationSeparator] = {}
|
||||||
|
custom_layout = False
|
||||||
|
layout_source = "module"
|
||||||
|
|
||||||
for source, preferences, may_lock in (
|
for source, preferences, may_lock in (
|
||||||
("system", system, True),
|
("system", system, True),
|
||||||
@@ -103,7 +125,13 @@ def resolve_navigation_preferences(
|
|||||||
):
|
):
|
||||||
if preferences is None:
|
if preferences is None:
|
||||||
continue
|
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:
|
if requested_order:
|
||||||
requested = set(requested_order)
|
requested = set(requested_order)
|
||||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
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[item_id] = True
|
||||||
visibility_source[item_id] = source
|
visibility_source[item_id] = source
|
||||||
|
|
||||||
return {
|
result: dict[str, EffectiveNavigationItem] = {}
|
||||||
item_id: 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,
|
id=item_id,
|
||||||
order=index,
|
order=index,
|
||||||
visible=visibility[item_id],
|
visible=visibility[item_id],
|
||||||
@@ -136,9 +169,29 @@ def resolve_navigation_preferences(
|
|||||||
order_source=order_source[item_id],
|
order_source=order_source[item_id],
|
||||||
visibility_source=visibility_source[item_id],
|
visibility_source=visibility_source[item_id],
|
||||||
lock_source=locks.get(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, ...]:
|
def _ids(value: object) -> tuple[str, ...]:
|
||||||
@@ -168,6 +221,7 @@ __all__ = [
|
|||||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||||
"NAVIGATION_PREFERENCES_KEY",
|
"NAVIGATION_PREFERENCES_KEY",
|
||||||
"NavigationPreferences",
|
"NavigationPreferences",
|
||||||
|
"NavigationSeparator",
|
||||||
"navigation_preferences_from_mapping",
|
"navigation_preferences_from_mapping",
|
||||||
"navigation_preferences_from_settings",
|
"navigation_preferences_from_settings",
|
||||||
"resolve_navigation_preferences",
|
"resolve_navigation_preferences",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
|
|||||||
"navigation",
|
"navigation",
|
||||||
"permission",
|
"permission",
|
||||||
"product_area",
|
"product_area",
|
||||||
|
"product_surface",
|
||||||
"provided_interface",
|
"provided_interface",
|
||||||
"public_route",
|
"public_route",
|
||||||
"search_provider",
|
"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:
|
for tool in frontend.quick_access_tools:
|
||||||
declarations.append(
|
declarations.append(
|
||||||
PlatformInterfaceDeclaration(
|
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,16 +16,20 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
PublicFrontendRoute,
|
PublicFrontendRoute,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
|
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
|
||||||
TenantSummaryBatchProvider,
|
TenantSummaryBatchProvider,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
|
documentation_structured_translation_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_entitlements import (
|
from govoplan_core.core.module_entitlements import (
|
||||||
TenantModuleEntitlementResolver,
|
TenantModuleEntitlementResolver,
|
||||||
@@ -89,6 +93,9 @@ _WILDCARD_RE = re.compile(
|
|||||||
)
|
)
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_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}$")
|
_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(
|
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||||
)
|
)
|
||||||
@@ -962,6 +969,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
)
|
)
|
||||||
|
for issue in documentation_structured_translation_issues(topic):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
)
|
||||||
_validate_documentation_extensions(manifest)
|
_validate_documentation_extensions(manifest)
|
||||||
_validate_architecture_declarations(manifest)
|
_validate_architecture_declarations(manifest)
|
||||||
_validate_workflow_definition_contributions(manifest)
|
_validate_workflow_definition_contributions(manifest)
|
||||||
@@ -969,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
|
|
||||||
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
area_definitions: dict[str, tuple[str, str]] = {}
|
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] = {}
|
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:
|
for manifest in manifests:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
@@ -982,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
|
|||||||
f"Product area {area.id!r} has conflicting labels or icons"
|
f"Product area {area.id!r} has conflicting labels or icons"
|
||||||
)
|
)
|
||||||
area_definitions[area.id] = definition
|
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:
|
for tool in frontend.quick_access_tools:
|
||||||
previous_owner = tool_owners.get(tool.id)
|
previous_owner = tool_owners.get(tool.id)
|
||||||
if previous_owner is not None:
|
if previous_owner is not None:
|
||||||
@@ -1469,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
|||||||
f"in module {manifest.id!r}"
|
f"in module {manifest.id!r}"
|
||||||
)
|
)
|
||||||
seen_area_memberships.add(membership)
|
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()
|
seen_tools: set[str] = set()
|
||||||
for tool in frontend.quick_access_tools:
|
for tool in frontend.quick_access_tools:
|
||||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||||
@@ -1507,6 +1565,151 @@ def _validate_product_area(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_product_surface(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
surface: ProductSurfaceContribution,
|
||||||
|
known_surface_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
module_id = manifest.id
|
||||||
|
frontend = manifest.frontend
|
||||||
|
assert frontend is not None
|
||||||
|
if surface.module_id != module_id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||||
|
f"not module {module_id!r}"
|
||||||
|
)
|
||||||
|
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
|
||||||
|
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
|
||||||
|
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} uses unsupported contract version "
|
||||||
|
f"{surface.contract_version!r}"
|
||||||
|
)
|
||||||
|
if not surface.label.strip() or not surface.icon.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} needs a label and icon"
|
||||||
|
)
|
||||||
|
for label, path in (
|
||||||
|
("entry", surface.entry_path),
|
||||||
|
("owner", surface.route_path),
|
||||||
|
*(("alias", alias) for alias in surface.aliases),
|
||||||
|
):
|
||||||
|
if not path.startswith("/") or "?" in path or "#" in path:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
surface.entry_path == surface.route_path
|
||||||
|
or surface.entry_path in surface.aliases
|
||||||
|
or surface.route_path in surface.aliases
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
|
||||||
|
)
|
||||||
|
if len(set(surface.aliases)) != len(surface.aliases):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} contains duplicate aliases"
|
||||||
|
)
|
||||||
|
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
|
||||||
|
if surface.route_path not in route_paths:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references unknown owner route "
|
||||||
|
f"{surface.route_path!r}"
|
||||||
|
)
|
||||||
|
if not surface.surface_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} has no owner surfaces"
|
||||||
|
)
|
||||||
|
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
|
||||||
|
if unknown_surfaces:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references unknown surfaces: "
|
||||||
|
+ ", ".join(sorted(unknown_surfaces))
|
||||||
|
)
|
||||||
|
allowed_presentations = {"task", "reader", "admin", "operator"}
|
||||||
|
if (
|
||||||
|
not surface.presentations
|
||||||
|
or len(set(surface.presentations)) != len(surface.presentations)
|
||||||
|
or set(surface.presentations) - allowed_presentations
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} has invalid presentations"
|
||||||
|
)
|
||||||
|
declared_capabilities = {
|
||||||
|
*manifest.required_capabilities,
|
||||||
|
*manifest.optional_capabilities,
|
||||||
|
*manifest.capability_factories,
|
||||||
|
*(provider.name for provider in manifest.provides_interfaces),
|
||||||
|
*(requirement.name for requirement in manifest.requires_interfaces),
|
||||||
|
}
|
||||||
|
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
|
||||||
|
if unknown_capabilities:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references undeclared capabilities: "
|
||||||
|
+ ", ".join(sorted(unknown_capabilities))
|
||||||
|
)
|
||||||
|
search_source_ids = {source.id for source in manifest.search_sources}
|
||||||
|
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
|
||||||
|
if unknown_search_sources:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references unknown search sources: "
|
||||||
|
+ ", ".join(sorted(unknown_search_sources))
|
||||||
|
)
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
|
||||||
|
if unknown_topics:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references unknown documentation topics: "
|
||||||
|
+ ", ".join(sorted(unknown_topics))
|
||||||
|
)
|
||||||
|
documented_help_contexts: set[str] = set()
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
contexts = topic.metadata.get("help_contexts", ())
|
||||||
|
if isinstance(contexts, (list, tuple, set, frozenset)):
|
||||||
|
documented_help_contexts.update(
|
||||||
|
context for context in contexts if isinstance(context, str)
|
||||||
|
)
|
||||||
|
unknown_help = set(surface.help_context_ids) - documented_help_contexts
|
||||||
|
if unknown_help:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface.id!r} references undocumented help contexts: "
|
||||||
|
+ ", ".join(sorted(unknown_help))
|
||||||
|
)
|
||||||
|
_validate_product_availability_explanation(surface.id, surface.unavailable)
|
||||||
|
if surface.degraded is not None:
|
||||||
|
_validate_product_availability_explanation(surface.id, surface.degraded)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_product_availability_explanation(
|
||||||
|
surface_id: str,
|
||||||
|
explanation: ProductAvailabilityExplanation,
|
||||||
|
) -> None:
|
||||||
|
allowed_reasons = {
|
||||||
|
"authorization",
|
||||||
|
"policy",
|
||||||
|
"configuration",
|
||||||
|
"disabled",
|
||||||
|
"capability",
|
||||||
|
"offline",
|
||||||
|
"provider_degraded",
|
||||||
|
}
|
||||||
|
if explanation.reason not in allowed_reasons:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface_id!r} has an invalid availability reason"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
not value.strip()
|
||||||
|
for value in (
|
||||||
|
explanation.title,
|
||||||
|
explanation.description,
|
||||||
|
explanation.resolution,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product surface {surface_id!r} has an incomplete availability explanation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_quick_access_tool(
|
def _validate_quick_access_tool(
|
||||||
module_id: str,
|
module_id: str,
|
||||||
tool: QuickAccessTool,
|
tool: QuickAccessTool,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +21,77 @@ DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
|
|||||||
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
|
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
|
||||||
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
|
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
|
||||||
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
|
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):
|
class TabularSourceError(ValueError):
|
||||||
@@ -39,29 +114,44 @@ class TabularSourceUnavailableError(TabularSourceError):
|
|||||||
pass
|
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(
|
def parse_tabular_csv(
|
||||||
csv_text: str,
|
csv_text: str,
|
||||||
*,
|
*,
|
||||||
delimiter: str = ",",
|
delimiter: str = ",",
|
||||||
max_rows: int = 10_000,
|
max_rows: int = 10_000,
|
||||||
|
max_bytes: int = 5_000_000,
|
||||||
|
value_mode: CsvValueMode = "legacy_typed",
|
||||||
) -> tuple[Mapping[str, object], ...]:
|
) -> 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:
|
if len(delimiter) != 1:
|
||||||
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
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:
|
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)
|
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||||
rows: list[dict[str, object]] = []
|
rows: list[dict[str, object]] = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
_validate_csv_row_shape(row)
|
_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
|
continue
|
||||||
if len(rows) >= max_rows:
|
if len(rows) >= max_rows:
|
||||||
raise TabularSourceValidationError(
|
raise TabularSourceValidationError(
|
||||||
f"CSV snapshots are limited to {max_rows:,} rows."
|
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)
|
return tuple(rows)
|
||||||
except csv.Error as exc:
|
except csv.Error as exc:
|
||||||
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from 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],
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
original_headers: Sequence[str],
|
original_headers: Sequence[str],
|
||||||
normalized_headers: Sequence[str],
|
normalized_headers: Sequence[str],
|
||||||
|
*,
|
||||||
|
value_mode: CsvValueMode = "legacy_typed",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
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(
|
for original, normalized in zip(
|
||||||
original_headers,
|
original_headers,
|
||||||
normalized_headers,
|
normalized_headers,
|
||||||
@@ -128,9 +220,15 @@ def _csv_scalar(value: str | None) -> object:
|
|||||||
if lowered in {"true", "false"}:
|
if lowered in {"true", "false"}:
|
||||||
return lowered == "true"
|
return lowered == "true"
|
||||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
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):
|
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
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +239,48 @@ class TabularColumn:
|
|||||||
nullable: bool = True
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TabularPushdown:
|
class TabularPushdown:
|
||||||
projections: bool = False
|
projections: bool = False
|
||||||
@@ -221,6 +361,7 @@ class TabularSnapshotInput:
|
|||||||
rows: tuple[Mapping[str, object], ...]
|
rows: tuple[Mapping[str, object], ...]
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
csv_source: TabularCsvSource | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -296,6 +437,10 @@ __all__ = [
|
|||||||
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||||
"DEFAULT_PREVIEW_BYTES",
|
"DEFAULT_PREVIEW_BYTES",
|
||||||
"DEFAULT_PREVIEW_TIMEOUT_MS",
|
"DEFAULT_PREVIEW_TIMEOUT_MS",
|
||||||
|
"CsvValueMode",
|
||||||
|
"TabularCsvSource",
|
||||||
|
"tabular_type_name",
|
||||||
|
"infer_tabular_schema",
|
||||||
"TabularColumn",
|
"TabularColumn",
|
||||||
"TabularPreviewDiagnostic",
|
"TabularPreviewDiagnostic",
|
||||||
"TabularPushdown",
|
"TabularPushdown",
|
||||||
@@ -313,6 +458,10 @@ __all__ = [
|
|||||||
"TabularSourceUnavailableError",
|
"TabularSourceUnavailableError",
|
||||||
"TabularSourceValidationError",
|
"TabularSourceValidationError",
|
||||||
"parse_tabular_csv",
|
"parse_tabular_csv",
|
||||||
|
"csv_source_payload",
|
||||||
|
"csv_source_summary",
|
||||||
|
"verified_csv_source_text",
|
||||||
|
"csv_projection_matches",
|
||||||
"tabular_snapshot_writer",
|
"tabular_snapshot_writer",
|
||||||
"tabular_source_provider",
|
"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:create": "access:tenant:create",
|
||||||
"system:tenants:update": "access:tenant:update",
|
"system:tenants:update": "access:tenant:update",
|
||||||
"system:tenants:suspend": "access:tenant:suspend",
|
"system:tenants:suspend": "access:tenant:suspend",
|
||||||
|
"system:tenants:erase": "access:tenant:erase",
|
||||||
"system:accounts:read": "access:account:read",
|
"system:accounts:read": "access:account:read",
|
||||||
"system:accounts:create": "access:account:create",
|
"system:accounts:create": "access:account:create",
|
||||||
"system:accounts:update": "access:account:update",
|
"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: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: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: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: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:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
JSON_CACHE_CONTROL = "private, no-cache"
|
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 = (
|
JSON_ETAG_VARY_HEADERS = (
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Cookie",
|
"Cookie",
|
||||||
@@ -26,15 +29,34 @@ async def conditional_json_get_middleware(
|
|||||||
|
|
||||||
The middleware deliberately works after route handling. That keeps the
|
The middleware deliberately works after route handling. That keeps the
|
||||||
contract platform-wide without requiring every module router to learn about
|
contract platform-wide without requiring every module router to learn about
|
||||||
conditional requests, while still limiting buffering to successful JSON GET
|
conditional requests. Only small successful JSON responses are buffered;
|
||||||
responses.
|
larger responses stream unchanged. Authorization still runs on every GET.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if not _eligible_for_conditional_json_get(request, response):
|
if not _eligible_for_conditional_json_get(request, response):
|
||||||
return 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)
|
etag = response.headers.get("etag") or json_response_etag(body)
|
||||||
headers = dict(response.headers)
|
headers = dict(response.headers)
|
||||||
headers["etag"] = etag
|
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)
|
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:
|
def json_response_etag(body: bytes) -> str:
|
||||||
digest = hashlib.sha256(body).hexdigest()
|
digest = hashlib.sha256(body).hexdigest()
|
||||||
return f'W/"sha256-{digest}"'
|
return f'W/"sha256-{digest}"'
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
PublicFrontendRoute,
|
PublicFrontendRoute,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||||
@@ -198,6 +200,9 @@ def _nav_item_payload(
|
|||||||
"visible": scoped.visible,
|
"visible": scoped.visible,
|
||||||
"locked": scoped.locked,
|
"locked": scoped.locked,
|
||||||
"lock_source": scoped.lock_source,
|
"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")
|
for scope in ("module", "system", "tenant")
|
||||||
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
|
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]:
|
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"id": tool.id,
|
"id": tool.id,
|
||||||
@@ -372,6 +418,10 @@ def _frontend_payload(
|
|||||||
"product_areas": [
|
"product_areas": [
|
||||||
_product_area_payload(area) for area in frontend.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_tools": [
|
||||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ class Settings(BaseSettings):
|
|||||||
le=1000,
|
le=1000,
|
||||||
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
|
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(
|
module_live_apply_enabled: bool | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||||
@@ -164,6 +170,9 @@ class Settings(BaseSettings):
|
|||||||
ge=60,
|
ge=60,
|
||||||
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
|
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_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")
|
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",
|
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||||
)
|
)
|
||||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
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(
|
auth_login_throttle_identity_limit: int = Field(
|
||||||
default=10,
|
default=10,
|
||||||
ge=1,
|
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 time
|
||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pyzipper
|
import pyzipper
|
||||||
@@ -3545,13 +3546,17 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
||||||
job_summary = jobs.json()["jobs"][0]
|
job_summary = jobs.json()["jobs"][0]
|
||||||
self.assertEqual(job_summary["campaign_version_id"], version_id)
|
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(
|
detail = self.client.get(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
|
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
self.assertEqual(detail.status_code, 200, detail.text)
|
self.assertEqual(detail.status_code, 200, detail.text)
|
||||||
job = detail.json()["job"]
|
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(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
@@ -4613,7 +4618,9 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
|
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.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:
|
with SessionLocal() as session:
|
||||||
jobs = (
|
jobs = (
|
||||||
@@ -4645,7 +4652,42 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False)
|
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(
|
retry_unknown = self.client.post(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
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()["total"], 2)
|
||||||
self.assertEqual(page.json()["pages"], 2)
|
self.assertEqual(page.json()["pages"], 2)
|
||||||
self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1)
|
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(
|
filtered_page = self.client.get(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs",
|
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()["total_unfiltered"], 1)
|
||||||
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
|
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
|
||||||
self.assertIn("reviewed", first_jobs.json()["jobs"][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(
|
first_csv = self.client.get(
|
||||||
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
|
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)
|
visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id)
|
||||||
self.assertIsNotNone(visible_key["revoked_at"])
|
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:
|
def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
|
||||||
@@ -6860,6 +6934,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||||
"hidden": ["mail.navigation.mail"],
|
"hidden": ["mail.navigation.mail"],
|
||||||
"locked": [],
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
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.responses import PlainTextResponse
|
||||||
from fastapi.testclient import TestClient
|
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.auth import get_api_principal
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
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):
|
class ConditionalRequestTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from govoplan_core.core.modules import (
|
|||||||
DocumentationSourceDefinition,
|
DocumentationSourceDefinition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
localized_documentation_metadata,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
|
with_documentation_structured_translations,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
|
||||||
@@ -83,6 +85,97 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||||
|
|
||||||
|
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
|
||||||
|
topic = DocumentationTopic(
|
||||||
|
id="example.workflow.localized",
|
||||||
|
title="Run task",
|
||||||
|
summary="Run the task.",
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"steps": ["Review", "Execute"],
|
||||||
|
"verification": "Confirm the result.",
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"steps": ["Prüfen", "Ausführen"],
|
||||||
|
"verification": "Das Ergebnis bestätigen.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
registry_for(topic).validate()
|
||||||
|
self.assertEqual(
|
||||||
|
["Prüfen", "Ausführen"],
|
||||||
|
localized_documentation_metadata(topic, "de")["steps"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow", localized_documentation_metadata(topic, "de")["kind"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
|
||||||
|
missing_version = DocumentationTopic(
|
||||||
|
id="example.localized.missing-version",
|
||||||
|
title="Localized",
|
||||||
|
summary="Invalid contract.",
|
||||||
|
metadata={"limitations": ["One", "Two"]},
|
||||||
|
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RegistryError, "require structured_translation_version"
|
||||||
|
):
|
||||||
|
registry_for(missing_version).validate()
|
||||||
|
|
||||||
|
incomplete_shape = DocumentationTopic(
|
||||||
|
id="example.localized.incomplete",
|
||||||
|
title="Localized",
|
||||||
|
summary="Invalid shape.",
|
||||||
|
metadata={"limitations": ["One", "Two"]},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={"de": {"limitations": ["Eins"]}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||||
|
registry_for(incomplete_shape).validate()
|
||||||
|
|
||||||
|
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
|
||||||
|
topic = DocumentationTopic(
|
||||||
|
id="example.workflow.localized",
|
||||||
|
title="Run task",
|
||||||
|
summary="Run the task.",
|
||||||
|
metadata={"steps": ["Review", "Execute"]},
|
||||||
|
)
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
documentation=(topic,),
|
||||||
|
)
|
||||||
|
|
||||||
|
localized = with_documentation_structured_translations(
|
||||||
|
manifest,
|
||||||
|
locale="de",
|
||||||
|
translations={
|
||||||
|
topic.id: {"steps": ["Prüfen", "Ausführen"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["Prüfen", "Ausführen"],
|
||||||
|
localized.documentation[0].structured_translations["de"]["steps"],
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
|
||||||
|
with_documentation_structured_translations(
|
||||||
|
manifest,
|
||||||
|
locale="de",
|
||||||
|
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "preserve list length"):
|
||||||
|
with_documentation_structured_translations(
|
||||||
|
manifest,
|
||||||
|
locale="de",
|
||||||
|
translations={topic.id: {"steps": ["Prüfen"]}},
|
||||||
|
)
|
||||||
|
|
||||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||||
resolver = lambda _context, keys: { # noqa: E731
|
resolver = lambda _context, keys: { # noqa: E731
|
||||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -9,12 +10,53 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from govoplan_core.core.infrastructure_capabilities import (
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
InfrastructureCapabilityReceiptError,
|
InfrastructureCapabilityReceiptError,
|
||||||
|
InfrastructureDependency,
|
||||||
|
collect_infrastructure_dependency_inventory,
|
||||||
deployment_capability_status,
|
deployment_capability_status,
|
||||||
infrastructure_capability_receipt_from_mapping,
|
infrastructure_capability_receipt_from_mapping,
|
||||||
load_infrastructure_capability_receipt,
|
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]:
|
def _receipt_payload() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
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:
|
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||||
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
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(["access:membership:read"], "admin:users:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership: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: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(["system:*"], "access:tenant:read"))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
scopes_grant_compatible(
|
scopes_grant_compatible(
|
||||||
@@ -1015,8 +1027,10 @@ finally:
|
|||||||
json={"mode": "destroy", "reason": "not supported"},
|
json={"mode": "destroy", "reason": "not supported"},
|
||||||
)
|
)
|
||||||
self.assertEqual(409, destructive.status_code, destructive.text)
|
self.assertEqual(409, destructive.status_code, destructive.text)
|
||||||
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
self.assertIn(
|
||||||
self.assertIn("tenant_data_present", issue_codes)
|
"Direct destructive deletion is disabled",
|
||||||
|
destructive.json()["detail"]["message"],
|
||||||
|
)
|
||||||
|
|
||||||
with database.session() as session:
|
with database.session() as session:
|
||||||
empty_tenant = Tenant(
|
empty_tenant = Tenant(
|
||||||
@@ -1029,15 +1043,43 @@ finally:
|
|||||||
session.commit()
|
session.commit()
|
||||||
empty_tenant_id = empty_tenant.id
|
empty_tenant_id = empty_tenant.id
|
||||||
|
|
||||||
destroyed = client.request(
|
erasure_policy = client.patch(
|
||||||
"DELETE",
|
"/api/v1/admin/tenant-erasure-policy",
|
||||||
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
|
||||||
headers=headers,
|
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(200, erasure_policy.status_code, erasure_policy.text)
|
||||||
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
erasure_preview = client.post(
|
||||||
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
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(
|
retired = client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import unittest
|
|||||||
|
|
||||||
from govoplan_core.core.navigation import (
|
from govoplan_core.core.navigation import (
|
||||||
NavigationPreferences,
|
NavigationPreferences,
|
||||||
|
NavigationSeparator,
|
||||||
navigation_preferences_from_settings,
|
navigation_preferences_from_settings,
|
||||||
resolve_navigation_preferences,
|
resolve_navigation_preferences,
|
||||||
update_navigation_preferences,
|
update_navigation_preferences,
|
||||||
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
|
|||||||
|
|
||||||
|
|
||||||
class NavigationPreferenceTests(unittest.TestCase):
|
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:
|
def test_user_order_overrides_tenant_and_system_order(self) -> None:
|
||||||
resolved = resolve_navigation_preferences(
|
resolved = resolve_navigation_preferences(
|
||||||
("dashboard", "files", "mail", "campaign"),
|
("dashboard", "files", "mail", "campaign"),
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ class _RevisionFixture(Base):
|
|||||||
|
|
||||||
|
|
||||||
class OptimisticConcurrencyTests(unittest.TestCase):
|
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:
|
def test_strong_etags_and_if_match_use_strong_comparison(self) -> None:
|
||||||
etag = strong_resource_etag("campaign_version", "version-1", 3)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
|
|||||||
surface_ids=("example.route.main",),
|
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=(
|
quick_access_tools=(
|
||||||
QuickAccessTool(
|
QuickAccessTool(
|
||||||
id="example.summary",
|
id="example.summary",
|
||||||
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
|
|||||||
frontend = response.json()["modules"][0]["frontend"]
|
frontend = response.json()["modules"][0]["frontend"]
|
||||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
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("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
|
|||||||
frontend["quick_access_tools"][0]["help_context_id"],
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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):
|
with self.assertRaises(TabularSourceValidationError):
|
||||||
parse_tabular_csv("id,name\n1,Ada,extra\n")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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": {
|
"initialJs": {
|
||||||
"rawBytes": 524288,
|
"rawBytes": 524288,
|
||||||
"gzipBytes": 163840
|
"gzipBytes": 164128
|
||||||
},
|
},
|
||||||
"asyncChunk": {
|
"asyncChunk": {
|
||||||
"rawBytes": 393216,
|
"rawBytes": 393216,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
|
||||||
|
export default function AddressExplorerScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
|
||||||
|
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
|
||||||
|
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
|
||||||
|
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
|
||||||
|
"addresses:address_book:write", "addresses:address_book:delete",
|
||||||
|
"addresses:address_list:write", "addresses:address_list:delete",
|
||||||
|
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
|
||||||
|
],
|
||||||
|
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
|
||||||
|
};
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<AddressBookPage settings={settings} auth={auth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
|
||||||
|
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
|
||||||
|
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
|
||||||
|
import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||||
|
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||||
|
|
||||||
|
const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" },
|
||||||
|
tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||||
|
|
||||||
|
export default function CampaignAttachmentsScenario() {
|
||||||
|
const [available, setAvailable] = useState(true);
|
||||||
|
const modules: PlatformWebModule[] = [
|
||||||
|
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||||
|
{ id: "files", label: "Files", version: "fixture", uiCapabilities: available ? {
|
||||||
|
"files.fileExplorer": { ManagedFileChooser, listFileSpaces }
|
||||||
|
} : {} }
|
||||||
|
];
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
|
||||||
|
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
|
||||||
|
<ConcurrencyConflictProvider>
|
||||||
|
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
|
||||||
|
</ConcurrencyConflictProvider>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { apiPostJson } from "../src/api/client";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
|
||||||
|
function job(id: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`,
|
||||||
|
review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignBulkReviewScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [buildToken, setBuildToken] = useState("build-one");
|
||||||
|
const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)),
|
||||||
|
job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }),
|
||||||
|
job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }),
|
||||||
|
job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }),
|
||||||
|
job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })];
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<main><button onClick={() => setOpen(true)}>Open grouped review</button>
|
||||||
|
<button data-testid="replace-build" onClick={() => setBuildToken("build-two")}>Replace build</button>
|
||||||
|
{open && <BulkMessageReviewDialog rows={rows} buildToken={buildToken} disabled={params.has("read-only")}
|
||||||
|
onClose={() => setOpen(false)} onAccept={async (selection) => {
|
||||||
|
await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection);
|
||||||
|
}} />}
|
||||||
|
</main>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel";
|
||||||
|
|
||||||
|
export default function CampaignDeliveryPolicyScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"}>
|
||||||
|
<CampaignDeliveryPolicyPanel settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }}
|
||||||
|
scope={params.get("scope") === "tenant" ? "tenant" : "system"} canWrite={!params.has("readonly")} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||||
|
const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" },
|
||||||
|
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review",
|
||||||
|
"campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||||
|
export default function CampaignDeliveryProgressScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="delivery-campaign" />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Real optional-module settings surface with test-owned HTTP responses only.
|
||||||
|
import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { PlatformWebModule } from "../src/types";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||||
|
const modules: PlatformWebModule[] = [
|
||||||
|
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||||
|
{ id: "mail", label: "Mail", version: "fixture" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CampaignMailSettingsScenario() {
|
||||||
|
const language = new URLSearchParams(window.location.search).get("language") ?? "en";
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<ConcurrencyConflictProvider>
|
||||||
|
<MailSettingsPage settings={settings} campaignId="campaign-mail" />
|
||||||
|
</ConcurrencyConflictProvider>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||||
|
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||||
|
import {
|
||||||
|
HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn,
|
||||||
|
type HeaderAddressValues
|
||||||
|
} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||||
|
const initialAddresses = [
|
||||||
|
{ name: "Alpha", email: "alpha@example.test" },
|
||||||
|
{ name: "Beta", email: "beta@example.test" },
|
||||||
|
{ name: "Zulu", email: "zulu@example.test" }
|
||||||
|
];
|
||||||
|
const initialVersion: CampaignVersionDetail = {
|
||||||
|
id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"',
|
||||||
|
current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false,
|
||||||
|
raw_json: {
|
||||||
|
campaign: { name: "Recipient ordering" }, server: {},
|
||||||
|
recipients: { allow_individual_to: true, to: initialAddresses },
|
||||||
|
entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] }
|
||||||
|
}, editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignRecipientOrderScenario() {
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<ConcurrencyConflictProvider><RecipientOrderEditor /></ConcurrencyConflictProvider>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipientOrderEditor() {
|
||||||
|
const [version, setVersion] = useState(initialVersion);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [dialog, setDialog] = useState<"entry" | "header" | null>(null);
|
||||||
|
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []);
|
||||||
|
const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false,
|
||||||
|
reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" });
|
||||||
|
const entries = editor.displayDraft.entries as { defaults: Record<string, unknown>; inline: Record<string, unknown>[] };
|
||||||
|
const recipients = editor.displayDraft.recipients as Record<string, unknown>;
|
||||||
|
const entry = entries?.inline?.[0] ?? {};
|
||||||
|
return <main>
|
||||||
|
<button onClick={() => setDialog("entry")}>Edit individual addresses</button>
|
||||||
|
<button onClick={() => setDialog("header")}>Edit global addresses</button>
|
||||||
|
<button disabled={!editor.dirty || editor.saving} onClick={() => void editor.saveDraft()}>Save campaign</button>
|
||||||
|
<output data-testid="recipient-order">{JSON.stringify(entry.to)}</output>
|
||||||
|
<output data-testid="global-order">{JSON.stringify(recipients?.to)}</output>
|
||||||
|
<output data-testid="primary-address">{String(entry.email ?? "")}</output>
|
||||||
|
<output data-testid="recipient-dirty">{String(editor.dirty)}</output>
|
||||||
|
<output data-testid="recipient-error">{editor.localError || error}</output>
|
||||||
|
{dialog === "entry" && <RecipientAddressEditorDialog
|
||||||
|
entry={entry} index={0} locked={false} recipientsSection={recipients} entryDefaults={entries.defaults ?? {}}
|
||||||
|
onSave={(values, merges) => {
|
||||||
|
// Same owning helper as RecipientDataPage; no test copy of ordering or
|
||||||
|
// legacy primary-address/merge-field normalization.
|
||||||
|
editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]);
|
||||||
|
setDialog(null);
|
||||||
|
}} onClose={() => setDialog(null)} />}
|
||||||
|
{dialog === "header" && <HeaderAddressEditorDialog title="Global To addresses"
|
||||||
|
columns={[getAddressColumn("to")]} values={{ to: recipients.to } as HeaderAddressValues} locked={false}
|
||||||
|
onSave={(values) => { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||||
|
export default function CampaignReportScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [account, setAccount] = useState("reporter");
|
||||||
|
const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"];
|
||||||
|
const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" },
|
||||||
|
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read :
|
||||||
|
[...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
{params.has("switch-account") && <button type="button" onClick={() => setAccount("another-reporter")}>Switch fixture account</button>}
|
||||||
|
<CampaignReportPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="report-campaign" />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails";
|
||||||
|
import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const issues = Array.from({ length: 12 }, (_, index) => [
|
||||||
|
{ code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` },
|
||||||
|
{ code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` }
|
||||||
|
]).flat();
|
||||||
|
const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" }));
|
||||||
|
|
||||||
|
export default function CampaignReviewDetailsScenario() {
|
||||||
|
const [count, setCount] = useState(12);
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<button onClick={() => setCount(2)}>Reduce fixture details</button>
|
||||||
|
<section aria-label="Validation details fixture"><ValidationDetails issues={issues.slice(0, count * 2)} /></section>
|
||||||
|
<section aria-label="Repeated files fixture"><RepeatedFilesDetails findings={findings.slice(0, count)} /></section>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" },
|
||||||
|
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||||
|
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||||
|
export default function CampaignReviewScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth;
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
{params.has("race") && <button onClick={() => setSearchParams((current) => {
|
||||||
|
const next = new URLSearchParams(current); next.set("version", "replacement-version"); return next;
|
||||||
|
})}>Switch fixture version</button>}
|
||||||
|
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} campaignId="review-campaign" />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||||
|
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||||
|
const initialVersion: CampaignVersionDetail = {
|
||||||
|
id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"',
|
||||||
|
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||||
|
raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} },
|
||||||
|
editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignSavingScenario() {
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||||
|
<ConcurrencyConflictProvider><SavingEditor /></ConcurrencyConflictProvider>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavingEditor() {
|
||||||
|
const [version, setVersion] = useState(initialVersion);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [result, setResult] = useState("");
|
||||||
|
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []);
|
||||||
|
const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false,
|
||||||
|
reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" });
|
||||||
|
const subject = (editor.displayDraft.template as Record<string, unknown>)?.subject ?? "";
|
||||||
|
return <main>
|
||||||
|
<label>Subject<input aria-label="Subject" value={String(subject)} onChange={(event) => editor.patch(["template", "subject"], event.target.value)} /></label>
|
||||||
|
<button disabled={editor.saving} onClick={() => void editor.saveDraft().then((saved) => setResult(String(saved)))}>Save draft</button>
|
||||||
|
<button disabled={editor.saving} onClick={() => { void editor.saveDraft(); void editor.saveDraft(); }}>Request save twice</button>
|
||||||
|
<button disabled={editor.saving} onClick={() => void editor.discardDraft()}>Discard draft</button>
|
||||||
|
<button onClick={() => setVersion({ ...initialVersion, raw_json: { ...initialVersion.raw_json, template: { subject: "Background refresh" } } })}>Background refresh</button>
|
||||||
|
<Link to="/?campaign-saving&elsewhere">Other route</Link>
|
||||||
|
<output data-testid="save-busy">{String(editor.saving)}</output>
|
||||||
|
<output data-testid="save-dirty">{String(editor.dirty)}</output>
|
||||||
|
<output data-testid="save-status">{editor.saveState}</output>
|
||||||
|
<output data-testid="save-error">{editor.localError || error}</output>
|
||||||
|
<output data-testid="save-result">{result}</output>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Exercise the owning Campaign hook, with network responses controlled by tests.
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData";
|
||||||
|
|
||||||
|
export default function CampaignWorkspaceScenario() {
|
||||||
|
const [campaignId, setCampaignId] = useState("campaign-a");
|
||||||
|
const [accessToken, setAccessToken] = useState("token-a");
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||||
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true });
|
||||||
|
return <main>
|
||||||
|
<button onClick={() => void reload()}>Reload workspace</button>
|
||||||
|
<button onClick={() => void reload({ force: true })}>Force reload workspace</button>
|
||||||
|
<button onClick={() => setCampaignId("campaign-b")}>Switch campaign</button>
|
||||||
|
<button onClick={() => setAccessToken("token-b")}>Switch identity</button>
|
||||||
|
<button onClick={() => setSearchParams((current) => {
|
||||||
|
const next = new URLSearchParams(current);
|
||||||
|
next.set("version", "selected-version");
|
||||||
|
return next;
|
||||||
|
})}>Select version</button>
|
||||||
|
<output data-testid="workspace-campaign">{data.campaign?.id ?? "none"}</output>
|
||||||
|
<output data-testid="workspace-version">{data.currentVersion?.id ?? "none"}</output>
|
||||||
|
<output data-testid="workspace-revision">{data.currentVersion?.edit_revision ?? 0}</output>
|
||||||
|
<output data-testid="workspace-loading">{String(loading)}</output>
|
||||||
|
<output data-testid="workspace-error">{error}</output>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,38 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { useLocation } from "react-router";
|
||||||
|
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||||
|
import DataGridLayoutScenario from "./DataGridLayoutScenario";
|
||||||
|
import NavigationLayoutScenario from "./NavigationLayoutScenario";
|
||||||
|
import ManagedArchiveScenario from "./ManagedArchiveScenario";
|
||||||
|
import FilesToolbarScenario from "./FilesToolbarScenario";
|
||||||
|
import CredentialReferencesScenario from "./CredentialReferencesScenario";
|
||||||
|
import FormControlLayoutScenario from "./FormControlLayoutScenario";
|
||||||
|
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
|
||||||
|
import CampaignReportScenario from "./CampaignReportScenario";
|
||||||
|
import ModuleLayoutScenario from "./ModuleLayoutScenario";
|
||||||
|
import HelpCenterScenario from "./HelpCenterScenario";
|
||||||
|
import NotificationFilterScenario from "./NotificationFilterScenario";
|
||||||
|
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
|
||||||
|
import SearchFiltersScenario from "./SearchFiltersScenario";
|
||||||
|
import AddressExplorerScenario from "./AddressExplorerScenario";
|
||||||
|
import MailFolderExplorerScenario from "./MailFolderExplorerScenario";
|
||||||
|
import MailToolbarScenario from "./MailToolbarScenario";
|
||||||
|
import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario";
|
||||||
|
import CampaignSavingScenario from "./CampaignSavingScenario";
|
||||||
|
import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario";
|
||||||
|
import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario";
|
||||||
|
import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario";
|
||||||
|
import CampaignReviewScenario from "./CampaignReviewScenario";
|
||||||
|
import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
|
||||||
|
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
|
||||||
|
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
|
||||||
|
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
|
||||||
|
import 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 QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||||
import ActionToolbar from "../src/components/ActionToolbar";
|
import ActionToolbar from "../src/components/ActionToolbar";
|
||||||
import Button from "../src/components/Button";
|
import Button from "../src/components/Button";
|
||||||
@@ -25,14 +57,26 @@ import StatePanel from "../src/components/StatePanel";
|
|||||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||||
|
import WysiwygEditor from "../src/components/WysiwygEditor";
|
||||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||||
import HelpMenu from "../src/layout/HelpMenu";
|
import HelpMenu from "../src/layout/HelpMenu";
|
||||||
|
import IconRail from "../src/layout/IconRail";
|
||||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||||
import {
|
import {
|
||||||
createQuickAccessLaunchContext,
|
createQuickAccessLaunchContext,
|
||||||
quickAccessLaunchState
|
quickAccessLaunchState
|
||||||
} from "../src/platform/launchContext";
|
} 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() {
|
export default function ConformanceApp() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -40,6 +84,65 @@ export default function ConformanceApp() {
|
|||||||
const [editorDirty, setEditorDirty] = useState(true);
|
const [editorDirty, setEditorDirty] = useState(true);
|
||||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||||
|
|
||||||
|
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("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 (
|
return (
|
||||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@@ -172,6 +275,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() {
|
function HelpConformanceScenario() {
|
||||||
return (
|
return (
|
||||||
<section className="conformance-section" aria-labelledby="help-heading">
|
<section className="conformance-section" aria-labelledby="help-heading">
|
||||||
@@ -290,6 +455,132 @@ const CONFORMANCE_AUTH = {
|
|||||||
groups_loaded: true
|
groups_loaded: true
|
||||||
} satisfies AuthInfo;
|
} 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 = {
|
const CONFORMANCE_SETTINGS: ApiSettings = {
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Exercise the real shared credential editor with Mail's optional capability.
|
||||||
|
// API fixtures supply public metadata only; this never loads or saves secrets.
|
||||||
|
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
|
||||||
|
import type { ApiSettings, PlatformWebModule } from "../src/types";
|
||||||
|
|
||||||
|
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
const modules: PlatformWebModule[] = [{
|
||||||
|
id: "mail", label: "Mail", version: "1",
|
||||||
|
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
|
||||||
|
}];
|
||||||
|
|
||||||
|
export default function CredentialReferencesScenario() {
|
||||||
|
return <PlatformModulesProvider modules={modules}>
|
||||||
|
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
|
||||||
|
</PlatformModulesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
|
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
|
||||||
|
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||||
|
|
||||||
|
type Row = { id: string; name: string; detail: string };
|
||||||
|
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
|
||||||
|
|
||||||
|
/** Genuine shared grid: intentionally undersized legacy action preference. */
|
||||||
|
export default function DataGridLayoutScenario() {
|
||||||
|
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
|
||||||
|
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
|
||||||
|
const composite = mode === "composite";
|
||||||
|
const [width, setWidth] = useState(900);
|
||||||
|
const [mounted, setMounted] = useState(true);
|
||||||
|
const [empty, setEmpty] = useState(false);
|
||||||
|
const [extraAction, setExtraAction] = useState(false);
|
||||||
|
const [clicked, setClicked] = useState("");
|
||||||
|
const columns = useMemo<DataGridColumn<Row>[]>(() => [
|
||||||
|
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
|
||||||
|
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
|
||||||
|
{
|
||||||
|
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
|
||||||
|
minWidth: mode === "oversized" ? 500 : undefined,
|
||||||
|
columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end",
|
||||||
|
render: (row) => {
|
||||||
|
const group = <TableActionGroup actions={[
|
||||||
|
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setClicked(`Inspect ${row.name}`) },
|
||||||
|
{ id: "up", label: "Move up", icon: <ArrowUp />, disabledReason: "Already first", onClick: () => undefined },
|
||||||
|
{ id: "down", label: "Move down", icon: <ArrowDown />, onClick: () => setClicked("Move down") },
|
||||||
|
{ id: "remove", label: "Remove row", icon: <Trash2 />, onClick: () => setClicked("Remove row") },
|
||||||
|
extraAction && { id: "add", label: "Add below", icon: <Plus />, onClick: () => setClicked("Add below") }
|
||||||
|
]} />;
|
||||||
|
return composite ? <div role="group" aria-label="Composite controls" style={{ display: "flex", gap: 4, width: "100%" }}>
|
||||||
|
<Button className="table-action-button" aria-label="Extra control" onClick={() => setClicked("Extra control")}><Plus /></Button>
|
||||||
|
{group}
|
||||||
|
</div> : group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
], [behavior, composite, extraAction, mode]);
|
||||||
|
return (
|
||||||
|
<main style={{ padding: 16, minWidth: 0 }}>
|
||||||
|
<h1>Data grid layout conformance</h1>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||||
|
<Button onClick={() => setWidth(320)}>Narrow grid</Button>
|
||||||
|
<Button onClick={() => setWidth(900)}>Wide grid</Button>
|
||||||
|
<Button onClick={() => setMounted((value) => !value)}>Toggle grid mount</Button>
|
||||||
|
<Button onClick={() => setEmpty((value) => !value)}>Toggle empty rows</Button>
|
||||||
|
<Button onClick={() => setExtraAction((value) => !value)}>Toggle extra action</Button>
|
||||||
|
</div>
|
||||||
|
<output data-testid="clicked-action">{clicked}</output>
|
||||||
|
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
|
||||||
|
{mounted && <DataGrid
|
||||||
|
id={`layout-conformance-${behavior}`}
|
||||||
|
rows={empty ? [] : rows}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
initialFit={behavior === "free" ? "content" : "container"}
|
||||||
|
resizeBehavior={behavior}
|
||||||
|
emptyAction={<DataGridEmptyAction onAdd={() => setClicked("Add first row")} />}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage";
|
||||||
|
import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-templates/webui/src/styles/templates.css";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
|
import Dialog from "../src/components/Dialog";
|
||||||
|
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||||
|
import { FormGrid } from "../src/components/ContentGrid";
|
||||||
|
import FormField from "../src/components/FormField";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
|
||||||
|
const fixtureAuth: AuthInfo = {
|
||||||
|
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||||
|
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||||
|
scopes: ["templates:template:read", "templates:template:write"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
if (templates) return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[templateTranslations]}>
|
||||||
|
<TemplatesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
return <main className="conformance-root">
|
||||||
|
<Button onClick={() => setOpen(true)}>Open layout fixture</Button>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={`LangeDialogüberschriftOhneTrennzeichen${"Zusatz".repeat(12)}`}
|
||||||
|
description={`Referenz:${"abcdef0123456789".repeat(12)}`}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={<><Button>Abbrechen</Button><Button variant="primary">Änderungen speichern</Button></>}
|
||||||
|
>
|
||||||
|
<DialogForm onSubmit={(event) => event.preventDefault()}>
|
||||||
|
<DialogSection>
|
||||||
|
<FormGrid columns={2} collapseAt="standard">
|
||||||
|
<FormField label={`Feldname${"Lang".repeat(20)}`}><input defaultValue={"Langer Wert ".repeat(20)} /></FormField>
|
||||||
|
<FormField label="Auswahl"><select defaultValue="long"><option value="long">{"Lange Auswahlliste ".repeat(20)}</option></select></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
</DialogSection>
|
||||||
|
<FormField label="Mehrzeiliger Inhalt"><textarea defaultValue={"Nachweistext ".repeat(60)} rows={5} /></FormField>
|
||||||
|
<div style={{ maxWidth: "100%", minWidth: 0, overflowX: "auto" }} data-testid="dialog-local-scroll">
|
||||||
|
<table style={{ width: 1400 }}><tbody><tr><td>Absichtlich breite Tabelle</td><td>Letzte Tabellenspalte</td></tr></tbody></table>
|
||||||
|
</div>
|
||||||
|
</DialogForm>
|
||||||
|
</Dialog>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Real owning Files module with fixture-only authentication; tests intercept every API call.
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
|
||||||
|
const fullAuth: AuthInfo = {
|
||||||
|
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||||
|
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||||
|
scopes: ["files:file:read", "files:file:download", "files:file:upload", "files:file:organize", "files:file:delete", "files:file:share", "access:role:read"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
const readOnlyAuth = { ...fullAuth, scopes: ["files:file:read"] };
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
|
||||||
|
export default function FilesToolbarScenario() {
|
||||||
|
const parameters = new URLSearchParams(useLocation().search);
|
||||||
|
const [nextSession, setNextSession] = useState(false);
|
||||||
|
const auth = parameters.has("read-only") ? readOnlyAuth : fullAuth;
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
{parameters.has("session-switch") && <button type="button" onClick={() => setNextSession(true)}>Switch fixture session</button>}
|
||||||
|
<FilesPage settings={nextSession ? { ...settings, accessToken: "fixture-next-session" } : settings}
|
||||||
|
auth={nextSession ? { ...auth, user: { ...auth.user, id: "fixture-next-user" } } : auth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
|
||||||
|
import FormField from "../src/components/FormField";
|
||||||
|
import PasswordField from "../src/components/PasswordField";
|
||||||
|
import ToggleSwitch from "../src/components/ToggleSwitch";
|
||||||
|
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { AttachmentRulesDataGrid } from "../../../govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay";
|
||||||
|
import type { AttachmentRule } from "../../../govoplan-campaign/webui/src/features/campaigns/utils/attachments";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||||
|
|
||||||
|
/** Real shared controls and Campaign attachment editor; no API writes. */
|
||||||
|
export default function FormControlLayoutScenario() {
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
const [rules, setRules] = useState<AttachmentRule[]>([]);
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||||
|
<main style={{ padding: 16, minWidth: 0 }}>
|
||||||
|
<h1>Shared form-control layout</h1>
|
||||||
|
<FormGrid columns={2} data-testid="mixed-form-grid">
|
||||||
|
<FormField label="Password" help="Leave blank to retain the configured secret.">
|
||||||
|
<PasswordField value={secret} onValueChange={setSecret} generator />
|
||||||
|
</FormField>
|
||||||
|
<ToggleSwitch label="Remove configured secret" checked={checked} onChange={setChecked} disabled={Boolean(secret)} />
|
||||||
|
<FormField label={"Long translated field label with additional context ".repeat(4)}>
|
||||||
|
<input aria-label="Long label field" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Short label"><input aria-label="Short label field" /></FormField>
|
||||||
|
<FormField label="Wrapped field"><input aria-label="Wrapped field" /></FormField>
|
||||||
|
<GridItem><ToggleSwitch label="Wrapped switch" checked={checked} onChange={setChecked} /></GridItem>
|
||||||
|
</FormGrid>
|
||||||
|
<FormLayout columns={2} data-testid="mixed-form-layout" onSubmit={(event) => event.preventDefault()}>
|
||||||
|
<FormField label="Other setting"><input aria-label="Other setting" /></FormField>
|
||||||
|
<ToggleSwitch label="Other switch" checked={checked} onChange={setChecked} />
|
||||||
|
</FormLayout>
|
||||||
|
<h2>Global attachments</h2>
|
||||||
|
<div data-testid="global-attachments">
|
||||||
|
<AttachmentRulesDataGrid id="layout-global-attachments" rules={rules}
|
||||||
|
settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }} campaignId="fixture-campaign"
|
||||||
|
basePaths={[{ id: "fixture-source", name: "Fixture source", path: "fixtures" }]}
|
||||||
|
onChange={setRules} />
|
||||||
|
</div>
|
||||||
|
<h2>Compact actions resist broad consumer styles</h2>
|
||||||
|
<style>{".fixture-broad-button-style .btn { width: 100%; }"}</style>
|
||||||
|
<div className="fixture-broad-button-style" data-testid="compact-action">
|
||||||
|
<TableActionGroup actions={[{ id: "add", label: "Add fixture item", icon: <Plus />, onClick: () => setChecked(true) }]} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
export default function HelpCenterScenario() {
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { MailProfileScope } from "../src/types";
|
||||||
|
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||||
|
|
||||||
|
export default function MailCredentialPolicyScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
|
||||||
|
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
|
||||||
|
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
|
||||||
|
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
|
||||||
|
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
|
||||||
|
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MailFolderExplorerScenario() {
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||||
|
<MailboxPage settings={settings} auth={auth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
|
||||||
|
export default function MailToolbarScenario() {
|
||||||
|
const parameters = new URLSearchParams(window.location.search);
|
||||||
|
const [tenant, setTenant] = useState("first");
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
|
||||||
|
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
|
||||||
|
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
|
||||||
|
<MailboxPage settings={settings} auth={auth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Conformance-only composition: render the owning module, never a test copy.
|
||||||
|
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||||
|
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
|
||||||
|
const fixtureAuth: AuthInfo = {
|
||||||
|
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||||
|
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||||
|
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
|
||||||
|
const auth = downloadAllowed ? fixtureAuth : {
|
||||||
|
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
|
||||||
|
};
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
|
||||||
|
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
|
||||||
|
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
|
||||||
|
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
|
||||||
|
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
|
||||||
|
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
|
||||||
|
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
|
||||||
|
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
|
||||||
|
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
|
||||||
|
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
|
||||||
|
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
|
||||||
|
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
import "../../../govoplan-committee/webui/src/styles/committee.css";
|
||||||
|
import "../../../govoplan-voting/webui/src/styles/voting.css";
|
||||||
|
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
|
||||||
|
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
|
||||||
|
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
|
||||||
|
import "../../../govoplan-idm/webui/src/styles/idm.css";
|
||||||
|
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
|
||||||
|
export default function ModuleLayoutScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
|
||||||
|
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
|
||||||
|
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
|
||||||
|
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
|
||||||
|
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
|
||||||
|
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
|
||||||
|
"idm:relationship:read", "idm:relationship:write"
|
||||||
|
],
|
||||||
|
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
};
|
||||||
|
const context = { settings, auth };
|
||||||
|
const module = params.get("module-layouts");
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
|
||||||
|
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
|
||||||
|
{module === "committee" && <CommitteePage {...context} />}
|
||||||
|
{module === "voting" && <VotingPage {...context} />}
|
||||||
|
{module === "scheduling" && <SchedulingPage {...context} />}
|
||||||
|
{module === "risk" && <RiskCompliancePage {...context} />}
|
||||||
|
{module === "organizations" && <OrganizationsPage {...context} />}
|
||||||
|
{module === "idm" && <TypedRelationshipsPanel {...context} />}
|
||||||
|
</div>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
|
import Dialog from "../src/components/Dialog";
|
||||||
|
import MultiSelectFilter from "../src/components/MultiSelectFilter";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: "first", label: "First tag" },
|
||||||
|
{ value: "long", label: "X".repeat(80) },
|
||||||
|
{ value: "last", label: "Last tag" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MultiSelectFilterScenario() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selection, setSelection] = useState<string[] | null>(null);
|
||||||
|
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode="en">
|
||||||
|
<main className="content-pad">
|
||||||
|
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
|
||||||
|
<Button>Outside action</Button>
|
||||||
|
{filter}
|
||||||
|
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
|
||||||
|
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
|
||||||
|
panelStyle={{ transform: "translateZ(0)" }}
|
||||||
|
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: "hidden", maxHeight: 100 }}>
|
||||||
|
<p>The filter escapes this clipped and transformed dialog.</p>
|
||||||
|
{filter}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Folder, LayoutDashboard, Mail } from "lucide-react";
|
||||||
|
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import IconRail from "../src/layout/IconRail";
|
||||||
|
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
|
||||||
|
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||||
|
|
||||||
|
const items: PlatformNavItem[] = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
|
||||||
|
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
|
||||||
|
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
|
||||||
|
];
|
||||||
|
const areas: ProductAreaContribution[] = [
|
||||||
|
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
|
||||||
|
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
|
||||||
|
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
|
||||||
|
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
|
||||||
|
} : null);
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={language}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
|
||||||
|
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
|
||||||
|
<main style={{ minWidth: 0, padding: 16 }}>
|
||||||
|
<h1>Navigation editor fixture</h1>
|
||||||
|
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
|
||||||
|
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { AuthInfo } from "../src/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
|
||||||
|
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
|
||||||
|
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
|
||||||
|
scopes: ["notifications:notification:read"], roles: [], groups: [],
|
||||||
|
profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationFilterScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [account, setAccount] = useState("filter-user");
|
||||||
|
useEffect(() => {
|
||||||
|
const changeAccount = () => setAccount("other-user");
|
||||||
|
window.addEventListener("conformance-notification-account", changeAccount);
|
||||||
|
return () => window.removeEventListener("conformance-notification-account", changeAccount);
|
||||||
|
}, []);
|
||||||
|
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
|
||||||
|
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||||
|
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { StrictMode, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
|
import FormField from "../src/components/FormField";
|
||||||
|
import PasswordField from "../src/components/PasswordField";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import "../src/styles/tokens.css";
|
||||||
|
import "../src/styles/layout.css";
|
||||||
|
import "../src/styles/forms.css";
|
||||||
|
import "../src/styles/components.css";
|
||||||
|
import "../src/styles/dialogs.css";
|
||||||
|
import "./conformance.css";
|
||||||
|
|
||||||
|
const GENERATOR_OPTIONS = { length: 24 };
|
||||||
|
|
||||||
|
function PasswordFieldScenario() {
|
||||||
|
const [password, setPassword] = useState("fixture-unchanged-password");
|
||||||
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
const [changes, setChanges] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="conformance-root">
|
||||||
|
<h1>Optional password generator</h1>
|
||||||
|
<FormField label="Editable password">
|
||||||
|
<PasswordField
|
||||||
|
data-testid="editable-password"
|
||||||
|
value={password}
|
||||||
|
disabled={disabled}
|
||||||
|
generator
|
||||||
|
generatorOptions={GENERATOR_OPTIONS}
|
||||||
|
helpContextId="access.authentication.password"
|
||||||
|
helpModuleId="access"
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPassword(value);
|
||||||
|
setChanges((count) => count + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<output data-testid="password-changes">{changes}</output>
|
||||||
|
<Button data-testid="toggle-generator-access" onClick={() => setDisabled((value) => !value)}>
|
||||||
|
{disabled ? "Enable generation" : "Disable generation"}
|
||||||
|
</Button>
|
||||||
|
<FormField label="Sign-in password">
|
||||||
|
<PasswordField value="fixture-sign-in-password" onValueChange={() => undefined} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Disabled password">
|
||||||
|
<PasswordField generator disabled value="" onValueChange={() => undefined} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Read-only password">
|
||||||
|
<PasswordField generator readOnly value="" onValueChange={() => undefined} />
|
||||||
|
</FormField>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately no Core barrel or other fixture imports: they could eagerly load
|
||||||
|
// the generator and mask a regression in this field's real lazy boundary.
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"}>
|
||||||
|
<PasswordFieldScenario />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import "../src/styles/auth-gate.css";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
|
||||||
|
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
|
||||||
|
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
|
||||||
|
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
|
||||||
|
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
|
||||||
|
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
|
||||||
|
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
|
||||||
|
import AuthActionGate from "../src/features/auth/AuthActionGate";
|
||||||
|
import LoginModal from "../src/features/auth/LoginModal";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
|
||||||
|
|
||||||
|
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
const capability: AuthActionUiCapability = {
|
||||||
|
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
|
||||||
|
};
|
||||||
|
const modules: PlatformWebModule[] = [{
|
||||||
|
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
|
||||||
|
helpContexts: [
|
||||||
|
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
|
||||||
|
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
|
||||||
|
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
|
export default function PasswordLifecycleScenario() {
|
||||||
|
const parameters = new URLSearchParams(useLocation().search);
|
||||||
|
const mode = parameters.get("mode") ?? "required";
|
||||||
|
const language = parameters.get("language") ?? "en";
|
||||||
|
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
|
||||||
|
const owner = parameters.get("owner") !== "false";
|
||||||
|
const [auth, setAuth] = useState<AuthInfo>({
|
||||||
|
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
|
||||||
|
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
|
||||||
|
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
|
||||||
|
profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||||
|
});
|
||||||
|
const [updated, setUpdated] = useState("");
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
function update(next: AuthUpdate | null, token?: string) {
|
||||||
|
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
|
||||||
|
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
|
||||||
|
}
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
|
||||||
|
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
|
||||||
|
<div data-testid="password-scenario">
|
||||||
|
{mode === "required" || mode === "missing"
|
||||||
|
? auth.user.required_auth_action
|
||||||
|
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
|
||||||
|
: <h1>Workspace available</h1>
|
||||||
|
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
|
||||||
|
: mode === "issue" ? open
|
||||||
|
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
|
||||||
|
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
|
||||||
|
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
|
||||||
|
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
|
||||||
|
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
|
||||||
|
<output data-testid="auth-update">{updated}</output>
|
||||||
|
</div>
|
||||||
|
</PlatformModulesProvider>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -1,25 +1,144 @@
|
|||||||
// Narrow facade used only by the conformance build. It lets the optional
|
// Narrow facade used only by the conformance build. It lets optional modules
|
||||||
// Quick Access module exercise its real rail without pulling the composed
|
// exercise their real task surfaces without pulling the composed application's
|
||||||
// application's generated module catalogue into this isolated test bundle.
|
// generated module catalogue into this isolated test bundle.
|
||||||
export { apiFetch } from "../src/api/client";
|
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
|
||||||
|
export { fetchAuthGroups } from "../src/api/auth";
|
||||||
|
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
|
||||||
|
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
|
||||||
|
export type * from "../src/api/privacyRetention";
|
||||||
|
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
|
||||||
|
export { default as FormSection } from "../src/components/FormSection";
|
||||||
|
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
|
||||||
|
export type * from "../src/api/mailContracts";
|
||||||
|
export type * from "../src/types";
|
||||||
|
export { default as FieldLabel } from "../src/components/help/FieldLabel";
|
||||||
|
export { default as PasswordField } from "../src/components/PasswordField";
|
||||||
|
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
|
||||||
|
export { default as ExplorerTree } from "../src/components/ExplorerTree";
|
||||||
|
export type { ExplorerTreeNodeContext } from "../src/components/ExplorerTree";
|
||||||
|
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions, DataGridPaginationBar } from "../src/components/table/DataGrid";
|
||||||
|
export type { DataGridColumn, DataGridQueryState, DataGridListOption } from "../src/components/table/DataGrid";
|
||||||
|
export { default as TableActionGroup } from "../src/components/table/TableActionGroup";
|
||||||
|
export { revisionConflictFromError, threeWayMerge } from "../src/api/concurrency";
|
||||||
|
export { useConcurrencyConflictResolver } from "../src/components/ConcurrencyConflictDialog";
|
||||||
|
export { useRegisterUnsavedChanges, UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||||
|
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "../src/components/UnsavedChangesGuard";
|
||||||
|
export { useDeltaWatermarks } from "../src/utils/deltaHooks";
|
||||||
|
export { default as ReferenceSelect } from "../src/components/ReferenceSelect";
|
||||||
|
export { unavailableReferenceOption } from "../src/components/ReferenceSelect";
|
||||||
|
export { filterSearchableSelectOptions } from "../src/components/SearchableSelect";
|
||||||
|
export type { ReferenceOptionProvider, ReferenceOption, CredentialReferenceSelectorContext, CredentialReferenceSelectorsUiCapability } from "../src/components/ReferenceSelect";
|
||||||
|
export { apiReferenceOptionProvider } from "../src/platform/referenceProviders";
|
||||||
|
export { fetchResourceAccessExplanation, fetchResourceAccessExplanationSubjects } from "../src/api/resourceAccess";
|
||||||
|
export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser, ResourceAccessExplanationResponse, ResourceAccessExplanationSubjectsResponse } from "../src/api/resourceAccess";
|
||||||
|
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
|
||||||
|
export type { ActionBlockerReason } from "../src/components/ActionBlockerHint";
|
||||||
|
export { default as MessageDisplayPanel } from "../src/components/MessageDisplayPanel";
|
||||||
|
export type { MessageDisplayAttachment } from "../src/components/MessageDisplayPanel";
|
||||||
|
export { default as GuidedReviewList } from "../src/components/GuidedReviewList";
|
||||||
|
export { default as InlineHelp } from "../src/components/help/InlineHelp";
|
||||||
|
export { default as ActionToolbar } from "../src/components/ActionToolbar";
|
||||||
|
export { ToolbarGroup } from "../src/components/ActionToolbar";
|
||||||
|
export { default as MultiSelectFilter } from "../src/components/MultiSelectFilter";
|
||||||
|
export { default as CountBadge } from "../src/components/CountBadge";
|
||||||
|
export { default as Button } from "../src/components/Button";
|
||||||
|
export { default as Card } from "../src/components/Card";
|
||||||
|
export { default as MetricGrid } from "../src/components/MetricGrid";
|
||||||
|
export { default as MetricCard } from "../src/components/MetricCard";
|
||||||
|
export { default as PageActionBar } from "../src/components/PageActionBar";
|
||||||
|
export { default as PageLayout } from "../src/components/PageLayout";
|
||||||
|
export { default as PageTitle } from "../src/components/PageTitle";
|
||||||
|
export { default as AdminIconButton } from "../src/components/admin/AdminIconButton";
|
||||||
|
export { default as ModuleSubnav } from "../src/layout/ModuleSubnav";
|
||||||
|
export type { ModuleSubnavGroup } from "../src/layout/ModuleSubnav";
|
||||||
|
export { default as DateTimeField } from "../src/components/DateTimeField";
|
||||||
|
export { default as PeoplePicker } from "../src/components/people/PeoplePicker";
|
||||||
|
export type { PeoplePickerItem, PeoplePickerSearch, PeoplePickerSearchGroup } from "../src/components/people/peoplePickerTypes";
|
||||||
|
export type { FormatDateTimeOptions } from "../src/utils/datetime";
|
||||||
|
export { default as SearchableSelect } from "../src/components/SearchableSelect";
|
||||||
|
export type { SearchableSelectOption } from "../src/components/SearchableSelect";
|
||||||
|
export { FormLayout } from "../src/components/ContentGrid";
|
||||||
|
export { apiPatchJson, isApiError } from "../src/api/client";
|
||||||
|
export { hasAnyScope } from "../src/utils/permissions";
|
||||||
|
export { useEffectiveView, useViewSurfaces } from "../src/platform/ViewContext";
|
||||||
|
export { isViewSurfaceVisible } from "../src/platform/views";
|
||||||
|
export { MailServerFolderLookupResultView } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
|
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
|
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
|
||||||
|
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
|
||||||
|
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "../src/components/admin/adminUtils";
|
||||||
|
export { default as ConnectionTree } from "../src/components/ConnectionTree";
|
||||||
|
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
|
||||||
|
export { default as StageRail } from "../src/components/StageRail";
|
||||||
|
export type { StageRailTone } from "../src/components/StageRail";
|
||||||
|
export { default as PolicyLockedHint } from "../src/components/PolicyLockedHint";
|
||||||
|
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "../src/components/PolicyPathHelp";
|
||||||
|
export type { NormalizedPolicySourcePathItem } from "../src/components/PolicyPathHelp";
|
||||||
|
export { default as PolicySourcePath } from "../src/components/PolicySourcePath";
|
||||||
|
export type { PolicySourcePathItem } from "../src/components/PolicySourcePath";
|
||||||
|
export { PolicyRow, PolicyTable } from "../src/components/PolicyTable";
|
||||||
|
export { default as MailServerSettingsPanel, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
|
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerImapSettings, MailServerSmtpSettings } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
|
export { mergeDeltaRows } from "../src/utils/delta";
|
||||||
|
export { ReferenceMultiSelect, customReferenceOption, staticReferenceOptionProvider } from "../src/components/ReferenceSelect";
|
||||||
|
export { platformModuleReferenceProvider } from "../src/platform/referenceProviders";
|
||||||
|
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
|
||||||
|
export { default as ContentSection } from "../src/components/ContentSection";
|
||||||
|
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
|
||||||
|
export { default as Dialog } from "../src/components/Dialog";
|
||||||
|
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||||
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
|
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
|
||||||
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
|
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
|
||||||
|
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
|
||||||
|
export { default as FileDropZone } from "../src/components/FileDropZone";
|
||||||
|
export { default as FilterBar } from "../src/components/FilterBar";
|
||||||
|
export { default as FormField } from "../src/components/FormField";
|
||||||
|
export { FormGrid, default as ContentGrid } from "../src/components/ContentGrid";
|
||||||
export { default as IconButton } from "../src/components/IconButton";
|
export { default as IconButton } from "../src/components/IconButton";
|
||||||
export { default as LoadingFrame } from "../src/components/LoadingFrame";
|
export { default as LoadingFrame } from "../src/components/LoadingFrame";
|
||||||
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
|
||||||
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
|
export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
|
||||||
|
export { default as SegmentedControl } from "../src/components/SegmentedControl";
|
||||||
|
export {
|
||||||
|
default as SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent
|
||||||
|
} from "../src/components/SelectionList";
|
||||||
|
export { default as StatePanel } from "../src/components/StatePanel";
|
||||||
|
export { default as StatusBadge } from "../src/components/StatusBadge";
|
||||||
|
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
|
||||||
|
export {
|
||||||
|
useGuardedNavigate,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard
|
||||||
|
} from "../src/components/UnsavedChangesGuard";
|
||||||
|
export {
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage
|
||||||
|
} from "../src/i18n/LanguageContext";
|
||||||
|
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
|
||||||
export {
|
export {
|
||||||
dispatchQuickAccessResult,
|
dispatchQuickAccessResult,
|
||||||
quickAccessLaunchState
|
quickAccessLaunchState
|
||||||
} from "../src/platform/launchContext";
|
} from "../src/platform/launchContext";
|
||||||
export { i18nMessage } from "../src/i18n/LanguageContext";
|
export { hasScope } from "../src/utils/permissions";
|
||||||
|
export { formatDateTime } from "../src/utils/datetime";
|
||||||
|
export { insertAfter, moveArrayItem } from "../src/utils/arrayOrder";
|
||||||
|
export { addressesFromValue, dedupeAddresses, parseMailboxAddressText } from "../src/utils/emailAddresses";
|
||||||
|
export type { MailboxAddress } from "../src/utils/emailAddresses";
|
||||||
|
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
|
||||||
|
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
|
||||||
|
export { default as WorkspaceLayout } from "../src/components/WorkspaceLayout";
|
||||||
export type {
|
export type {
|
||||||
ApiSettings,
|
ApiSettings,
|
||||||
|
GlobalSearchProps,
|
||||||
|
SearchContextContribution,
|
||||||
|
SearchContextsUiCapability,
|
||||||
|
DeltaDeletedItem,
|
||||||
|
FilesManagedFileLinkTarget,
|
||||||
|
AuthInfo,
|
||||||
|
PlatformRouteContext,
|
||||||
|
PlatformTranslations,
|
||||||
QuickAccessRailProps,
|
QuickAccessRailProps,
|
||||||
QuickAccessToolsUiCapability
|
QuickAccessToolsUiCapability
|
||||||
} from "../src/types";
|
} from "../src/types";
|
||||||
|
|
||||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
|
||||||
void capabilityName;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import SearchPage from "../../../govoplan-search/webui/src/features/search/SearchPage";
|
||||||
|
import GlobalSearch from "../../../govoplan-search/webui/src/components/GlobalSearch";
|
||||||
|
import { searchFilterTranslations } from "../../../govoplan-search/webui/src/i18n/searchFilterTranslations";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
|
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import "../../../govoplan-search/webui/src/styles/search.css";
|
||||||
|
|
||||||
|
const auth: AuthInfo = {
|
||||||
|
user: { id: "search-user", account_id: "search-account", email: "search@example.test" },
|
||||||
|
tenant: { id: "search-tenant", name: "Search fixture", slug: "search" },
|
||||||
|
scopes: ["search:result:read"], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||||
|
};
|
||||||
|
const modules: PlatformWebModule[] = [
|
||||||
|
{ id: "files", label: "Files", version: "1", uiCapabilities: { "search.contexts": { contexts: [{ id: "files.current", moduleId: "files", label: "Current files", pathPrefixes: ["/files"], resourceTypes: ["file"] }] } } },
|
||||||
|
{ id: "mail", label: "Mail", version: "1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SearchFiltersScenario() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [account, setAccount] = useState("search-user");
|
||||||
|
const accessToken = params.has("same-token") ? "search-user" : account;
|
||||||
|
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||||
|
const scopedAuth = useMemo(() => ({ ...auth,
|
||||||
|
user: { ...auth.user, id: account, account_id: `${account}-account` },
|
||||||
|
tenant: { ...auth.tenant, id: `${account}-tenant` },
|
||||||
|
}), [account]);
|
||||||
|
useEffect(() => {
|
||||||
|
const changeAccount = () => setAccount("other-user");
|
||||||
|
window.addEventListener("conformance-search-account", changeAccount);
|
||||||
|
return () => window.removeEventListener("conformance-search-account", changeAccount);
|
||||||
|
}, []);
|
||||||
|
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[searchFilterTranslations]}>
|
||||||
|
<PlatformModulesProvider modules={modules}>
|
||||||
|
{params.has("overlay") ? <GlobalSearch settings={settings} auth={scopedAuth} /> : <SearchPage settings={settings} auth={scopedAuth} />}
|
||||||
|
</PlatformModulesProvider>
|
||||||
|
</PlatformLanguageProvider>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
import ConformanceApp from "./ConformanceApp";
|
import ConformanceApp from "./ConformanceApp";
|
||||||
|
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
|
||||||
|
import { productSurfaceTranslations } from "../src/index";
|
||||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||||
@@ -14,6 +16,7 @@ import "../src/styles/badges.css";
|
|||||||
import "../src/styles/components.css";
|
import "../src/styles/components.css";
|
||||||
import "../src/styles/dialogs.css";
|
import "../src/styles/dialogs.css";
|
||||||
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
||||||
|
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
|
||||||
import "./conformance.css";
|
import "./conformance.css";
|
||||||
|
|
||||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||||
@@ -35,9 +38,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||||
<PlatformLanguageProvider preferredLanguageCode="de">
|
<PlatformLanguageProvider
|
||||||
|
preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? (["navigation-layout", "managed-archive"].some((key) => new URLSearchParams(window.location.search).has(key)) ? "en" : "de")}
|
||||||
|
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<ConformanceApp />
|
<Routes>
|
||||||
|
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
|
||||||
|
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
|
||||||
|
<Route path="*" element={<ConformanceApp />} />
|
||||||
|
</Routes>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</PlatformLanguageProvider>
|
</PlatformLanguageProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Password field loading conformance</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./PasswordFieldMain.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function mockAddresses(page: Page) {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const writes: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const timestamp = "2026-09-01T12:00:00Z";
|
||||||
|
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||||
|
const request = route.request();
|
||||||
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (request.method() !== "GET") writes.push(path);
|
||||||
|
let body: unknown = {};
|
||||||
|
if (path === "/api/v1/addresses/address-books") body = { address_books: [{
|
||||||
|
id: "book-fixture", name: "Fixture address book", scope_type: "tenant",
|
||||||
|
source_kind: "local", read_only: false, contact_count: 0, created_at: timestamp, updated_at: timestamp
|
||||||
|
}] };
|
||||||
|
else if (path === "/api/v1/addresses/address-lists") body = { address_lists: [{
|
||||||
|
id: "list-fixture", address_book_id: "book-fixture", name: "Fixture address list",
|
||||||
|
source_kind: "local", read_only: false, entry_count: 0, created_at: timestamp, updated_at: timestamp
|
||||||
|
}] };
|
||||||
|
else if (path === "/api/v1/addresses/sync-sources") body = { sync_sources: [] };
|
||||||
|
else if (path === "/api/v1/addresses/contacts") body = { contacts: [], total: 0, limit: 50, offset: 0 };
|
||||||
|
else if (path.endsWith("/entries")) body = { entries: [] };
|
||||||
|
return route.fulfill({ json: body });
|
||||||
|
});
|
||||||
|
return { errors, writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Addressbook keeps Reload/New together and moves specialist actions out of the narrow tree header", async ({ page }) => {
|
||||||
|
const fixture = await mockAddresses(page);
|
||||||
|
await page.setViewportSize({ width: 1360, height: 900 });
|
||||||
|
await page.goto("/?address-explorer&language=en&theme=light");
|
||||||
|
const toolbar = page.getByRole("toolbar", { name: "Address book actions", exact: true });
|
||||||
|
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
|
||||||
|
const create = toolbar.getByRole("button", { name: "Add address book", exact: true });
|
||||||
|
await expect(reload).toBeEnabled();
|
||||||
|
await expect(create).toBeVisible();
|
||||||
|
const [reloadBox, createBox] = await Promise.all([reload.boundingBox(), create.boundingBox()]);
|
||||||
|
expect(createBox!.x).toBeGreaterThan(1100);
|
||||||
|
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
|
||||||
|
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
|
||||||
|
await expect(page.locator('.address-tree-header button')).toHaveCount(1);
|
||||||
|
await toolbar.getByRole("button", { name: "Import / export", exact: true }).click();
|
||||||
|
const transfer = page.getByRole("dialog", { name: "Import / export", exact: true });
|
||||||
|
await expect(transfer.getByRole("button", { name: "Import contacts", exact: true })).toBeVisible();
|
||||||
|
await expect(transfer.getByRole("button", { name: "Export address book", exact: true })).toBeVisible();
|
||||||
|
await expect(transfer.getByRole("combobox", { name: "vCard export version" })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await toolbar.getByRole("button", { name: "Connections", exact: true }).click();
|
||||||
|
const connections = page.getByRole("dialog", { name: "Connections", exact: true });
|
||||||
|
await expect(connections.getByRole("button", { name: "Connect CardDAV", exact: true })).toBeVisible();
|
||||||
|
await expect(connections.getByRole("button", { name: "Connect LDAP or Active Directory", exact: true })).toBeVisible();
|
||||||
|
await expect(connections.getByRole("button", { name: "Run sync", exact: true })).toBeDisabled();
|
||||||
|
expect(fixture.errors).toEqual([]);
|
||||||
|
expect(fixture.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Addressbook labels select only; folder buttons toggle, including after Reload", async ({ page }) => {
|
||||||
|
const fixture = await mockAddresses(page);
|
||||||
|
await page.goto("/?address-explorer&language=en&theme=light");
|
||||||
|
const root = page.locator('.address-tree-list > .explorer-tree-children > div').first();
|
||||||
|
const label = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-node');
|
||||||
|
const folder = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-toggle');
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await label.click();
|
||||||
|
await expect(label).toHaveAttribute("aria-current", "true");
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await expect(page.getByText("Select an address book in this group.", { exact: false })).toBeVisible();
|
||||||
|
await folder.click();
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await label.click();
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await page.locator('[data-page-action-slot="reload"] button').click();
|
||||||
|
await expect(page.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await expect(label).toHaveAttribute("aria-current", "true");
|
||||||
|
await folder.click();
|
||||||
|
const book = page.locator('.address-tree-list .explorer-tree-node').filter({ hasText: "Fixture address book" });
|
||||||
|
await book.click();
|
||||||
|
await expect(book).toHaveAttribute("aria-current", "true");
|
||||||
|
await page.locator('.address-tree-header').getByRole("button", { name: "Manage", exact: true }).click();
|
||||||
|
const manage = page.getByRole("dialog", { name: "Manage selected book or list", exact: true });
|
||||||
|
await expect(manage.getByRole("button", { name: "Add address list", exact: true })).toBeVisible();
|
||||||
|
await expect(manage.getByRole("button", { name: "Edit address book", exact: true })).toBeVisible();
|
||||||
|
await expect(manage.locator('.form-section-separated').getByRole("button", { name: "Delete address book", exact: true })).toBeVisible();
|
||||||
|
expect(fixture.errors).toEqual([]);
|
||||||
|
expect(fixture.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Addressbook specialist controls retain permission blockers in German", async ({ page }) => {
|
||||||
|
const fixture = await mockAddresses(page);
|
||||||
|
await page.goto("/?address-explorer&language=de&read-only&theme=light");
|
||||||
|
await expect(page.getByRole("button", { name: "Adressbuch hinzufügen", exact: true })).toBeDisabled();
|
||||||
|
await page.getByRole("button", { name: "Import / Export", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Import / Export", exact: true });
|
||||||
|
await expect(dialog.getByRole("button", { name: "Kontakte importieren", exact: true })).toBeDisabled();
|
||||||
|
await expect(dialog.getByRole("heading", { name: "In das ausgewählte Adressbuch importieren", exact: true })).toBeVisible();
|
||||||
|
expect(fixture.errors).toEqual([]);
|
||||||
|
expect(fixture.writes).toEqual([]);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user