Compare commits
89 Commits
e6f7c45f0a
...
v0.1.14
| Author | SHA1 | Date | |
|---|---|---|---|
| e11ea81008 | |||
| bc8afeb139 | |||
| f876345656 | |||
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 | |||
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 | |||
| c4b90181e0 | |||
| 55ed194a99 | |||
| b3b0cf0fca | |||
| fa9119bea7 | |||
| 70ca772138 | |||
| 2eae5c4df6 | |||
| 57fe6c6006 | |||
| 713afdb39b | |||
| 77f8d15d17 | |||
| fda99d40eb | |||
| 5ab1af803b | |||
| 0845e99cf6 | |||
| 28a0a596a6 | |||
| ae74189588 | |||
| 09b5009187 | |||
| 2ca61059dc | |||
| 865901f090 | |||
| 2ac1e64daa | |||
| 7526c5ebb2 | |||
| 8e1f64c790 | |||
| 66e4783d2e | |||
| 7af86b42eb | |||
| ad202f1267 | |||
| 6526f37aae | |||
| 9dabd9356d | |||
| 6502775bf7 | |||
| b2492b820f | |||
| 78d9ae48b2 | |||
| 4cb3e94de3 | |||
| 9131838b98 | |||
| 8e9eb6e1f5 | |||
| 249bf63eb8 | |||
| 248e3dc70e | |||
| 230ecf42b0 | |||
| 825791e9b0 | |||
| 7184b6cdd6 | |||
| ea8c600dce | |||
| 1839693575 | |||
| 183bf7aef0 | |||
| 37a5dfb182 | |||
| 844f934379 | |||
| a98475f7bc | |||
| 1153c9dd36 | |||
| 7eef52776c | |||
| c50ce58ad8 | |||
| 344fc0077f | |||
| 28afc01371 | |||
| 1a29e75db4 | |||
| 57ec960f40 | |||
| 6388afdad8 | |||
| 1a0e90b22d | |||
| 7ad6f6328a | |||
| c79a7124b7 | |||
| abbef5a10b | |||
| 2f559e3f0b | |||
| 9b5418db78 | |||
| 1678602fd6 | |||
| 6e373dcdd6 | |||
| d1c033edc7 | |||
| b5cfba666c | |||
| 78b4afdec4 | |||
| 15596f0742 | |||
| a2320fcb5d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -138,11 +138,13 @@ dist
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.file-drop-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.file-drop-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
|
||||
@@ -5,7 +5,11 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
try:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "govoplan_access":
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
@@ -20,7 +24,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
# Migrations can run inside the long-lived application process when module
|
||||
# state changes. Do not let Alembic's logging setup disable loggers that the
|
||||
# server already created (for example slow-request diagnostics).
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
|
||||
def _target_metadata():
|
||||
|
||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
||||
```
|
||||
|
||||
The integration check runs migrations and startup smoke checks across the
|
||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
||||
on throwaway databases.
|
||||
standard module permutations. It first requires the retirement atomicity proof,
|
||||
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||
release evidence.
|
||||
|
||||
### Broker And Workers
|
||||
|
||||
@@ -153,16 +157,24 @@ on throwaway databases.
|
||||
| --- | --- | --- |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
||||
|
||||
Worker command:
|
||||
|
||||
```bash
|
||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||
--queues send_email,append_sent,default \
|
||||
--queues send_email,append_sent,notifications,calendar,default \
|
||||
--loglevel INFO
|
||||
```
|
||||
|
||||
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||
crashes, and expired worker leases:
|
||||
|
||||
```bash
|
||||
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
| Setting | Default | Notes |
|
||||
@@ -184,15 +196,60 @@ prefer `FILE_STORAGE_*`.
|
||||
| Setting | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
||||
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
|
||||
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
|
||||
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
||||
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
||||
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
||||
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
||||
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
|
||||
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
|
||||
|
||||
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||
when Redis is reachable so replicas share state; a bounded process-local
|
||||
fallback keeps development and Redis outages functional, with per-process
|
||||
enforcement until Redis recovers.
|
||||
|
||||
### Outbound Connector Egress
|
||||
|
||||
| Setting | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
|
||||
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
|
||||
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
|
||||
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
|
||||
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
|
||||
|
||||
Production-like configuration validation requires the private-network choice to
|
||||
be explicit. HTTP connector downloads are streamed up to the configured bound,
|
||||
and credential-bearing DAV redirects remain confined to their configured
|
||||
origin.
|
||||
|
||||
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
|
||||
connect to the same approved address record while retaining the original host
|
||||
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
|
||||
fails closed in both public-only and private-network deployments: the current
|
||||
SDK transports cannot pin every initial and secondary peer or revalidate every
|
||||
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
|
||||
rule. Production deployments should still enforce the same decision at their
|
||||
worker/container egress firewall or outbound proxy as a second boundary.
|
||||
|
||||
File connector TLS verification may be disabled only in dev/test. A custom CA
|
||||
bundle must be an existing regular file whose resolved absolute path is listed
|
||||
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
|
||||
credentials are supported only in deployment-owned connector JSON and require
|
||||
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
|
||||
must use encrypted stored credentials or a scoped secret-provider reference.
|
||||
|
||||
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||
module settings. Do not hardcode them in core; configuration packages should ask
|
||||
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
||||
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
|
||||
aligned with the reverse proxy and do not expose the application server directly
|
||||
through the same trusted address range.
|
||||
|
||||
### Module Catalogs, Licenses, And Trust Roots
|
||||
|
||||
@@ -275,7 +332,7 @@ the checked in `.env.example`. It runs:
|
||||
- explicit `ENABLED_MODULES`
|
||||
- explicit migrations and `--with-dev-data` bootstrap
|
||||
- API via the module-aware devserver
|
||||
- a Celery worker for `send_email,append_sent,default`
|
||||
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
||||
- WebUI through the Vite dev server
|
||||
- durable local files under `runtime/production-like/files`
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
# GovOPlaN Master Roadmap
|
||||
|
||||
This roadmap is the durable product north star and sequencing guide for
|
||||
GovOPlaN as a modular platform for administrative operations. It keeps the
|
||||
product moving without turning every possible public-sector need into an
|
||||
immediate implementation track.
|
||||
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||
a modular platform for administrative operations. It translates the
|
||||
cross-product outcome horizons into dependency waves without turning every
|
||||
possible public-sector need into an immediate implementation track.
|
||||
|
||||
Use this document for product direction, sequencing, and module routing. Issues
|
||||
are the active backlog; this document is durable planning context and should be
|
||||
mirrored to the Gitea wiki.
|
||||
Use this document for technical sequencing, module routing, and implementation
|
||||
gates. Issues are the active backlog; this document is durable architecture
|
||||
planning context and should be mirrored to the Gitea wiki.
|
||||
|
||||
The meta repository's
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
describes the corresponding cross-product stakeholder visions, configurable
|
||||
service and operating configurations, connected outcome stories, and
|
||||
capability horizons. The selected five-stage delivery sequence and its gates
|
||||
are in the meta repository's
|
||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
Those product documents are canonical; this Core roadmap remains their
|
||||
technical sequencing and module-routing companion.
|
||||
|
||||
## Product Thesis
|
||||
|
||||
@@ -113,7 +123,8 @@ pattern exists.
|
||||
|
||||
## Focus Rules
|
||||
|
||||
1. Build one reference journey per wave.
|
||||
1. Build one selected reference journey stage at a time; a later capability
|
||||
cluster is not an active program merely because it appears below.
|
||||
2. Do not implement a module because the repository exists.
|
||||
3. Do not add module-to-module imports for optional behavior.
|
||||
4. Every new domain module must justify its own semantics beyond `cases`,
|
||||
@@ -138,11 +149,15 @@ pattern exists.
|
||||
| Internal work queues and tasks | `govoplan-tasks` |
|
||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||
| Canonical subjects and account links | `govoplan-identity` |
|
||||
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
||||
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||
| Service directory/catalog | `govoplan-portal` |
|
||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
||||
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||
| External software integration | `govoplan-connectors` |
|
||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
|
||||
|
||||
## Reference Journeys
|
||||
|
||||
The roadmap should be driven by three journeys.
|
||||
The active sequence is selected. Workflow remains deliberately deferred and is
|
||||
not a dependency of these journeys.
|
||||
|
||||
### Journey 1: Permit To Payment
|
||||
### Journey 1: Campaign Demonstration Composition
|
||||
|
||||
This is the primary public-administration journey.
|
||||
Campaign is the first complete proof of modular composition. Campaign owns
|
||||
intent, recipient snapshots, personalization, execution state, and delivery
|
||||
evidence. Mail owns reusable profiles, credentials, protocol policy, and
|
||||
provider execution; Campaign stores only a selected profile reference. Files
|
||||
owns storage, connector profiles, file policy, and provenance.
|
||||
|
||||
1. A person applies for a permit through the public portal.
|
||||
2. The applicant uploads required files and submits structured form data.
|
||||
3. Submission creates a case, a workflow instance, and an internal task.
|
||||
4. Completing the task creates a postbox message, a notification, and an email
|
||||
notification with an appointment proposal.
|
||||
5. The applicant accepts an appointment, which updates the calendar and the
|
||||
workflow state.
|
||||
6. During the appointment, the case is opened and the permit is generated from
|
||||
a governed template.
|
||||
7. The payment is processed and linked to the case and accounting handoff.
|
||||
8. The permit, payment evidence, communication history, audit trail, retention
|
||||
state, and records evidence remain available according to policy.
|
||||
The technical gate is a pinned Campaign/Mail/Files composition with central
|
||||
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
|
||||
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
|
||||
semantics. Readers must not receive backend paths, worker claims, secrets, or
|
||||
raw provider diagnostics.
|
||||
|
||||
This journey proves the platform can coordinate modules without core knowing
|
||||
module internals.
|
||||
### Journey 2: Function-Bound Postbox Delivery
|
||||
|
||||
### Journey 2: Training To Certificate
|
||||
Postbox accepts delivery to an addressable postbox or a function in an
|
||||
organizational unit. Organizations owns units and functions, Identity owns
|
||||
subjects, IDM owns identity-to-function assignments and upstream sync, and
|
||||
Access resolves current roles, delegation, acting context, and permission.
|
||||
|
||||
This is the best university-administration and internal-administration journey.
|
||||
Campaign consumes a typed delivery-target capability without importing Postbox
|
||||
or identity internals. Reassignment changes future access without moving the
|
||||
message; vacancy or ambiguous acting context fails visibly; delivery, access,
|
||||
and correction remain auditable.
|
||||
|
||||
1. Course or training offer is planned.
|
||||
2. Room, trainer, resource, and capacity are booked.
|
||||
3. Participants register or are assigned.
|
||||
4. Attendance is tracked.
|
||||
5. Certificate or participation confirmation is issued.
|
||||
6. Evidence remains available through records, files, audit, and docs.
|
||||
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
|
||||
|
||||
This journey keeps `booking`, `resources`, `learning`, and `certificates`
|
||||
focused instead of becoming broad ERP replacements.
|
||||
An authenticated user follows an opaque, short-lived launch reference from HIS
|
||||
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
|
||||
curated data context server-side, displays source/freshness/version, and renders
|
||||
one reproducible document and report.
|
||||
|
||||
### Journey 3: Report To Resolution
|
||||
Templates owns definition/version/schema/rendering, Reporting owns source
|
||||
selection/parameters/execution/export, Files owns generated bytes, and
|
||||
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
|
||||
or trusted raw personal data. Retries are idempotent and generation evidence
|
||||
connects source, snapshot/reference, transformation, definition, parameters,
|
||||
output checksum, actor, and policy.
|
||||
|
||||
This is the internal operations and municipal issue-reporting journey.
|
||||
### Journey 4: Governed University BI Path
|
||||
|
||||
1. A person reports an issue.
|
||||
2. The issue is triaged into helpdesk, facilities, assets, or a case.
|
||||
3. Work is assigned, tracked, and escalated.
|
||||
4. Evidence, communication, and status updates are preserved.
|
||||
5. Reports show workload, SLA, recurring problems, and completion.
|
||||
Starting from the Journey 3 source contract, one bounded university dataset is
|
||||
catalogued, staged by snapshot or watermark, validated, transformed through a
|
||||
versioned lineage graph, and exposed as a policy-aware analytical data product.
|
||||
The result must preserve official-key mappings, organizational and reporting
|
||||
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||
and reproducible promotion between development, test, and production.
|
||||
|
||||
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
|
||||
from becoming disconnected ticket silos.
|
||||
Reporting consumes the product. Create `govoplan-datasources` or
|
||||
`govoplan-dataflow` only after the concrete path proves repeated ownership that
|
||||
does not belong to connectors, Reporting, or the producing domain module.
|
||||
|
||||
## Roadmap Waves
|
||||
### Journey 5: Collaborative Document Lifecycle
|
||||
|
||||
An uploaded or generated artifact becomes a DMS document. Files continues to
|
||||
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
|
||||
comments, review, approval, comparison, and recovery; a collaboration connector
|
||||
owns provider-specific protocol behavior; Records owns later classification,
|
||||
hold, archive, and disposal.
|
||||
|
||||
The gate requires no silent lost updates, short-lived and currently authorized
|
||||
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
|
||||
immutable accepted renditions, and a Records-ready handoff with stable content
|
||||
and provenance.
|
||||
|
||||
## Capability Dependency Waves
|
||||
|
||||
The waves below remain a dependency and ownership catalogue for the wider
|
||||
product vision. They are not the active delivery order. The five selected
|
||||
journeys above and the meta roadmap decide what is implemented now; other
|
||||
clusters remain dormant until a selected journey consumes them or they are
|
||||
explicitly reprioritized.
|
||||
|
||||
### Wave 0: Platform Spine
|
||||
|
||||
@@ -248,9 +289,13 @@ Refine:
|
||||
|
||||
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
||||
catalog, configuration package runtime, WebUI shell.
|
||||
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
|
||||
memberships, function assignments, delegation, RBAC decisions.
|
||||
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
|
||||
- `govoplan-identity`: canonical identities and account links.
|
||||
- `govoplan-organizations`: organizational structures, units, and functions.
|
||||
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
|
||||
preview, conflicts, and reconciliation.
|
||||
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
|
||||
function-to-role projection, delegation, acting context, and RBAC decisions.
|
||||
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
|
||||
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
||||
directory, assurance, and later encrypted postbox key access.
|
||||
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
||||
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
|
||||
- smoke test or permutation test
|
||||
- no required imports from optional modules
|
||||
|
||||
## Priority Order Summary
|
||||
## Technical Dependency Order Summary
|
||||
|
||||
1. Stabilize the platform spine.
|
||||
2. Deliver permit-to-payment MVP.
|
||||
3. Build booking and resource operations.
|
||||
4. Add learning and certificates.
|
||||
5. Add issue reporting and helpdesk.
|
||||
6. Add records, DMS, search, and transparency.
|
||||
7. Add procurement, contracts, grants, and finance handoff.
|
||||
8. Add committee and consultation workflows.
|
||||
9. Expand integration, dataflow, reporting, and operations.
|
||||
Use this active order while respecting the ownership and implementation gates
|
||||
in the capability waves:
|
||||
|
||||
1. Keep the platform/release spine green and extend connector, identity,
|
||||
external-effect, provenance, documentation, focused-view, recovery, and
|
||||
version contracts only as the current journey requires.
|
||||
2. Complete and package Campaign with Mail-owned profiles, Files, target
|
||||
delivery/recovery, central UI, and adaptive documentation.
|
||||
3. Implement function-bound Postbox delivery through
|
||||
Organizations–Identity–IDM–Access and consume it from Campaign through a
|
||||
typed capability.
|
||||
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||
launch.
|
||||
5. Extend that concrete source into one governed university analytical data
|
||||
product before generalizing data-source or dataflow ownership.
|
||||
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||
editing lifecycle, then connect Records handoff.
|
||||
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||
activate another capability cluster only when the current journey needs it
|
||||
or the product roadmap explicitly reprioritizes it.
|
||||
8. Resume Workflow only by explicit product decision and constrain it with
|
||||
stable actions from one demonstrated package.
|
||||
|
||||
## Deliberate Deferrals
|
||||
|
||||
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
|
||||
|
||||
- full ERP replacement
|
||||
- native project management beyond connector support
|
||||
- broad BI/dataflow platform
|
||||
- an unbounded general-purpose dataflow platform; the bounded governed BI
|
||||
reference journey is selected
|
||||
- every possible public-sector protocol adapter
|
||||
- rich LMS behavior beyond training administration
|
||||
- full qualified digital signing/trust services beyond the identity-trust and
|
||||
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
|
||||
|
||||
## Next Practical Work
|
||||
|
||||
The next planning step should create or update Gitea issues for Wave 0 and Wave
|
||||
1 only. Later waves should stay as roadmap context until the permit-to-payment
|
||||
MVP is demonstrable.
|
||||
The active cross-product story is
|
||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
||||
Module repositories own implementation issues; do not clone their state here.
|
||||
|
||||
Recommended immediate issue buckets:
|
||||
Immediate issue buckets:
|
||||
|
||||
- platform spine hardening
|
||||
- configuration package preflight and rollback
|
||||
- forms-runtime MVP
|
||||
- portal submission MVP
|
||||
- cases/workflow/tasks integration MVP
|
||||
- template-generated decision document
|
||||
- postbox/notification handoff
|
||||
- appointment/booking handoff
|
||||
- payment evidence handoff
|
||||
- configured documentation for the reference process
|
||||
- fail-closed connector destination pinning and private-network deployment
|
||||
control for every real transport
|
||||
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
|
||||
- immediate audited secret deletion when a provider/profile is removed
|
||||
- Campaign central-component and role-safe UI acceptance
|
||||
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
|
||||
security/acceptance documentation
|
||||
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
|
||||
restore proof for the pinned Campaign reference composition
|
||||
|
||||
Once that gate is demonstrable, activate the existing Postbox model, access,
|
||||
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
|
||||
and DMS collaboration remain durable selected direction, but should be
|
||||
decomposed only as the preceding stage stabilizes or a bounded independent
|
||||
contract can be implemented without pre-deciding target-system choices.
|
||||
|
||||
@@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of
|
||||
access/tenant ORM models when they need labels, group membership, default
|
||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||
|
||||
Other stable runtime capabilities currently include:
|
||||
|
||||
- `identity.directory` and `identity.search`
|
||||
- `organizations.directory`
|
||||
- `idm.directory`
|
||||
- `calendar.outbox` and `calendar.scheduling`
|
||||
- `poll.scheduling`
|
||||
- `notifications.dispatch`
|
||||
|
||||
### Named Interface Contracts
|
||||
|
||||
Capabilities are runtime objects. Named interface contracts are compatibility
|
||||
@@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
||||
allowed, but an installed provider with an incompatible version blocks
|
||||
activation because the integration would otherwise bind to an unsafe API.
|
||||
|
||||
Current named interfaces:
|
||||
Current named interfaces, generated from the source manifests by the workspace
|
||||
contract checks, are:
|
||||
|
||||
- `files.campaign_attachments`
|
||||
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
||||
- `calendar.outbox`, `calendar.scheduling`
|
||||
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||
`campaigns.retention`
|
||||
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
|
||||
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
|
||||
- `files.access`, `files.campaign_attachments`
|
||||
- `mail.campaign_delivery`
|
||||
- `campaigns.access`
|
||||
- `campaigns.delivery_tasks`
|
||||
- `campaigns.mail_policy_context`
|
||||
- `campaigns.policy_context`
|
||||
- `campaigns.retention`
|
||||
- `notifications.dispatch`
|
||||
- `poll.availability_matrix`, `poll.option_selection`,
|
||||
`poll.response_collection`, `poll.signed_participation`,
|
||||
`poll.workflow_context`
|
||||
- `rest.function_publication`
|
||||
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
|
||||
- `soap.operation_publication`
|
||||
|
||||
Core validates named interface contracts in three places:
|
||||
|
||||
@@ -434,6 +453,18 @@ The manifest should declare:
|
||||
- navigation metadata using serializable icon names
|
||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
||||
|
||||
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
|
||||
only when every authenticated tenant member must receive that narrow baseline
|
||||
while the contributing module is installed. Access derives the explicit grant
|
||||
from the active manifest set during authorization without mutating the request
|
||||
transaction. It may materialize a non-assignable role row for administration,
|
||||
but no per-user assignment is required and role edits cannot remove the
|
||||
baseline.
|
||||
This is not a shortcut for feature authorization: keep the template narrow and
|
||||
continue to enforce each domain action's own permission and resource policy.
|
||||
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
|
||||
templates are rejected by registry validation.
|
||||
|
||||
Backend nav metadata must use icon-name strings, not frontend components:
|
||||
|
||||
```python
|
||||
|
||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
||||
- key rotation and epoch tracking
|
||||
- recovery policy hooks
|
||||
|
||||
Recovery must be organizationally governed. A server-held universal plaintext
|
||||
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||
user-visible consequence.
|
||||
|
||||
## Role And Function Postboxes
|
||||
|
||||
Role-bound access needs special handling. A postbox can be bound to an
|
||||
@@ -75,6 +80,30 @@ rewrapping service:
|
||||
Key epochs are required when role membership changes. Older messages may remain
|
||||
readable according to policy, but new access must use the current epoch.
|
||||
|
||||
The function-bound container exists independently of membership. It may remain
|
||||
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||
the postbox is never copied into their account ownership.
|
||||
|
||||
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||
encryption permits the normal rotation path to rewrap per-message data keys
|
||||
rather than rewrite large ciphertext objects; a security policy may require
|
||||
full content re-encryption for selected compromise or cryptographic-profile
|
||||
events. The history available to a new incumbent must be selected policy (all
|
||||
retained history, a bounded historical window, or assignment-time content) and
|
||||
recorded with the grant.
|
||||
|
||||
Delegation is a time-bounded represented-function grant, not a copy or
|
||||
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||
remain distinguishable in key-fetch and action evidence.
|
||||
|
||||
Postbox content and signed manifests are immutable. Correction or replacement
|
||||
creates a linked new object/version; it never silently substitutes ciphertext
|
||||
or evidence that another actor may already have inspected.
|
||||
|
||||
## External Recipients
|
||||
|
||||
External recipients may need one-time or time-limited access without a full
|
||||
|
||||
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
|
||||
Update those refs when cutting a release:
|
||||
|
||||
```text
|
||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
|
||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
|
||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
|
||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
|
||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
|
||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
|
||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
|
||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
|
||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
|
||||
```
|
||||
|
||||
## WebUI Packages
|
||||
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||
```
|
||||
|
||||
The module repositories include root-level npm package manifests so git
|
||||
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
|
||||
`@govoplan/files-webui`, `@govoplan/mail-webui`,
|
||||
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
|
||||
roots even though their source lives below `webui/src`.
|
||||
Module repositories with a frontend include root-level npm package manifests
|
||||
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||
resolved from repository roots even though their source lives below
|
||||
`webui/src`.
|
||||
|
||||
### Release Lockfile Strategy
|
||||
|
||||
The supported release composition currently is the full GovOPlaN product: core
|
||||
plus access, admin, tenancy, organizations, identity, policy, audit,
|
||||
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
|
||||
full-product release lockfile at
|
||||
The supported backend release composition is the set pinned in the meta
|
||||
repository's `requirements-release.txt`. The supported frontend composition
|
||||
is the independently buildable module set pinned in
|
||||
`webui/package.release.json`; backend-only modules do not need a frontend
|
||||
package entry. Keep one committed full-product release lockfile at
|
||||
`webui/package-lock.release.json`, generated from
|
||||
`webui/package.release.json` in a clean release workspace. Development
|
||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/release/push-release-tag.sh --version 0.1.6
|
||||
tools/release/push-release-tag.sh --version 0.1.8
|
||||
```
|
||||
|
||||
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
||||
@@ -134,13 +143,11 @@ Current tag-only module repositories:
|
||||
- `govoplan-fit-connect`
|
||||
- `govoplan-forms`
|
||||
- `govoplan-identity-trust`
|
||||
- `govoplan-idm`
|
||||
- `govoplan-ledger`
|
||||
- `govoplan-notifications`
|
||||
- `govoplan-payments`
|
||||
- `govoplan-portal`
|
||||
- `govoplan-postbox`
|
||||
- `govoplan-reporting`
|
||||
- `govoplan-scheduling`
|
||||
- `govoplan-search`
|
||||
- `govoplan-tasks`
|
||||
- `govoplan-templates`
|
||||
@@ -781,7 +788,18 @@ tools/checks/postgres-integration-check.py \
|
||||
The script checks migrations and `/health` startup for core-only, files-only,
|
||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||
module sets. `--reset-schema` is destructive and must only be used against a
|
||||
throwaway database.
|
||||
throwaway database. Before those permutations, the required Core proof runs in
|
||||
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||
credential-owning retirement provider and proves that credential scrubbing,
|
||||
non-secret audit
|
||||
insertion, and table retirement commit together; database-injected audit and
|
||||
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||
installer Session connection instead of waiting through a second connection.
|
||||
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||
collecting release evidence.
|
||||
|
||||
## Migration Baselines
|
||||
|
||||
|
||||
@@ -40,10 +40,16 @@ set +a
|
||||
|
||||
The command reports all known blockers at once. Production-like/self-hosted
|
||||
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
||||
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
|
||||
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
|
||||
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
|
||||
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||
catalog source is configured.
|
||||
|
||||
Connector process-secret names and custom CA files are deployment-owned through
|
||||
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
|
||||
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
|
||||
either boundary.
|
||||
|
||||
## Production-Like Dev Stack
|
||||
|
||||
Use the local production-like wrapper for repeatable rehearsal:
|
||||
|
||||
21
docs/THROTTLING.md
Normal file
21
docs/THROTTLING.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Shared fixed-window throttling
|
||||
|
||||
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||
become store keys. A configured Redis instance provides atomic counters shared
|
||||
across API workers; development, a missing Redis configuration, and temporary
|
||||
Redis outages use a bounded process-local fallback. When Redis fails, local
|
||||
attempts are still mirrored so losing the distributed store does not reset the
|
||||
active worker's protection window.
|
||||
|
||||
Callers define one or more `ThrottleDimension` values with a controlled
|
||||
namespace, a subject and a positive limit. They must call `check` before an
|
||||
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||
dimension after successful verification. A blocked decision includes a
|
||||
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||
|
||||
The first consumer is Scheduling's anonymous participation password challenge.
|
||||
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||
login throttle predates this primitive and should be migrated onto it in a
|
||||
separate compatibility-preserving slice.
|
||||
@@ -39,6 +39,17 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -156,6 +167,104 @@ shared CSS tokens and persisted user preference selection.
|
||||
- Visual preview in settings is illustrative; it must reflect token families,
|
||||
not become a second theme implementation.
|
||||
|
||||
### DUE-009: Central Component And Exception Contract
|
||||
|
||||
Decision: module interfaces are compositions of the components exported by
|
||||
`@govoplan/core-webui`. When Core already owns the matching interaction, using
|
||||
the central component is required rather than preferred.
|
||||
|
||||
A route or domain-specific page composed from central components is ordinary
|
||||
module composition. A new reusable UI control, presentation primitive, or
|
||||
module-local substitute is a custom component. Before one is implemented, the
|
||||
product owner must explicitly authorize it and the owning decision or issue must
|
||||
record:
|
||||
|
||||
- its single, narrowly defined purpose and intended consumers
|
||||
- why central components or their composition cannot meet that purpose
|
||||
- the permitted scope and the boundary it must not grow beyond
|
||||
- accessibility, reachable states, theme behavior, and test expectations
|
||||
- whether the component remains domain-owned or is a candidate for Core
|
||||
|
||||
Custom components must not duplicate, fork, or cosmetically replace a central
|
||||
component. An existing local implementation does not grant an exception. If a
|
||||
central contract later covers the need, migrate to it unless the product owner
|
||||
explicitly retains the exception.
|
||||
|
||||
### DUE-010: Scheduling Request Reference Composition
|
||||
|
||||
Decision: Scheduling requests provide a concrete reference application of the
|
||||
universal placement and component rules.
|
||||
|
||||
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||
requests for me`; it is list context, not a second creation affordance.
|
||||
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||
the shared view/create/edit surface in the right main panel.
|
||||
- Basic information, Calendar integration, candidate slots, and participants
|
||||
use the central `Card` component as four logical sections.
|
||||
- Candidate slots and participants use the central `DataGrid`, including its
|
||||
standard row-action placement and order.
|
||||
- Calendar integration uses the central `ToggleSwitch`, with its dependent
|
||||
controls shown when enabled.
|
||||
- Each participant is edited as one structured row with name, email address,
|
||||
and actions. The normal editor does not parse a free-form list of addresses;
|
||||
that interaction requires a separate, explicitly designed bulk-import flow.
|
||||
|
||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||
not Scheduling-local component variants.
|
||||
|
||||
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||
|
||||
Decision: the central components own these interactions; modules compose them
|
||||
instead of reproducing their behavior.
|
||||
|
||||
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||
browser-controlled confirmation because browsers do not permit a custom
|
||||
modal at that boundary.
|
||||
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||
`disabledReason` for row state; omit an action only when that action does not
|
||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||
complete logical row set and applies filtering and sorting before slicing a
|
||||
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||
and the backend applies every emitted filter/sort before pagination while
|
||||
returning `totalRows` for the filtered result. Server list filters declare
|
||||
their complete option domain instead of deriving it from the loaded page.
|
||||
External filter affordances such as summary-count shortcuts update the
|
||||
grid's `query` contract; the grid header controls and backend query therefore
|
||||
always display and execute the same filter state.
|
||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||
|
||||
#### FieldLabel Omission Register
|
||||
|
||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||
listed here. Module repositories keep an equivalent register in their durable
|
||||
UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||
|
||||
#### Alert Exception Register
|
||||
|
||||
No `window.alert` or global `alert` exception is authorized.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
| Phase | Scope | Output |
|
||||
@@ -218,6 +327,22 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Does the action surface show consequence, reversibility, and audit evidence
|
||||
when rights, duties, records, money, communication, external systems, or
|
||||
workflow state are affected?
|
||||
- Does the surface use every applicable central Core component? If it contains
|
||||
a custom component, is the product-owner authorization, narrow purpose,
|
||||
rationale, scope, and non-duplication evidence recorded?
|
||||
- Is a collection-wide create action in the collection heading rather than
|
||||
duplicated in a persistent side panel?
|
||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||
structured row per person respectively?
|
||||
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||
recorded with its rationale and accessible-name source?
|
||||
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||
registration/dialog rather than a page-local confirmation?
|
||||
- Does every row retain the table's action set in the same order, disabling
|
||||
unavailable actions and reserving the same empty-row slots?
|
||||
- Is feedback rendered with a central dialog/alert component, with no
|
||||
unauthorized `window.alert` or global `alert` call?
|
||||
- If automation is involved, can the user see the trigger, system actor,
|
||||
observed effects, and failure/manual-intervention state?
|
||||
- Are technical details available without being the first thing the user sees?
|
||||
|
||||
@@ -81,8 +81,588 @@
|
||||
],
|
||||
"recorded_at": "2026-07-11T01:39:45Z",
|
||||
"release": "0.1.7",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "2c3d4e5f7081"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "3d4e5f708192"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "9e0f1a2b3c4d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"9e0f1a2b3c4d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"2c3d4e5f7081"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"3d4e5f708192"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-20T02:45:41Z",
|
||||
"release": "0.1.8",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "4d5e6f7a9203"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"4d5e6f7a9203"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T02:42:27Z",
|
||||
"release": "0.1.11",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T07:06:21Z",
|
||||
"release": "0.1.12",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T08:39:02Z",
|
||||
"release": "0.1.13",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T18:15:01Z",
|
||||
"release": "0.1.14",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
@@ -84,7 +84,15 @@
|
||||
"provides_interfaces": [
|
||||
{
|
||||
"name": "mail.campaign_delivery",
|
||||
"version": "1.4.0"
|
||||
"version": "0.2.0"
|
||||
}
|
||||
],
|
||||
"requires_interfaces": [
|
||||
{
|
||||
"name": "campaigns.access",
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"artifact_integrity": {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.14"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -27,6 +27,12 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_core = ["py.typed"]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
"govoplan_core_runtime" = ["alembic.ini"]
|
||||
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||
|
||||
[project.scripts]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
@@ -42,3 +48,7 @@ dev = [
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5,<3",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/test_api_smoke.py" = ["E402"]
|
||||
"tests/test_module_system.py" = ["E402"]
|
||||
|
||||
@@ -201,7 +201,7 @@ class AuthGroupsResponse(BaseModel):
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
|
||||
expires_at: datetime
|
||||
user: UserInfo
|
||||
# Backwards-compatible alias for the active tenant.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Core auth dependency facade.
|
||||
|
||||
Routers depend on this module instead of a concrete access-provider package.
|
||||
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
|
||||
capability registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from celery import Celery
|
||||
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||
@@ -27,10 +28,18 @@ celery.conf.update(
|
||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
beat_schedule={
|
||||
"calendar-outbox-every-minute": {
|
||||
"task": "govoplan.calendar.dispatch_outbox",
|
||||
"schedule": 60.0,
|
||||
"args": (None, 100),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +76,16 @@ def _notification_dispatch() -> NotificationDispatchProvider:
|
||||
return capability
|
||||
|
||||
|
||||
def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||
registry = _platform_registry()
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||
if not isinstance(capability, CalendarOutboxProvider):
|
||||
raise RuntimeError("Calendar outbox capability is invalid")
|
||||
return capability
|
||||
|
||||
|
||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||
def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
@@ -102,7 +121,9 @@ def deliver_notification(self, notification_id: str):
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
||||
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
|
||||
@@ -110,4 +131,21 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
||||
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
||||
"""Drain durable Calendar operations; retry timing lives in the database."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
provider = _calendar_outbox()
|
||||
if provider is None:
|
||||
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
|
||||
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
98
src/govoplan_core/core/calendar.py
Normal file
98
src/govoplan_core/core/calendar.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||
|
||||
|
||||
class CalendarCapabilityError(ValueError):
|
||||
"""Stable error raised by Calendar capability implementations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarEventRequest:
|
||||
summary: str
|
||||
start_at: datetime
|
||||
calendar_id: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
status: str = "CONFIRMED"
|
||||
transparency: str = "OPAQUE"
|
||||
classification: str = "PUBLIC"
|
||||
end_at: datetime | None = None
|
||||
timezone: str | None = None
|
||||
attendees: tuple[Mapping[str, object], ...] = ()
|
||||
categories: tuple[str, ...] = ()
|
||||
related_to: tuple[Mapping[str, object], ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarEventRef:
|
||||
id: str
|
||||
calendar_id: str
|
||||
uid: str
|
||||
external_state: str = "local"
|
||||
outbox_operation_id: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarSchedulingProvider(Protocol):
|
||||
def list_freebusy(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
calendar_ids: Sequence[str] | None = None,
|
||||
) -> Sequence[Mapping[str, object]]:
|
||||
...
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarEventRequest,
|
||||
) -> CalendarEventRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarOutboxProvider(Protocol):
|
||||
"""Stable worker boundary for Calendar-owned external side effects."""
|
||||
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
|
||||
return capability if isinstance(capability, CalendarSchedulingProvider) else None
|
||||
|
||||
|
||||
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
from govoplan_core.security.permissions import scopes_grant
|
||||
from govoplan_core.security.redaction import contains_plain_secret
|
||||
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: ConfigurationRisk
|
||||
secret_handling: SecretHandling = "none"
|
||||
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
|
||||
maintenance_required: bool = False
|
||||
maintenance_satisfied: bool = False
|
||||
rollback_history_required: bool = False
|
||||
secret_handling: SecretHandling = "none"
|
||||
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||
audit_event: str | None = None
|
||||
policy_explanation: str | None = None
|
||||
blockers: tuple[str, ...] = ()
|
||||
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
required_scopes=("mail_servers:manage_credentials",),
|
||||
validation_required=True,
|
||||
audit_event="mail_server_profile.credential_updated",
|
||||
@@ -256,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
required_scopes=("files:file:admin",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="files.connector_profile.updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
||||
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="DATABASE_URL",
|
||||
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||
),
|
||||
@@ -285,11 +284,179 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
two_person_approval_required=True,
|
||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
label="Private-network connector access",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
label="Structured connector response limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
label="Connector file-transfer limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
label="Connector secret environment allowlist",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||
label="Connector CA bundle allowlist",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
label="HTTP request-body limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||
label="HTTP Strict Transport Security duration",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_ENABLED",
|
||||
label="Interactive login throttling",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Disabling the deployment login throttle weakens protection against password guessing.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||
label="Login failures per identity",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Shared through Redis when available, with a bounded process-local fallback.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||
label="Login failures per client",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||
label="Login throttle window",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Fixed counter window shared by identity and direct-client limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||
label="Login throttle Redis retry interval",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="medium",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_TRUSTED_HOSTS",
|
||||
label="Trusted HTTP hosts",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Host-header validation is deployment-managed and must cover every public API host.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="FORWARDED_ALLOW_IPS",
|
||||
label="Trusted reverse proxy addresses",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||
label="Module package catalog trusted keys",
|
||||
@@ -298,7 +465,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
@@ -309,7 +476,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
notes="Configuration package trust roots are deployment-managed.",
|
||||
),
|
||||
)
|
||||
@@ -414,9 +581,9 @@ def _configuration_change_safety_state(
|
||||
approval_satisfied = not approval_required or approval_count >= 2
|
||||
if approval_required and not approval_satisfied:
|
||||
blockers.append("two_person_approval_required")
|
||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
|
||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
|
||||
blockers.append("secret_reference_required")
|
||||
if field.secret_handling == "env_only" and value is not None:
|
||||
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
|
||||
blockers.append("env_only_secret")
|
||||
if field.rollback_history_required:
|
||||
warnings.append("rollback_history_required")
|
||||
@@ -466,7 +633,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
||||
parts.append("requires two-person approval")
|
||||
if field.maintenance_required:
|
||||
parts.append("requires maintenance mode")
|
||||
if field.secret_handling != "none":
|
||||
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
|
||||
parts.append(f"uses {field.secret_handling} secret handling")
|
||||
return "; ".join(parts) + "."
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
IDENTITY_MODULE_ID = "identity"
|
||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||
CAPABILITY_IDENTITY_SEARCH = "identity.search"
|
||||
|
||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||
|
||||
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentitySearchProvider(Protocol):
|
||||
def search_identities(
|
||||
self,
|
||||
query: str | None = None,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
limit: int = 25,
|
||||
) -> Sequence[IdentityRef]:
|
||||
...
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.engine import make_url
|
||||
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
|
||||
_validate_async_and_auth_settings(env, runtime, collector)
|
||||
_validate_cors_settings(env, runtime, collector)
|
||||
_validate_file_storage_settings(env, runtime, collector)
|
||||
_validate_outbound_connector_policy(env, runtime, collector)
|
||||
_validate_module_catalog_trust(env, runtime, collector)
|
||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||
|
||||
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||
if runtime.production_like and not trusted_hosts:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||
elif "*" in trusted_hosts and runtime.production_like:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||
|
||||
|
||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
|
||||
|
||||
def _validate_outbound_connector_policy(
|
||||
env: Mapping[str, str],
|
||||
runtime: _RuntimeProfile,
|
||||
collector: _ConfigIssueCollector,
|
||||
) -> None:
|
||||
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
"Private-network connector access must be an explicit deployment decision.",
|
||||
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||
)
|
||||
for key in (
|
||||
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
):
|
||||
value = _clean(env.get(key))
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||
secret_env_names = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
"Connector secret environment allowlist contains an invalid variable name.",
|
||||
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||
)
|
||||
ca_bundle_paths = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
if not runtime.production or not catalog_source:
|
||||
@@ -274,9 +338,32 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
||||
|
||||
CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,default
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
|
||||
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||
|
||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||
|
||||
CORS_ORIGINS=https://govoplan.example.org
|
||||
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
|
||||
# restricted to the actual reverse proxy address or network.
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
AUTH_COOKIE_SECURE=true
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
@@ -317,10 +404,28 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,default
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||
|
||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||
|
||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
AUTH_COOKIE_SECURE=false
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||
|
||||
@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
|
||||
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
||||
|
||||
def executor(execute_session: object, _module_id: str) -> None:
|
||||
if not hasattr(execute_session, "get_bind"):
|
||||
if not hasattr(execute_session, "connection"):
|
||||
raise RuntimeError("No database session is available for destructive table retirement.")
|
||||
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
|
||||
live_inspector = inspect(execute_bind)
|
||||
# Enlist schema retirement in the caller's active transaction. An
|
||||
# Engine returned by Session.get_bind() may acquire a second
|
||||
# connection, separating the DROP from module-specific secret
|
||||
# scrubbing/audit writes and deadlocking on their uncommitted locks.
|
||||
execute_connection = execute_session.connection() # type: ignore[attr-defined]
|
||||
live_inspector = inspect(execute_connection)
|
||||
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
|
||||
if not live_tables:
|
||||
return
|
||||
metadata = live_tables[0].metadata
|
||||
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
|
||||
metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
|
||||
|
||||
return MigrationRetirementPlan(
|
||||
supported=True,
|
||||
|
||||
@@ -15,7 +15,8 @@ import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import stat
|
||||
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
|
||||
import sys
|
||||
import tomllib
|
||||
from typing import Any, Literal
|
||||
@@ -579,7 +580,7 @@ def _prepare_module_install_run(
|
||||
) -> _ModuleInstallRunState:
|
||||
run_id = _run_id()
|
||||
run_dir = effective_runtime_dir / "runs" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=False)
|
||||
_create_private_installer_run_dir(run_dir)
|
||||
commands = structured_install_commands(
|
||||
plan,
|
||||
webui_root=webui_root,
|
||||
@@ -2445,8 +2446,8 @@ def _migration_provider_modules(
|
||||
target_metadata: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, tuple[tuple[str, str], ...]]:
|
||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for module_id, metadata in target_metadata.items():
|
||||
for provided in metadata.get("provides_interfaces", ()):
|
||||
for module_id, module_metadata in target_metadata.items():
|
||||
for provided in module_metadata.get("provides_interfaces", ()):
|
||||
if not isinstance(provided, Mapping):
|
||||
continue
|
||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
@@ -3828,14 +3829,47 @@ def _restore_external_database_snapshot(
|
||||
def _write_installer_secret(path: Path, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
path.write_text(value, encoding="utf-8")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not restrict installer secret file permissions for %s: %s", path, exc, exc_info=True)
|
||||
raise ModuleInstallerError(f"Could not create private installer secret file: {path.name}") from exc
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(value)
|
||||
except OSError as exc:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Could not write private installer secret file: {path.name}") from exc
|
||||
|
||||
try:
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as exc:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Could not verify private installer secret file: {path.name}") from exc
|
||||
if mode != 0o600:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Installer secret file permissions are not private: {path.name}")
|
||||
return path.name
|
||||
|
||||
|
||||
def _create_private_installer_run_dir(path: Path) -> None:
|
||||
path.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||
try:
|
||||
# mkdir's requested mode is still reduced by the process umask. Set the
|
||||
# exact owner-only mode so the directory remains usable with a strict
|
||||
# deployment umask while never retaining group/other access.
|
||||
path.chmod(0o700)
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as exc:
|
||||
path.rmdir()
|
||||
raise ModuleInstallerError("Could not secure the installer run directory") from exc
|
||||
if mode != 0o700:
|
||||
path.rmdir()
|
||||
raise ModuleInstallerError("Installer run directory permissions are not exactly owner-only")
|
||||
|
||||
|
||||
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
|
||||
secret_name = raw.get("database_url_secret")
|
||||
if isinstance(secret_name, str) and secret_name:
|
||||
|
||||
@@ -106,7 +106,8 @@ def installer_notification_body(event_kind: str, request: Mapping[str, object])
|
||||
run_id = _result_run_id(request)
|
||||
if run_id:
|
||||
return ". Run: ".join((sentence, run_id))
|
||||
return sentence + "."
|
||||
# This helper returns plain notification text, not an HTTP/HTML response.
|
||||
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
|
||||
|
||||
|
||||
def installer_notification_priority(status: str) -> int:
|
||||
|
||||
@@ -6,7 +6,6 @@ from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
@@ -45,6 +45,7 @@ class RoleTemplate:
|
||||
level: PermissionLevel = "tenant"
|
||||
managed: bool = True
|
||||
protected: bool = False
|
||||
default_authenticated: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -69,6 +70,15 @@ class FrontendRoute:
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicFrontendRoute:
|
||||
"""Explicitly allowlisted route that can render without authentication."""
|
||||
|
||||
path: str
|
||||
component: str
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendModule:
|
||||
module_id: str
|
||||
@@ -79,6 +89,7 @@ class FrontendModule:
|
||||
asset_manifest_integrity: str | None = None
|
||||
asset_manifest_contract_version: str = "1"
|
||||
routes: tuple[FrontendRoute, ...] = ()
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
|
||||
@@ -236,6 +247,35 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
Topic conditions are alternatives. Every alternative therefore needs an
|
||||
explicit scope constraint; otherwise one unscoped alternative would make
|
||||
the workflow visible regardless of the actor's task authority.
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||
return (
|
||||
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||
f"unscoped alternative(s): {alternatives}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationContext:
|
||||
registry: object
|
||||
|
||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||
|
||||
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||
# instance-wide canonical-identity directory and has no tenant/principal
|
||||
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||
# providers instead.
|
||||
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
)
|
||||
|
||||
|
||||
class PeopleSearchError(ValueError):
|
||||
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonSearchCandidate:
|
||||
"""A policy-filtered selection target returned by a directory provider."""
|
||||
|
||||
selection_key: str
|
||||
kind: str
|
||||
reference_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_module: str | None = None
|
||||
source_label: str | None = None
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
description: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeopleSearchGroup:
|
||||
key: str
|
||||
label: str
|
||||
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PeopleSearchProvider(Protocol):
|
||||
"""Server-side, principal-aware people/delivery-target search.
|
||||
|
||||
Implementations must derive visibility from ``principal`` and may only
|
||||
return records the principal can discover in its active tenant and policy
|
||||
context. Callers remain responsible for authorizing the task for which the
|
||||
picker is used (for example, creating a scheduling request).
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> Sequence[PeopleSearchGroup]:
|
||||
...
|
||||
|
||||
|
||||
def people_search_providers(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchProvider, ...]:
|
||||
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
|
||||
providers: list[PeopleSearchProvider] = []
|
||||
for capability_name in capability_names:
|
||||
if not registry.has_capability(capability_name):
|
||||
continue
|
||||
capability = registry.capability(capability_name)
|
||||
if isinstance(capability, PeopleSearchProvider):
|
||||
providers.append(capability)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def search_visible_people(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
"""Collect grouped results without importing optional feature modules.
|
||||
|
||||
``limit`` is enforced per provider so one directory cannot starve another
|
||||
result group. Providers are expected to return at most that many candidates
|
||||
in total. Duplicate group keys are merged and candidate selection keys are
|
||||
de-duplicated while preserving provider order.
|
||||
"""
|
||||
|
||||
normalized_query = str(query or "").strip()
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
ordered_group_keys: list[str] = []
|
||||
labels: dict[str, str] = {}
|
||||
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||
|
||||
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||
groups = provider.search_people(
|
||||
session,
|
||||
principal,
|
||||
query=normalized_query,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
provider_candidate_count = 0
|
||||
for group in groups:
|
||||
if not group.key:
|
||||
continue
|
||||
if group.key not in candidates_by_group:
|
||||
ordered_group_keys.append(group.key)
|
||||
labels[group.key] = group.label
|
||||
candidates_by_group[group.key] = []
|
||||
candidate_keys_by_group[group.key] = set()
|
||||
for candidate in group.candidates:
|
||||
if provider_candidate_count >= normalized_limit:
|
||||
break
|
||||
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||
continue
|
||||
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||
candidates_by_group[group.key].append(candidate)
|
||||
provider_candidate_count += 1
|
||||
|
||||
return tuple(
|
||||
PeopleSearchGroup(
|
||||
key=group_key,
|
||||
label=labels[group_key],
|
||||
candidates=tuple(candidates_by_group[group_key]),
|
||||
)
|
||||
for group_key in ordered_group_keys
|
||||
if candidates_by_group[group_key]
|
||||
)
|
||||
|
||||
|
||||
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||
normalized_kind = str(kind).strip().casefold()
|
||||
normalized_reference = str(reference_id).strip()
|
||||
normalized_email = str(email or "").strip().casefold()
|
||||
if not normalized_kind or not normalized_reference:
|
||||
raise ValueError("Person selection keys require a kind and reference id.")
|
||||
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||
"PeopleSearchError",
|
||||
"PeopleSearchGroup",
|
||||
"PeopleSearchProvider",
|
||||
"PersonSearchCandidate",
|
||||
"people_search_providers",
|
||||
"person_selection_key",
|
||||
"search_visible_people",
|
||||
]
|
||||
@@ -5,8 +5,10 @@ from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_chec
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||
|
||||
@@ -126,6 +128,55 @@ class PolicyDecision:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingParticipantPrivacyRequest:
|
||||
"""Context for resolving what one Scheduling participant may see.
|
||||
|
||||
``requested_visibility`` is the Scheduling-owned configuration result. A
|
||||
policy provider may preserve or reduce it, but must not broaden it. Core
|
||||
deliberately defines no fallback when the optional capability is absent;
|
||||
that secure default remains the responsibility of Scheduling.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
scheduling_request_id: str
|
||||
participant_id: str
|
||||
requested_visibility: SchedulingParticipantVisibility
|
||||
actor_user_id: str | None = None
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingParticipantPrivacyDecision:
|
||||
"""Policy-resolved visibility of other participants' names and statuses."""
|
||||
|
||||
effective_visibility: SchedulingParticipantVisibility
|
||||
reason: str | None = None
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"effective_visibility": self.effective_visibility,
|
||||
"reason": self.reason,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SchedulingParticipantPrivacyPolicy(Protocol):
|
||||
"""Optional policy hook for Scheduling participant-roster disclosure."""
|
||||
|
||||
def resolve_scheduling_participant_visibility(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SchedulingParticipantPrivacyRequest,
|
||||
) -> SchedulingParticipantPrivacyDecision:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PrivacyRetentionService(Protocol):
|
||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
||||
@@ -160,3 +211,16 @@ class PrivacyRetentionService(Protocol):
|
||||
|
||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
|
||||
def scheduling_participant_privacy_policy(
|
||||
registry: object | None,
|
||||
) -> SchedulingParticipantPrivacyPolicy | None:
|
||||
"""Return the optional provider without selecting a visibility fallback."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
||||
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
|
||||
|
||||
349
src/govoplan_core/core/poll.py
Normal file
349
src/govoplan_core/core/poll.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
|
||||
|
||||
|
||||
class PollCapabilityError(ValueError):
|
||||
"""Stable error raised by Poll capability implementations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionRequest:
|
||||
label: str
|
||||
key: str | None = None
|
||||
description: str | None = None
|
||||
value: Mapping[str, object] | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollCreateCommand:
|
||||
title: str
|
||||
kind: str
|
||||
status: str
|
||||
visibility: str
|
||||
result_visibility: str
|
||||
description: str | None = None
|
||||
context_module: str | None = None
|
||||
context_resource_type: str | None = None
|
||||
context_resource_id: str | None = None
|
||||
workflow_state: str | None = None
|
||||
workflow_steps: tuple[Mapping[str, object], ...] = ()
|
||||
allow_anonymous: bool = False
|
||||
allow_response_update: bool = True
|
||||
min_choices: int = 1
|
||||
max_choices: int | None = None
|
||||
opens_at: datetime | None = None
|
||||
closes_at: datetime | None = None
|
||||
options: tuple[PollOptionRequest, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollUpdateCommand:
|
||||
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
|
||||
|
||||
title: str
|
||||
description: str | None
|
||||
visibility: str
|
||||
result_visibility: str
|
||||
allow_anonymous: bool
|
||||
allow_response_update: bool
|
||||
closes_at: datetime | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionUpdateCommand:
|
||||
"""Complete mutable option snapshot supplied by an owning module.
|
||||
|
||||
Providers must treat an exact repeat as a no-op. When the snapshot
|
||||
changes, answers for this option are invalidated while answers for other
|
||||
options are retained.
|
||||
"""
|
||||
|
||||
label: str
|
||||
description: str | None = None
|
||||
value: Mapping[str, object] | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionOrderCommand:
|
||||
"""Complete active option order supplied by an owning module.
|
||||
|
||||
Providers must reject partial, duplicate, or unknown option collections.
|
||||
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||
option identities and their existing answers remain valid.
|
||||
"""
|
||||
|
||||
option_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationCommand:
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollAnswerRequest:
|
||||
option_id: str | None = None
|
||||
option_key: str | None = None
|
||||
value: object = None
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollSubmitResponseCommand:
|
||||
respondent_id: str
|
||||
respondent_label: str | None = None
|
||||
answers: tuple[PollAnswerRequest, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementCommand:
|
||||
"""Retire live responses while preserving their auditable history.
|
||||
|
||||
Owning modules provide every server-trusted identity that can refer to the
|
||||
participant. Providers must soft-delete matching live responses, retain
|
||||
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||
"""
|
||||
|
||||
respondent_ids: tuple[str, ...] = ()
|
||||
invitation_id: str | None = None
|
||||
reason: str = "participant_removed"
|
||||
idempotency_key: str = ""
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionRef:
|
||||
id: str
|
||||
position: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollRef:
|
||||
id: str
|
||||
status: str
|
||||
options: tuple[PollOptionRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationRef:
|
||||
id: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollAnswerRef:
|
||||
option_id: str | None = None
|
||||
option_key: str | None = None
|
||||
value: object = None
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRef:
|
||||
invitation_id: str | None
|
||||
submitted_at: datetime
|
||||
# Optional for backwards compatibility with existing providers. A
|
||||
# consumer can use it to reconcile an authenticated response without
|
||||
# trusting client-supplied invitation metadata.
|
||||
respondent_id: str | None = None
|
||||
# Optional for backwards compatibility. Providers that support response
|
||||
# editing expose the authoritative, still-valid answers here.
|
||||
answers: tuple[PollAnswerRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementRef:
|
||||
"""Outcome of an auditable response-retirement request."""
|
||||
|
||||
response_ids: tuple[str, ...] = ()
|
||||
retired_at: datetime | None = None
|
||||
newly_retired_count: int = 0
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollSchedulingProvider(Protocol):
|
||||
def create_poll(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
command: PollCreateCommand,
|
||||
) -> PollRef:
|
||||
...
|
||||
|
||||
def create_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollInvitationCommand,
|
||||
) -> PollInvitationRef:
|
||||
...
|
||||
|
||||
def update_poll(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollUpdateCommand,
|
||||
) -> PollRef:
|
||||
...
|
||||
|
||||
def update_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str,
|
||||
command: PollOptionUpdateCommand,
|
||||
) -> PollOptionRef:
|
||||
"""Update one option and invalidate only answers for that option."""
|
||||
|
||||
...
|
||||
|
||||
def reorder_options(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionOrderCommand,
|
||||
) -> PollRef:
|
||||
"""Atomically replace the complete active option order."""
|
||||
|
||||
...
|
||||
|
||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
def decide_poll(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str | None,
|
||||
) -> PollRef:
|
||||
...
|
||||
|
||||
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
def set_workflow_context(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
workflow_state: str,
|
||||
workflow_steps: Sequence[Mapping[str, object]],
|
||||
context_module: str,
|
||||
context_resource_type: str,
|
||||
context_resource_id: str,
|
||||
) -> PollRef:
|
||||
...
|
||||
|
||||
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
||||
...
|
||||
|
||||
def get_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
respondent_ids: Sequence[str],
|
||||
invitation_id: str | None = None,
|
||||
) -> PollResponseRef | None:
|
||||
"""Return one response matching server-trusted participant identities."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollResponseSubmissionProvider(Protocol):
|
||||
"""Optional extension for modules that collect responses through Poll."""
|
||||
|
||||
def submit_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollSubmitResponseCommand,
|
||||
) -> PollResponseRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollResponseRetirementProvider(Protocol):
|
||||
"""Optional extension for owning modules that remove participants."""
|
||||
|
||||
def retire_responses(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollResponseRetirementCommand,
|
||||
) -> PollResponseRetirementRef:
|
||||
...
|
||||
|
||||
|
||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||
return capability if isinstance(capability, PollSchedulingProvider) else None
|
||||
|
||||
|
||||
def poll_response_submission_provider(
|
||||
registry: object | None,
|
||||
) -> PollResponseSubmissionProvider | None:
|
||||
provider = poll_scheduling_provider(registry)
|
||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||
|
||||
|
||||
def poll_response_retirement_provider(
|
||||
registry: object | None,
|
||||
) -> PollResponseRetirementProvider | None:
|
||||
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, PollResponseRetirementProvider)
|
||||
else None
|
||||
)
|
||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.poll import (
|
||||
PollAnswerRequest,
|
||||
PollInvitationRef,
|
||||
PollOptionRequest,
|
||||
PollResponseRef,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||
# Policy-attestation identifier, not a credential or credential default.
|
||||
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||
PARTICIPATION_POLICY_VERSION = 1
|
||||
|
||||
|
||||
def participation_token_fingerprint(token: str) -> str:
|
||||
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseGatewayRef:
|
||||
"""Stable identity of the module resource governing a participation link."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationPolicy:
|
||||
"""Generic response rules snapshotted onto one signed invitation.
|
||||
|
||||
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||
a place.
|
||||
"""
|
||||
|
||||
version: int = PARTICIPATION_POLICY_VERSION
|
||||
single_choice: bool = False
|
||||
allow_maybe: bool = True
|
||||
max_participants_per_option: int | None = None
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedInvitationCommand:
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseCommand:
|
||||
"""Submission already authorized by the named in-process gateway.
|
||||
|
||||
Passwords never cross this boundary. A gateway that owns a password
|
||||
verifier reports the completed check through ``verified_requirements``.
|
||||
Poll independently re-enforces the remaining snapshotted rules while
|
||||
holding its Poll-row lock.
|
||||
"""
|
||||
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
participant_email: str | None = None
|
||||
participant_is_authenticated: bool = False
|
||||
answers: tuple[PollAnswerRequest, ...] = ()
|
||||
comment: str | None = None
|
||||
verified_requirements: frozenset[str] = frozenset()
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseRef:
|
||||
response: PollResponseRef
|
||||
participant_email: str | None = None
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionMutationRef:
|
||||
id: str
|
||||
position: int
|
||||
replayed: bool = False
|
||||
invalidated_response_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationRevocationRef:
|
||||
id: str
|
||||
revoked_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationExpiryRef:
|
||||
id: str
|
||||
expires_at: datetime | None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationContextRef:
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollGovernedInvitationCommand,
|
||||
) -> PollInvitationRef:
|
||||
...
|
||||
|
||||
def resolve_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str | None = None,
|
||||
participant_email: str | None = None,
|
||||
participant_is_authenticated: bool = False,
|
||||
verified_requirements: frozenset[str] = frozenset(),
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
...
|
||||
|
||||
def resolve_authenticated_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve one governed invitation without retaining its public token."""
|
||||
|
||||
...
|
||||
|
||||
def submit_authenticated_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
"""Submit atomically for the exact authenticated invitation identity."""
|
||||
|
||||
...
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionRequest,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def remove_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def revoke_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
) -> PollInvitationRevocationRef:
|
||||
...
|
||||
|
||||
def update_invitation_expiry(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
expires_at: datetime | None,
|
||||
) -> PollInvitationExpiryRef:
|
||||
"""Expire or extend a non-revoked governed invitation in place."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
def poll_participation_gateway_provider(
|
||||
registry: object | None,
|
||||
) -> PollParticipationGatewayProvider | None:
|
||||
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||
"PARTICIPATION_POLICY_VERSION",
|
||||
"PollGovernedInvitationCommand",
|
||||
"PollGovernedResponseCommand",
|
||||
"PollGovernedResponseRef",
|
||||
"PollInvitationExpiryRef",
|
||||
"PollInvitationRevocationRef",
|
||||
"PollOptionMutationRef",
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
]
|
||||
@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
PublicFrontendRoute,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
@@ -194,6 +196,7 @@ class PlatformRegistry:
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_public_frontend_route_uniqueness(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
@@ -320,9 +323,34 @@ def _validate_role_template_scopes(
|
||||
*,
|
||||
known_scopes: set[str],
|
||||
) -> None:
|
||||
seen_templates: dict[tuple[str, str], str] = {}
|
||||
for manifest in manifests:
|
||||
for template in manifest.role_templates:
|
||||
template_key = (template.level, template.slug)
|
||||
previous_module = seen_templates.get(template_key)
|
||||
if previous_module is not None:
|
||||
raise RegistryError(
|
||||
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
seen_templates[template_key] = manifest.id
|
||||
if template.default_authenticated and template.level != "tenant":
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||
)
|
||||
if template.default_authenticated and not template.managed:
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be managed"
|
||||
)
|
||||
for scope in template.permissions:
|
||||
if template.default_authenticated and (
|
||||
scope in {"*", "tenant:*", "system:*"}
|
||||
or _WILDCARD_RE.match(scope)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} "
|
||||
"must use explicit permissions, not wildcard scopes"
|
||||
)
|
||||
if _role_template_scope_known(scope, known_scopes):
|
||||
continue
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
@@ -344,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_frontend(manifest)
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
for topic in manifest.documentation:
|
||||
for issue in user_workflow_scope_condition_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
@@ -403,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
@@ -416,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
|
||||
|
||||
def _validate_public_frontend_route_uniqueness(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> None:
|
||||
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||
for manifest in manifests:
|
||||
if manifest.frontend is None:
|
||||
continue
|
||||
for route in manifest.frontend.public_routes:
|
||||
previous = owners.get(route.path)
|
||||
if previous is not None:
|
||||
previous_module, _previous_route = previous
|
||||
raise RegistryError(
|
||||
f"Duplicate public frontend route {route.path!r} in modules "
|
||||
f"{previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
owners[route.path] = (manifest.id, route)
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||
_REDIS_INCREMENT_SCRIPT = """
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {count, ttl}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FixedWindowBucket:
|
||||
count: int = 0
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleDimension:
|
||||
"""One independently limited dimension without putting its value in keys."""
|
||||
|
||||
namespace: str
|
||||
subject: str
|
||||
limit: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleDecision:
|
||||
allowed: bool
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
class FixedWindowStore(Protocol):
|
||||
def read(self, key: str) -> FixedWindowBucket: ...
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryFixedWindowStore:
|
||||
"""Bounded process-local store for development and distributed-store loss."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_entries: int = 10_000,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._entries: dict[str, tuple[int, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_entries = max(2, max_entries)
|
||||
self._clock = clock
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
return FixedWindowBucket()
|
||||
count, expires_at = entry
|
||||
return FixedWindowBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
self._make_room(now=now, incoming_key=key)
|
||||
count = 1
|
||||
expires_at = now + window_seconds
|
||||
else:
|
||||
count = entry[0] + 1
|
||||
expires_at = entry[1]
|
||||
self._entries[key] = (count, expires_at)
|
||||
return FixedWindowBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[1] <= now:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||
return
|
||||
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||
if expires_at <= now:
|
||||
self._entries.pop(key, None)
|
||||
while len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(next(iter(self._entries)))
|
||||
|
||||
|
||||
class RedisFixedWindowStore:
|
||||
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||
|
||||
def __init__(self, redis_url: str) -> None:
|
||||
self._client = Redis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=0.25,
|
||||
socket_timeout=0.25,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
pipeline = self._client.pipeline(transaction=False)
|
||||
pipeline.get(key)
|
||||
pipeline.ttl(key)
|
||||
raw_count, raw_ttl = pipeline.execute()
|
||||
return FixedWindowBucket(
|
||||
count=int(raw_count or 0),
|
||||
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
result = self._client.eval(
|
||||
_REDIS_INCREMENT_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
window_seconds,
|
||||
)
|
||||
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||
return FixedWindowBucket(
|
||||
count=int(result[0]),
|
||||
retry_after_seconds=max(1, int(result[1])),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._client.delete(key)
|
||||
|
||||
|
||||
class ResilientFixedWindowStore:
|
||||
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
primary: FixedWindowStore | None,
|
||||
fallback: FixedWindowStore,
|
||||
*,
|
||||
retry_seconds: int = 30,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._primary = primary
|
||||
self._fallback = fallback
|
||||
self._retry_seconds = max(1, retry_seconds)
|
||||
self._clock = clock
|
||||
self._primary_unavailable_until = 0.0
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
fallback_result = self._fallback.read(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
return _stricter_bucket(primary.read(key), fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
fallback_result = self._fallback.increment(
|
||||
key,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
primary_result = primary.increment(
|
||||
key,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
return _stricter_bucket(primary_result, fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._fallback.delete(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return
|
||||
try:
|
||||
primary.delete(key)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
|
||||
def _available_primary(self) -> FixedWindowStore | None:
|
||||
if self._primary is None:
|
||||
return None
|
||||
with self._state_lock:
|
||||
if self._clock() < self._primary_unavailable_until:
|
||||
return None
|
||||
return self._primary
|
||||
|
||||
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||
should_log = False
|
||||
with self._state_lock:
|
||||
now = self._clock()
|
||||
if now >= self._primary_unavailable_until:
|
||||
should_log = True
|
||||
self._primary_unavailable_until = now + self._retry_seconds
|
||||
if should_log:
|
||||
logger.warning(
|
||||
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _stricter_bucket(
|
||||
first: FixedWindowBucket,
|
||||
second: FixedWindowBucket,
|
||||
) -> FixedWindowBucket:
|
||||
return FixedWindowBucket(
|
||||
count=max(first.count, second.count),
|
||||
retry_after_seconds=max(
|
||||
first.retry_after_seconds,
|
||||
second.retry_after_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FixedWindowThrottle:
|
||||
def __init__(
|
||||
self,
|
||||
store: FixedWindowStore,
|
||||
*,
|
||||
window_seconds: int,
|
||||
key_prefix: str = "govoplan:throttle:v1",
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._window_seconds = max(1, window_seconds)
|
||||
self._key_prefix = key_prefix.rstrip(":")
|
||||
|
||||
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||
return self._decision(tuple(dimensions), increment=False)
|
||||
|
||||
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||
return self._decision(tuple(dimensions), increment=True)
|
||||
|
||||
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||
for dimension in tuple(dimensions):
|
||||
self._store.delete(self._key(dimension))
|
||||
|
||||
def _decision(
|
||||
self,
|
||||
dimensions: tuple[ThrottleDimension, ...],
|
||||
*,
|
||||
increment: bool,
|
||||
) -> ThrottleDecision:
|
||||
if not dimensions:
|
||||
raise ValueError("At least one throttle dimension is required")
|
||||
blocked_retry_after = 0
|
||||
for dimension in dimensions:
|
||||
if dimension.limit < 1:
|
||||
raise ValueError("Throttle limits must be positive")
|
||||
key = self._key(dimension)
|
||||
state = (
|
||||
self._store.increment(key, window_seconds=self._window_seconds)
|
||||
if increment
|
||||
else self._store.read(key)
|
||||
)
|
||||
if state.count >= dimension.limit:
|
||||
blocked_retry_after = max(
|
||||
blocked_retry_after,
|
||||
max(1, state.retry_after_seconds),
|
||||
)
|
||||
return ThrottleDecision(
|
||||
allowed=blocked_retry_after == 0,
|
||||
retry_after_seconds=blocked_retry_after,
|
||||
)
|
||||
|
||||
def _key(self, dimension: ThrottleDimension) -> str:
|
||||
namespace = dimension.namespace.strip().casefold()
|
||||
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||
|
||||
|
||||
def build_fixed_window_throttle(
|
||||
*,
|
||||
redis_url: str | None,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int = 30,
|
||||
max_local_entries: int = 10_000,
|
||||
key_prefix: str = "govoplan:throttle:v1",
|
||||
) -> FixedWindowThrottle:
|
||||
primary = (
|
||||
RedisFixedWindowStore(redis_url)
|
||||
if redis_url is not None and redis_url.strip()
|
||||
else None
|
||||
)
|
||||
store = ResilientFixedWindowStore(
|
||||
primary,
|
||||
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||
retry_seconds=redis_retry_seconds,
|
||||
)
|
||||
return FixedWindowThrottle(
|
||||
store,
|
||||
window_seconds=window_seconds,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FixedWindowBucket",
|
||||
"FixedWindowStore",
|
||||
"FixedWindowThrottle",
|
||||
"InMemoryFixedWindowStore",
|
||||
"RedisFixedWindowStore",
|
||||
"ResilientFixedWindowStore",
|
||||
"ThrottleDecision",
|
||||
"ThrottleDimension",
|
||||
"build_fixed_window_throttle",
|
||||
]
|
||||
@@ -65,7 +65,7 @@ def bootstrap_dev_data(
|
||||
api_key_secret: str | None = None,
|
||||
tenant_slug: str = "default",
|
||||
user_email: str = "admin@example.local",
|
||||
user_password: str = "dev-admin",
|
||||
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
|
||||
) -> BootstrapResult:
|
||||
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
||||
if tenant is None:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sysconfig
|
||||
from typing import Any
|
||||
|
||||
from alembic import command
|
||||
@@ -215,7 +216,10 @@ def _registered_module_registry(
|
||||
server_config = get_server_config()
|
||||
active_database_url = database_url or settings.database_url
|
||||
if active_database_url:
|
||||
configure_database(active_database_url)
|
||||
# Registry planning may target a different database than the currently
|
||||
# configured handle. The global handle is replaced either way, so
|
||||
# dispose its superseded pool instead of leaking open DBAPI connections.
|
||||
configure_database(active_database_url, dispose_previous=True)
|
||||
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
|
||||
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
|
||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||
@@ -459,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
||||
|
||||
def _repo_root() -> Path:
|
||||
packaged_root = Path(__file__).resolve().parents[3]
|
||||
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
|
||||
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
||||
candidates = [
|
||||
Path(configured).expanduser() if configured else None,
|
||||
Path.cwd(),
|
||||
packaged_root,
|
||||
installed_runtime_root,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
@@ -634,7 +640,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
||||
|
||||
def _row_count(connection, table_name: str) -> int:
|
||||
quoted = _quoted_table_name(connection, table_name)
|
||||
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
return int(connection.execute(statement).scalar_one())
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import ArgumentError
|
||||
|
||||
from govoplan_core.core.discovery import iter_module_entry_points
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
@@ -112,6 +115,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def redacted_database_url(database_url: str) -> str:
|
||||
try:
|
||||
return make_url(database_url).render_as_string(hide_password=True)
|
||||
except (ArgumentError, TypeError, ValueError):
|
||||
scheme = database_url.partition(":")[0].strip()
|
||||
return f"{scheme}:<redacted>" if scheme else "<redacted>"
|
||||
|
||||
|
||||
def _env_truthy(value: str | None) -> bool:
|
||||
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@@ -297,11 +308,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
||||
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
||||
print(f"Runtime root: {state.runtime_root}")
|
||||
if state.database_url:
|
||||
print(f"Database: {state.database_url}")
|
||||
print(f"Database: {redacted_database_url(state.database_url)}")
|
||||
if state.database_url.startswith("postgresql"):
|
||||
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
||||
if pgtools_url:
|
||||
print(f"PostgreSQL tools URL: {pgtools_url}")
|
||||
print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
|
||||
if state.bootstrap_db_path is not None:
|
||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||
|
||||
@@ -5,6 +5,12 @@ import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
bounded_response_bytes,
|
||||
build_outbound_http_opener,
|
||||
validate_outbound_http_url,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HttpFetchResponse:
|
||||
@@ -40,17 +46,26 @@ def fetch_http(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> HttpFetchResponse:
|
||||
request = urllib.request.Request(
|
||||
validate_http_url(url, label=label),
|
||||
validated_url = validate_outbound_http_url(url, label=label)
|
||||
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||
validated_url,
|
||||
headers=dict(headers or {}),
|
||||
method=method,
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
response_headers = dict(response.headers.items())
|
||||
return HttpFetchResponse(
|
||||
status=int(getattr(response, "status", 0)),
|
||||
headers=dict(response.headers.items()),
|
||||
body=response.read(),
|
||||
headers=response_headers,
|
||||
body=bounded_response_bytes(
|
||||
response,
|
||||
headers=response_headers,
|
||||
max_bytes=max_bytes,
|
||||
label=f"{label} response",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,5 +77,29 @@ def fetch_http_text(
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
encoding: str = "utf-8",
|
||||
max_bytes: int | None = None,
|
||||
) -> str:
|
||||
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)
|
||||
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
|
||||
|
||||
|
||||
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, *, label: str) -> None:
|
||||
super().__init__()
|
||||
self._label = label
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||
previous = urllib.parse.urlparse(req.full_url)
|
||||
redirected = urllib.parse.urlparse(candidate)
|
||||
if previous.scheme.lower() == "https" and redirected.scheme.lower() != "https":
|
||||
return None
|
||||
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||
new_request.remove_header(header)
|
||||
return new_request
|
||||
|
||||
|
||||
def _http_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
|
||||
scheme = parsed.scheme.lower()
|
||||
return scheme, (parsed.hostname or "").lower(), parsed.port or (443 if scheme == "https" else 80)
|
||||
|
||||
408
src/govoplan_core/security/outbound_http.py
Normal file
408
src/govoplan_core/security/outbound_http.py
Normal file
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import errno
|
||||
import http.client
|
||||
import os
|
||||
import socket
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import BinaryIO, Final
|
||||
|
||||
|
||||
DEFAULT_STRUCTURED_RESPONSE_BYTES: Final = 16 * 1024 * 1024
|
||||
DEFAULT_FILE_TRANSFER_BYTES: Final = 512 * 1024 * 1024
|
||||
_READ_CHUNK_BYTES: Final = 64 * 1024
|
||||
_TRUE_VALUES: Final = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE_VALUES: Final = frozenset({"0", "false", "no", "off"})
|
||||
_IPV4_LIMITED_BROADCAST: Final = ipaddress.IPv4Address("255.255.255.255")
|
||||
_IPV4_COMPATIBLE_NETWORK: Final = ipaddress.IPv6Network("::/96")
|
||||
_NAT64_WELL_KNOWN_NETWORK: Final = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
_KNOWN_METADATA_ADDRESSES: Final = frozenset(
|
||||
{
|
||||
ipaddress.ip_address("100.100.100.200"),
|
||||
ipaddress.ip_address("169.254.169.254"),
|
||||
ipaddress.ip_address("fd00:ec2::254"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OutboundHttpError(RuntimeError):
|
||||
"""Base error for deployment-wide outbound HTTP policy failures."""
|
||||
|
||||
|
||||
class OutboundHttpBlocked(OutboundHttpError):
|
||||
"""Raised when a URL targets an address forbidden by deployment policy."""
|
||||
|
||||
|
||||
class OutboundResponseTooLarge(OutboundHttpError):
|
||||
"""Raised before a connector can retain an oversized remote response."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OutboundHttpPolicy:
|
||||
allow_private_networks: bool
|
||||
structured_response_bytes: int
|
||||
file_transfer_bytes: int
|
||||
|
||||
|
||||
def outbound_http_policy(environ: Mapping[str, str] | None = None) -> OutboundHttpPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
return OutboundHttpPolicy(
|
||||
allow_private_networks=_private_network_default(env),
|
||||
structured_response_bytes=_positive_int(
|
||||
env.get("GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES"),
|
||||
default=DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||
name="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
),
|
||||
file_transfer_bytes=_positive_int(
|
||||
env.get("GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES"),
|
||||
default=DEFAULT_FILE_TRANSFER_BYTES,
|
||||
name="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate_outbound_http_url(
|
||||
value: str,
|
||||
*,
|
||||
label: str = "Connector URL",
|
||||
policy: OutboundHttpPolicy | None = None,
|
||||
) -> str:
|
||||
parsed = urllib.parse.urlparse(str(value).strip())
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||
raise OutboundHttpBlocked(f"{label} must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password:
|
||||
raise OutboundHttpBlocked(f"{label} must not include embedded credentials")
|
||||
try:
|
||||
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
|
||||
except ValueError as exc:
|
||||
raise OutboundHttpBlocked(f"{label} has an invalid port") from exc
|
||||
validate_outbound_host(parsed.hostname, port=port, label=label, policy=policy)
|
||||
return urllib.parse.urlunparse(parsed)
|
||||
|
||||
|
||||
def validate_unpinned_sdk_http_url(
|
||||
value: str,
|
||||
*,
|
||||
label: str,
|
||||
policy: OutboundHttpPolicy | None = None,
|
||||
) -> str:
|
||||
"""Fail closed when an SDK cannot connect to a prevalidated DNS answer."""
|
||||
|
||||
active_policy = policy or outbound_http_policy()
|
||||
validate_outbound_http_url(value, label=label, policy=active_policy)
|
||||
_raise_unpinned_transport(label)
|
||||
|
||||
|
||||
def validate_unpinned_sdk_host(
|
||||
hostname: str,
|
||||
*,
|
||||
port: int,
|
||||
label: str,
|
||||
policy: OutboundHttpPolicy | None = None,
|
||||
) -> None:
|
||||
"""Validate a host and then reject an SDK that may select another peer."""
|
||||
|
||||
active_policy = policy or outbound_http_policy()
|
||||
validate_outbound_host(hostname, port=port, label=label, policy=active_policy)
|
||||
_raise_unpinned_transport(label)
|
||||
|
||||
|
||||
def _raise_unpinned_transport(label: str) -> None:
|
||||
raise OutboundHttpBlocked(
|
||||
f"{label} uses an SDK that cannot pin every connection peer or revalidate SDK-managed redirects/referrals; "
|
||||
"it is disabled until that transport supports connection-time DNS/IP pinning"
|
||||
)
|
||||
|
||||
|
||||
def validate_outbound_host(
|
||||
hostname: str,
|
||||
*,
|
||||
port: int,
|
||||
label: str = "Connector host",
|
||||
policy: OutboundHttpPolicy | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
active_policy = policy or outbound_http_policy()
|
||||
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||
addresses = tuple(dict.fromkeys(str(item[4][0]).split("%", 1)[0] for item in records if item[4]))
|
||||
return addresses
|
||||
|
||||
|
||||
def create_outbound_connection(
|
||||
hostname: str,
|
||||
port: int,
|
||||
timeout: float | object | None = None,
|
||||
source_address: tuple[str, int] | None = None,
|
||||
socket_options: Iterable[tuple[object, ...]] | None = None,
|
||||
*,
|
||||
label: str = "Connector host",
|
||||
policy: OutboundHttpPolicy | None = None,
|
||||
) -> socket.socket:
|
||||
"""Resolve, validate, and connect to the exact approved address records.
|
||||
|
||||
Hostname resolution happens exactly once for this connection attempt. The
|
||||
returned socket connects directly to one of those validated sockaddr
|
||||
records, while higher protocol layers retain the original hostname for
|
||||
HTTP Host, TLS SNI, and certificate verification.
|
||||
"""
|
||||
|
||||
active_policy = policy or outbound_http_policy()
|
||||
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||
effective_timeout = None if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout # type: ignore[attr-defined]
|
||||
last_error: OSError | None = None
|
||||
for family, socktype, proto, _canonname, sockaddr in records:
|
||||
sock: socket.socket | None = None
|
||||
try:
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
sock.settimeout(effective_timeout) # type: ignore[arg-type]
|
||||
if source_address is not None:
|
||||
bind_address: tuple[object, ...] = source_address
|
||||
if family == socket.AF_INET6 and len(source_address) == 2:
|
||||
bind_address = (source_address[0], source_address[1], 0, 0)
|
||||
sock.bind(bind_address)
|
||||
for option in socket_options or ():
|
||||
sock.setsockopt(*option)
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOPROTOOPT:
|
||||
raise
|
||||
sock.connect(sockaddr)
|
||||
return sock
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise OutboundHttpBlocked(f"{label} hostname did not resolve to a usable address")
|
||||
|
||||
|
||||
def build_outbound_http_opener(*handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
|
||||
"""Build a proxy-free urllib opener whose sockets use approved addresses."""
|
||||
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({}),
|
||||
_OutboundHTTPHandler(),
|
||||
_OutboundHTTPSHandler(),
|
||||
*handlers,
|
||||
)
|
||||
|
||||
|
||||
def response_limit(kind: str, *, policy: OutboundHttpPolicy | None = None) -> int:
|
||||
active_policy = policy or outbound_http_policy()
|
||||
if kind == "structured":
|
||||
return active_policy.structured_response_bytes
|
||||
if kind == "file":
|
||||
return active_policy.file_transfer_bytes
|
||||
raise ValueError("Response kind must be 'structured' or 'file'")
|
||||
|
||||
|
||||
def bounded_response_bytes(
|
||||
stream: BinaryIO,
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
max_bytes: int | None = None,
|
||||
kind: str = "structured",
|
||||
label: str = "Connector response",
|
||||
) -> bytes:
|
||||
configured_limit = response_limit(kind)
|
||||
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||
if limit <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
declared_size = _content_length(headers or {})
|
||||
if declared_size is not None and declared_size > limit:
|
||||
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||
body = bytearray()
|
||||
while len(body) <= limit:
|
||||
chunk = stream.read(min(_READ_CHUNK_BYTES, limit + 1 - len(body)))
|
||||
if not chunk:
|
||||
return bytes(body)
|
||||
body.extend(chunk)
|
||||
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||
|
||||
|
||||
def bounded_chunks_bytes(
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
max_bytes: int | None = None,
|
||||
kind: str = "structured",
|
||||
label: str = "Connector response",
|
||||
) -> bytes:
|
||||
configured_limit = response_limit(kind)
|
||||
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||
if limit <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
declared_size = _content_length(headers or {})
|
||||
if declared_size is not None and declared_size > limit:
|
||||
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||
body = bytearray()
|
||||
for chunk in chunks:
|
||||
if len(chunk) > limit - len(body):
|
||||
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||
body.extend(chunk)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def _private_network_default(environ: Mapping[str, str]) -> bool:
|
||||
configured = environ.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
|
||||
if configured is not None and configured.strip():
|
||||
value = configured.strip().lower()
|
||||
if value in _TRUE_VALUES:
|
||||
return True
|
||||
if value in _FALSE_VALUES:
|
||||
return False
|
||||
raise ValueError("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS must be true or false")
|
||||
return environ.get("APP_ENV", "dev").strip().lower() in {"dev", "development", "test"}
|
||||
|
||||
|
||||
def _positive_int(value: str | None, *, default: int, name: str) -> int:
|
||||
if value is None or not value.strip():
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} must be a positive integer") from exc
|
||||
if parsed <= 0:
|
||||
raise ValueError(f"{name} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
def _content_length(headers: Mapping[str, str]) -> int | None:
|
||||
value = next((item for key, item in headers.items() if key.casefold() == "content-length"), None)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed >= 0 else None
|
||||
|
||||
|
||||
def _is_public_address(value: str) -> bool:
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
if address.is_reserved or getattr(address, "is_site_local", False) or not address.is_global:
|
||||
return False
|
||||
return all(
|
||||
embedded.is_global and not embedded.is_reserved
|
||||
for embedded in _embedded_ipv4_addresses(address)
|
||||
)
|
||||
|
||||
|
||||
def _is_forbidden_special_address(value: str) -> bool:
|
||||
"""Keep connector access away from host-local and non-unicast address space.
|
||||
|
||||
Enabling private-network connectors deliberately permits internal and
|
||||
loopback destinations, but it must not expose link-local metadata services
|
||||
or addresses that cannot identify a single remote peer.
|
||||
"""
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return True
|
||||
candidates = (address, *_embedded_ipv4_addresses(address))
|
||||
return any(
|
||||
candidate in _KNOWN_METADATA_ADDRESSES
|
||||
or candidate == _IPV4_LIMITED_BROADCAST
|
||||
or candidate.is_link_local
|
||||
or candidate.is_multicast
|
||||
or candidate.is_unspecified
|
||||
for candidate in candidates
|
||||
)
|
||||
|
||||
|
||||
def _embedded_ipv4_addresses(
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> tuple[ipaddress.IPv4Address, ...]:
|
||||
"""Return IPv4 destinations encoded by standard IPv6 transition forms."""
|
||||
|
||||
if isinstance(address, ipaddress.IPv4Address):
|
||||
return ()
|
||||
candidates: list[ipaddress.IPv4Address] = []
|
||||
if address.ipv4_mapped is not None:
|
||||
candidates.append(address.ipv4_mapped)
|
||||
elif address in _IPV4_COMPATIBLE_NETWORK:
|
||||
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
|
||||
if address in _NAT64_WELL_KNOWN_NETWORK:
|
||||
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
|
||||
if address.sixtofour is not None:
|
||||
candidates.append(address.sixtofour)
|
||||
if address.teredo is not None:
|
||||
candidates.extend(address.teredo)
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _resolved_address_records(
|
||||
hostname: str,
|
||||
*,
|
||||
port: int,
|
||||
label: str,
|
||||
policy: OutboundHttpPolicy,
|
||||
) -> tuple[tuple[int, int, int, str, tuple[object, ...]], ...]:
|
||||
host = str(hostname).strip().rstrip(".")
|
||||
if not host:
|
||||
raise OutboundHttpBlocked(f"{label} must include a hostname")
|
||||
if not 1 <= int(port) <= 65535:
|
||||
raise OutboundHttpBlocked(f"{label} has an invalid port")
|
||||
try:
|
||||
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise OutboundHttpBlocked(f"{label} hostname could not be resolved") from exc
|
||||
records = tuple(results)
|
||||
if not records:
|
||||
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||
addresses = tuple(str(item[4][0]).split("%", 1)[0] for item in records if item[4])
|
||||
if not addresses:
|
||||
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||
if any(_is_forbidden_special_address(address) for address in addresses):
|
||||
raise OutboundHttpBlocked(f"{label} resolves to a forbidden special-purpose network")
|
||||
if not policy.allow_private_networks and any(not _is_public_address(address) for address in addresses):
|
||||
raise OutboundHttpBlocked(
|
||||
f"{label} resolves to a non-public network; "
|
||||
"set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true for this deployment to permit it"
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _create_outbound_connection_compat(
|
||||
address: tuple[str, int],
|
||||
timeout: float | object | None = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore[attr-defined]
|
||||
source_address: tuple[str, int] | None = None,
|
||||
) -> socket.socket:
|
||||
return create_outbound_connection(
|
||||
address[0],
|
||||
address[1],
|
||||
timeout=timeout,
|
||||
source_address=source_address,
|
||||
label="Outbound HTTP connection",
|
||||
)
|
||||
|
||||
|
||||
class _OutboundHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||
self._create_connection = _create_outbound_connection_compat
|
||||
|
||||
|
||||
class _OutboundHTTPSConnection(http.client.HTTPSConnection):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||
self._create_connection = _create_outbound_connection_compat
|
||||
|
||||
|
||||
class _OutboundHTTPHandler(urllib.request.HTTPHandler):
|
||||
def http_open(self, req): # type: ignore[no-untyped-def]
|
||||
return self.do_open(_OutboundHTTPConnection, req)
|
||||
|
||||
|
||||
class _OutboundHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req): # type: ignore[no-untyped-def]
|
||||
return self.do_open(_OutboundHTTPSConnection, req, context=self._context)
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sensitive_key_tokens(key: object) -> set[str]:
|
||||
value = str(key).strip()
|
||||
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
|
||||
normalized_key = str(key).strip().casefold().replace("-", "_")
|
||||
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
||||
continue
|
||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}):
|
||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
|
||||
return True
|
||||
if isinstance(item, Mapping) and contains_plain_secret(item):
|
||||
return True
|
||||
|
||||
@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -9,14 +9,21 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||
from govoplan_core.core.install_config import validate_runtime_configuration
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
|
||||
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
|
||||
)
|
||||
|
||||
|
||||
def _slow_request_threshold_ms() -> float:
|
||||
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
|
||||
return 500.0
|
||||
|
||||
|
||||
def _hsts_seconds() -> int:
|
||||
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
|
||||
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except ValueError:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _max_request_body_bytes() -> int:
|
||||
default = 512 * 1024 * 1024
|
||||
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _trusted_hosts() -> tuple[str, ...]:
|
||||
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
|
||||
|
||||
|
||||
def _validate_production_startup() -> None:
|
||||
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
|
||||
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
|
||||
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
|
||||
return
|
||||
validation = validate_runtime_configuration()
|
||||
if validation.errors:
|
||||
raise RuntimeError(validation.to_text())
|
||||
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
||||
logger.warning(
|
||||
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
|
||||
)
|
||||
|
||||
|
||||
def create_govoplan_app(
|
||||
*,
|
||||
title: str,
|
||||
@@ -37,9 +87,27 @@ def create_govoplan_app(
|
||||
cors_origins: Iterable[str] = (),
|
||||
health_payload: dict[str, Any] | None = None,
|
||||
) -> FastAPI:
|
||||
_validate_production_startup()
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
|
||||
trusted_hosts = _trusted_hosts()
|
||||
if trusted_hosts:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
|
||||
app.state.govoplan_registry = registry
|
||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||
hsts_seconds = _hsts_seconds()
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_response_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
|
||||
if hsts_seconds and request.url.scheme == "https":
|
||||
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
|
||||
return response
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_correlation_context(request: Request, call_next):
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
if frontend is None:
|
||||
return None
|
||||
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||
|
||||
return {
|
||||
"module_id": frontend.module_id,
|
||||
"package_name": frontend.package_name,
|
||||
"asset_manifest": frontend.asset_manifest,
|
||||
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||
capabilities: list[str] = []
|
||||
if manifest_id == "mail":
|
||||
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
if manifest.frontend is not None
|
||||
and manifest.frontend.public_routes
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions(request: Request):
|
||||
registry = _registry(request)
|
||||
|
||||
71
src/govoplan_core/server/request_limits.py
Normal file
71
src/govoplan_core/server/request_limits.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class _RequestBodyTooLarge(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
def __init__(self, app: Any, *, max_bytes: int) -> None:
|
||||
if max_bytes <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
self.app = app
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
declared = _content_length(scope.get("headers", ()))
|
||||
if declared is not None and declared > self.max_bytes:
|
||||
await self._reject(scope, receive, send)
|
||||
return
|
||||
|
||||
received = 0
|
||||
response_started = False
|
||||
|
||||
async def limited_receive() -> dict[str, Any]:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message.get("type") == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > self.max_bytes:
|
||||
raise _RequestBodyTooLarge
|
||||
return message
|
||||
|
||||
async def tracked_send(message: dict[str, Any]) -> None:
|
||||
nonlocal response_started
|
||||
if message.get("type") == "http.response.start":
|
||||
response_started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, limited_receive, tracked_send)
|
||||
except _RequestBodyTooLarge:
|
||||
if response_started:
|
||||
raise
|
||||
await self._reject(scope, receive, send)
|
||||
|
||||
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
response = JSONResponse(
|
||||
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
|
||||
status_code=413,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
|
||||
for key, value in headers:
|
||||
if bytes(key).lower() != b"content-length":
|
||||
continue
|
||||
try:
|
||||
parsed = int(bytes(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed >= 0 else None
|
||||
return None
|
||||
@@ -47,9 +47,41 @@ class Settings(BaseSettings):
|
||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||
)
|
||||
auth_login_throttle_client_limit: int = Field(
|
||||
default=100,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||
)
|
||||
auth_login_throttle_window_seconds: int = Field(
|
||||
default=15 * 60,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||
)
|
||||
auth_login_throttle_redis_retry_seconds: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||
)
|
||||
|
||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||
celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES")
|
||||
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||
calendar_outbox_terminal_retention_days: int = Field(
|
||||
default=90,
|
||||
ge=0,
|
||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||
)
|
||||
scheduling_cancellation_notice_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=90,
|
||||
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||
)
|
||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||
|
||||
# Development bootstrap only. Do not use this in production.
|
||||
|
||||
@@ -83,7 +83,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -1382,6 +1381,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
app_configurators=(),
|
||||
)
|
||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||
from govoplan_core.server.app import create_app
|
||||
|
||||
app = create_app(config)
|
||||
|
||||
create_scope_tables(database.engine)
|
||||
|
||||
@@ -26,10 +26,21 @@ os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files")
|
||||
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
|
||||
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
|
||||
|
||||
from govoplan_core.settings import Settings, settings as core_settings
|
||||
|
||||
# unittest discovery imports other contract modules before this smoke module.
|
||||
# Refresh the shared settings object in place so modules that already imported
|
||||
# it observe this test's isolated environment before the default app is built.
|
||||
_isolated_settings = Settings()
|
||||
for _field_name in Settings.model_fields:
|
||||
setattr(core_settings, _field_name, getattr(_isolated_settings, _field_name))
|
||||
|
||||
from alembic import command
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
from govoplan_core.db.session import configure_database, set_database
|
||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
||||
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
||||
@@ -61,6 +72,16 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
create_scope_tables(engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# This suite builds the current ORM schema directly. Record the matching
|
||||
# release heads so module-lifecycle tests can safely run Alembic without
|
||||
# misclassifying the create_all database as a historic dev revision.
|
||||
if not getattr(type(self), "_migration_heads_stamped", False):
|
||||
command.stamp(
|
||||
alembic_config(database_url=os.environ["DATABASE_URL"]),
|
||||
"heads",
|
||||
purge=True,
|
||||
)
|
||||
type(self)._migration_heads_stamped = True
|
||||
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
||||
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
||||
with SessionLocal() as session:
|
||||
@@ -79,6 +100,37 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
payload = response.json()
|
||||
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
||||
|
||||
def _create_test_mail_profile(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
name: str,
|
||||
include_imap: bool = False,
|
||||
) -> str:
|
||||
credentials = {
|
||||
"smtp": {"username": "sender@example.org", "password": "test-secret"},
|
||||
}
|
||||
payload: dict[str, object] = {
|
||||
"name": name,
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"security": "starttls",
|
||||
},
|
||||
"credentials": credentials,
|
||||
}
|
||||
if include_imap:
|
||||
payload["imap"] = {
|
||||
"host": "mock.imap",
|
||||
"port": 993,
|
||||
"security": "tls",
|
||||
"sent_folder": "Sent",
|
||||
}
|
||||
credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"}
|
||||
response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload)
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
return str(response.json()["id"])
|
||||
|
||||
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
||||
headers, _ = self._login()
|
||||
payload = {
|
||||
@@ -203,6 +255,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
external_id: str,
|
||||
recipient_count: int = 1,
|
||||
) -> tuple[str, str]:
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery")
|
||||
entries = [
|
||||
{
|
||||
"id": f"recipient-{index + 1}",
|
||||
@@ -216,15 +269,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -310,6 +355,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
|
||||
self.assertEqual(roles.status_code, 200, roles.text)
|
||||
system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
|
||||
maintenance_operator = next(item for item in roles.json()["roles"] if item["slug"] == "maintenance_operator")
|
||||
created = self.client.post(
|
||||
"/api/v1/admin/system/accounts",
|
||||
headers=headers,
|
||||
@@ -319,7 +365,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"password": "approver-password",
|
||||
"password_reset_required": False,
|
||||
"is_active": True,
|
||||
"role_ids": [system_admin["id"]],
|
||||
"role_ids": [system_admin["id"], maintenance_operator["id"]],
|
||||
"memberships": [{
|
||||
"tenant_id": tenant_id,
|
||||
"is_active": True,
|
||||
@@ -718,10 +764,40 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
assert version is not None
|
||||
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
||||
snapshot = version.execution_snapshot or {}
|
||||
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
|
||||
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
|
||||
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||
self.assertEqual(snapshot["mail_profile_id"], profile_id)
|
||||
self.assertNotIn("smtp", snapshot)
|
||||
self.assertNotIn("imap", snapshot)
|
||||
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||
self.assertTrue(snapshot["imap_transport_revision"])
|
||||
|
||||
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
|
||||
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
|
||||
self.assertEqual(deleted.status_code, 200, deleted.text)
|
||||
self.assertFalse(deleted.json()["is_active"])
|
||||
self.assertFalse(deleted.json()["smtp_password_configured"])
|
||||
self.assertFalse(deleted.json()["imap_password_configured"])
|
||||
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
|
||||
with SessionLocal() as session:
|
||||
profile = session.get(MailServerProfile, profile_id)
|
||||
self.assertIsNotNone(profile)
|
||||
assert profile is not None
|
||||
self.assertIsNone(profile.smtp_password_encrypted)
|
||||
self.assertIsNone(profile.imap_password_encrypted)
|
||||
audit = (
|
||||
session.query(AuditLog)
|
||||
.filter(
|
||||
AuditLog.action == "mail.profile_credentials_deleted",
|
||||
AuditLog.object_id == profile_id,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"])
|
||||
self.assertEqual(audit.details["deletion_reason"], "profile_deactivated")
|
||||
self.assertNotIn("secret", repr(audit.details))
|
||||
|
||||
def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None:
|
||||
headers, _ = self._login()
|
||||
created_profile = self.client.post(
|
||||
"/api/v1/mail/profiles",
|
||||
@@ -753,15 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"mail_profile_id": profile_id,
|
||||
"inherit_smtp_credentials": False,
|
||||
"inherit_imap_credentials": False,
|
||||
"credentials": {
|
||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
||||
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
||||
},
|
||||
},
|
||||
"server": {"mail_profile_id": profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
@@ -788,31 +856,43 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(built.status_code, 200, built.text)
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignVersion
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
|
||||
from govoplan_campaign.backend.integrations import mail_integration
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
|
||||
with SessionLocal() as session:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
self.assertIsNotNone(version)
|
||||
assert version is not None
|
||||
snapshot_payload = version.execution_snapshot or {}
|
||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
|
||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
|
||||
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
|
||||
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
|
||||
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
|
||||
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
|
||||
self.assertEqual(snapshot_payload["snapshot_version"], "5")
|
||||
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
|
||||
self.assertNotIn("smtp", snapshot_payload)
|
||||
self.assertNotIn("imap", snapshot_payload)
|
||||
self.assertTrue(snapshot_payload["smtp_transport_revision"])
|
||||
self.assertTrue(snapshot_payload["imap_transport_revision"])
|
||||
|
||||
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
|
||||
smtp_runtime = runtime_smtp_config(session, version, snapshot)
|
||||
imap_runtime = runtime_imap_config(session, version, snapshot)
|
||||
self.assertEqual(smtp_runtime.host, "mock.smtp")
|
||||
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
|
||||
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
|
||||
self.assertIsNotNone(imap_runtime)
|
||||
assert imap_runtime is not None
|
||||
self.assertEqual(imap_runtime.host, "mock.imap")
|
||||
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
|
||||
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
|
||||
profile = session.get(MailServerProfile, profile_id)
|
||||
self.assertIsNotNone(profile)
|
||||
assert profile is not None
|
||||
integration = mail_integration()
|
||||
summary = integration.campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
campaign_id=version.campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
self.assertTrue(summary["smtp_available"])
|
||||
self.assertTrue(summary["imap_available"])
|
||||
self.assertNotIn("mock.smtp", repr(summary))
|
||||
self.assertNotIn("profile-smtp", repr(summary))
|
||||
self.assertNotIn("secret", repr(summary))
|
||||
for name in (
|
||||
"smtp_config_from_profile",
|
||||
"imap_config_from_profile",
|
||||
"send_email_bytes",
|
||||
"send_email_message",
|
||||
):
|
||||
self.assertFalse(hasattr(integration, name), name)
|
||||
|
||||
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
||||
public_health = self.client.get("/health")
|
||||
@@ -1789,12 +1869,21 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
tenant_id = str(login["tenant"]["id"])
|
||||
env_keys = [
|
||||
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
||||
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
||||
"GOVOPLAN_TEST_SMB_PASSWORD",
|
||||
]
|
||||
previous_env = {key: os.environ.get(key) for key in env_keys}
|
||||
try:
|
||||
os.environ["GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST"] = ",".join(
|
||||
(
|
||||
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
||||
"GOVOPLAN_TEST_WEBDAV_PASSWORD",
|
||||
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
||||
"GOVOPLAN_TEST_SMB_PASSWORD",
|
||||
)
|
||||
)
|
||||
os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile"
|
||||
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
|
||||
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
|
||||
@@ -2181,36 +2270,37 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertFalse(deleted_space.json()["is_active"])
|
||||
self.assertIsNotNone(deleted_space.json()["deleted_at"])
|
||||
|
||||
import httpx
|
||||
from govoplan_files.backend.storage.http_client import ConnectorHttpResponse
|
||||
|
||||
def seafile_request(method: str, url: str, **kwargs):
|
||||
request = httpx.Request(method, url)
|
||||
params = kwargs.get("params") or {}
|
||||
if url == "http://127.0.0.1:9082/api2/auth-token/":
|
||||
return httpx.Response(200, json={"token": "token-1"}, request=request)
|
||||
return ConnectorHttpResponse(200, {}, b'{"token":"token-1"}')
|
||||
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
|
||||
self.assertEqual(params.get("p"), "/reports/summary.txt")
|
||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
|
||||
return httpx.Response(
|
||||
return ConnectorHttpResponse(
|
||||
200,
|
||||
json={
|
||||
{},
|
||||
json.dumps(
|
||||
{
|
||||
"id": "file-1",
|
||||
"name": "summary.txt",
|
||||
"size": 14,
|
||||
"type": "file",
|
||||
"mtime": 1783425600,
|
||||
"permission": "r",
|
||||
},
|
||||
request=request,
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
|
||||
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
|
||||
return httpx.Response(200, json="https://download.example.invalid/summary.txt", request=request)
|
||||
return ConnectorHttpResponse(200, {}, b'"https://download.example.invalid/summary.txt"')
|
||||
if url == "https://download.example.invalid/summary.txt":
|
||||
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request)
|
||||
return httpx.Response(404, request=request)
|
||||
return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
|
||||
return ConnectorHttpResponse(404, {}, b"")
|
||||
|
||||
with patch("govoplan_files.backend.storage.connector_browse.httpx.request", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=seafile_request):
|
||||
with patch("govoplan_files.backend.storage.connector_browse.request_connector_bytes", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=seafile_request):
|
||||
imported = self.client.post(
|
||||
"/api/v1/files/connectors/profiles/seafile-dev/import",
|
||||
headers=headers,
|
||||
@@ -2237,18 +2327,16 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
|
||||
|
||||
def webdav_request(method: str, url: str, **kwargs):
|
||||
request = httpx.Request(method, url)
|
||||
self.assertEqual(method, "GET")
|
||||
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
|
||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
|
||||
return httpx.Response(
|
||||
return ConnectorHttpResponse(
|
||||
200,
|
||||
content=webdav_payload["content"],
|
||||
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
||||
request=request,
|
||||
{"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
||||
webdav_payload["content"],
|
||||
)
|
||||
|
||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
||||
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||
nextcloud_import = self.client.post(
|
||||
"/api/v1/files/connectors/profiles/user-nextcloud/import",
|
||||
headers=headers,
|
||||
@@ -2270,7 +2358,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
webdav_payload["content"] = b"nextcloud notice updated"
|
||||
webdav_payload["etag"] = "\"nextcloud-etag-3\""
|
||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
||||
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||
nextcloud_sync = self.client.post(
|
||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||
headers=headers,
|
||||
@@ -2292,7 +2380,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
|
||||
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
|
||||
|
||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
||||
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||
nextcloud_sync_unchanged = self.client.post(
|
||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||
headers=headers,
|
||||
@@ -2311,74 +2399,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(unchanged["file"]["id"], nextcloud_file["id"])
|
||||
self.assertEqual(unchanged["current_version_id"], synced["current_version_id"])
|
||||
|
||||
case = self
|
||||
|
||||
class _FakeSmbStat:
|
||||
st_size = 18
|
||||
st_mtime = 1783425600
|
||||
st_mtime_ns = 1783425600000000000
|
||||
|
||||
class _FakeSmbEntry:
|
||||
def __init__(self, name: str, is_dir: bool, size: int = 18) -> None:
|
||||
self.name = name
|
||||
self._is_dir = is_dir
|
||||
self._stat = _FakeSmbStat()
|
||||
self._stat.st_size = size
|
||||
|
||||
def is_dir(self) -> bool:
|
||||
return self._is_dir
|
||||
|
||||
def stat(self):
|
||||
return self._stat
|
||||
|
||||
class _FakeSmbScandir:
|
||||
def __enter__(self):
|
||||
return iter([
|
||||
_FakeSmbEntry("Archive", True, 0),
|
||||
_FakeSmbEntry("smb-notice.txt", False, 18),
|
||||
])
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
class _FakeSmbFile:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
case.assertGreater(size, 18)
|
||||
return b"smb connector data"
|
||||
|
||||
class _FakeSmbClient:
|
||||
def scandir(self, path: str, **kwargs):
|
||||
case.assertEqual(path, r"\\127.0.0.1\files\root\shared")
|
||||
case.assertEqual(kwargs["port"], 1445)
|
||||
case.assertEqual(kwargs["username"], "govoplan")
|
||||
case.assertEqual(kwargs["password"], "super-secret-smb")
|
||||
case.assertTrue(kwargs["require_signing"])
|
||||
return _FakeSmbScandir()
|
||||
|
||||
def stat(self, path: str, **kwargs):
|
||||
case.assertEqual(path, r"\\127.0.0.1\files\root\shared\smb-notice.txt")
|
||||
case.assertEqual(kwargs["port"], 1445)
|
||||
return _FakeSmbStat()
|
||||
|
||||
def open_file(self, path: str, mode: str, **kwargs):
|
||||
case.assertEqual(path, r"\\127.0.0.1\files\root\shared\smb-notice.txt")
|
||||
case.assertEqual(mode, "rb")
|
||||
case.assertEqual(kwargs["password"], "super-secret-smb")
|
||||
return _FakeSmbFile()
|
||||
|
||||
fake_smb = _FakeSmbClient()
|
||||
with patch("govoplan_files.backend.storage.connector_browse._smbclient_module", return_value=fake_smb):
|
||||
with patch("govoplan_files.backend.storage.connector_browse._smbclient_module") as smb_sdk:
|
||||
smb_browse = self.client.get("/api/v1/files/connectors/profiles/tenant-smb/browse?path=shared", headers=headers)
|
||||
self.assertEqual(smb_browse.status_code, 200, smb_browse.text)
|
||||
self.assertEqual([(item["kind"], item["path"]) for item in smb_browse.json()["items"]], [("folder", "shared/Archive"), ("file", "shared/smb-notice.txt")])
|
||||
self.assertEqual(smb_browse.status_code, 400, smb_browse.text)
|
||||
self.assertIn("redirects/referrals", smb_browse.json()["detail"])
|
||||
smb_sdk.assert_not_called()
|
||||
|
||||
with patch("govoplan_files.backend.storage.connector_imports._smbclient_module", return_value=fake_smb):
|
||||
with patch("govoplan_files.backend.storage.connector_imports._smbclient_module") as smb_import_sdk:
|
||||
smb_import = self.client.post(
|
||||
"/api/v1/files/connectors/profiles/tenant-smb/import",
|
||||
headers=headers,
|
||||
@@ -2390,12 +2417,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"target_folder": "imports",
|
||||
},
|
||||
)
|
||||
self.assertEqual(smb_import.status_code, 200, smb_import.text)
|
||||
smb_file = smb_import.json()["files"][0]
|
||||
self.assertEqual(smb_file["display_path"], "imports/smb-notice.txt")
|
||||
self.assertEqual(smb_file["source_provenance"]["provider"], "smb")
|
||||
self.assertEqual(smb_file["source_provenance"]["external_id"], "files:shared/smb-notice.txt")
|
||||
self.assertEqual(smb_file["source_provenance"]["metadata"]["share"], "files")
|
||||
self.assertEqual(smb_import.status_code, 400, smb_import.text)
|
||||
self.assertIn("redirects/referrals", smb_import.json()["detail"])
|
||||
smb_import_sdk.assert_not_called()
|
||||
|
||||
filtered = self.client.get("/api/v1/files/connectors/profiles?provider=smb", headers=headers)
|
||||
self.assertEqual(filtered.status_code, 200, filtered.text)
|
||||
@@ -2919,7 +2943,10 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertFalse(delta_payload["full"])
|
||||
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
|
||||
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
||||
self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation")
|
||||
self.assertEqual(
|
||||
delta_payload["jobs"][0]["last_error"],
|
||||
"SMTP delivery outcome requires operator reconciliation.",
|
||||
)
|
||||
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
||||
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
||||
|
||||
@@ -3129,28 +3156,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
||||
headers, _ = self._login()
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True)
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "smtp.example.invalid",
|
||||
"port": 587,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": True,
|
||||
"host": "imap.example.invalid",
|
||||
"port": 993,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "tls",
|
||||
},
|
||||
},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -3222,20 +3234,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "smtp.example.invalid",
|
||||
"port": 587,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -3432,23 +3437,130 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"202605-010001-90100010-9601741.XLSX",
|
||||
})
|
||||
|
||||
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||
"global_values": {},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Invoice", "text": "Please see the attached file."},
|
||||
"attachments": {
|
||||
"base_path": "invoices",
|
||||
"base_paths": [
|
||||
{
|
||||
"id": "personal-invoices",
|
||||
"name": "My invoices",
|
||||
"source": f"managed:user:{user_id}",
|
||||
"path": "invoices",
|
||||
"allow_individual": True,
|
||||
}
|
||||
],
|
||||
"global": [],
|
||||
"allow_individual": True,
|
||||
},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
|
||||
"fields": {"invoice_number": "202605-010002"},
|
||||
"attachments": [
|
||||
{
|
||||
"id": "exact-pattern",
|
||||
"label": "Exact workbook",
|
||||
"base_path_id": "personal-invoices",
|
||||
"base_dir": "invoices",
|
||||
"file_filter": "{{local:invoice_number}}.XLSX",
|
||||
"required": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"missing_required_attachment": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
}
|
||||
|
||||
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
campaign_id = created.json()["campaign"]["id"]
|
||||
version_id = created.json()["version"]["id"]
|
||||
|
||||
uploaded = self.client.post(
|
||||
"/api/v1/files/upload",
|
||||
headers=headers,
|
||||
data={
|
||||
"owner_type": "user",
|
||||
"owner_id": user_id,
|
||||
"path": "invoices",
|
||||
},
|
||||
files=[("files", ("202605-010002.XLSX", b"unlinked workbook", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
|
||||
)
|
||||
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
||||
|
||||
preview = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||
headers=headers,
|
||||
json={"include_unmatched": True, "include_unlinked_candidates": True},
|
||||
)
|
||||
self.assertEqual(preview.status_code, 200, preview.text)
|
||||
preview_payload = preview.json()
|
||||
self.assertEqual(preview_payload["matched_file_count"], 1)
|
||||
self.assertEqual(preview_payload["unlinked_file_count"], 1)
|
||||
self.assertEqual(preview_payload["rules"][0]["unlinked_match_count"], 1)
|
||||
self.assertFalse(preview_payload["rules"][0]["matches"][0]["linked_to_campaign"])
|
||||
|
||||
dry_run = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/link-matches",
|
||||
headers=headers,
|
||||
json={"dry_run": True},
|
||||
)
|
||||
self.assertEqual(dry_run.status_code, 200, dry_run.text)
|
||||
self.assertEqual(dry_run.json()["linked_file_count"], 0)
|
||||
self.assertEqual(len(dry_run.json()["linkable_files"]), 1)
|
||||
|
||||
validated = self.client.post(
|
||||
f"/api/v1/campaigns/versions/{version_id}/validate",
|
||||
headers=headers,
|
||||
json={"check_files": True, "link_unshared_matches": True},
|
||||
)
|
||||
self.assertEqual(validated.status_code, 200, validated.text)
|
||||
self.assertTrue(validated.json()["ok"], validated.text)
|
||||
|
||||
linked_preview = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||
headers=headers,
|
||||
json={"include_unmatched": True, "include_unlinked_candidates": True},
|
||||
)
|
||||
self.assertEqual(linked_preview.status_code, 200, linked_preview.text)
|
||||
linked_payload = linked_preview.json()
|
||||
self.assertEqual(linked_payload["matched_file_count"], 1)
|
||||
self.assertEqual(linked_payload["linked_file_count"], 1)
|
||||
self.assertEqual(linked_payload["unlinked_file_count"], 0)
|
||||
self.assertTrue(linked_payload["rules"][0]["matches"][0]["linked_to_campaign"])
|
||||
|
||||
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -3600,18 +3712,35 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
|
||||
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
||||
headers, _ = self._login()
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
||||
"fields": [], "global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||
"template": {"subject": "Warning test", "text": "Body"},
|
||||
"attachments": {"base_path": ".", "base_paths": [], "global": [{
|
||||
"id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf",
|
||||
"required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"}
|
||||
}], "zip": {"enabled": False, "archives": []}},
|
||||
"attachments": {
|
||||
"base_path": "review-files",
|
||||
"base_paths": [{
|
||||
"id": "managed-review-files",
|
||||
"name": "Review files",
|
||||
"source": f"managed:user:{user_id}",
|
||||
"path": "review-files",
|
||||
}],
|
||||
"global": [{
|
||||
"id": "optional-missing",
|
||||
"base_path_id": "managed-review-files",
|
||||
"base_dir": "review-files",
|
||||
"file_filter": "not-there.pdf",
|
||||
"required": False,
|
||||
"missing_behavior": "warn",
|
||||
"zip": {"archive_id": "exclude"},
|
||||
}],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
},
|
||||
"entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
||||
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
|
||||
@@ -3636,19 +3765,41 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
|
||||
|
||||
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
||||
headers, _ = self._login()
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
||||
"template": {"subject": "Hello", "text": "Active recipient only"},
|
||||
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
|
||||
"attachments": {
|
||||
"base_path": "inactive-files",
|
||||
"base_paths": [{
|
||||
"id": "managed-inactive-files",
|
||||
"name": "Inactive files",
|
||||
"source": f"managed:user:{user_id}",
|
||||
"path": "inactive-files",
|
||||
}],
|
||||
"global": [],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
},
|
||||
"entries": {"inline": [
|
||||
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]},
|
||||
{"id": "inactive", "active": False, "to": [], "attachments": [{"base_dir": "missing", "file_filter": "missing.pdf", "required": True}]},
|
||||
{
|
||||
"id": "inactive",
|
||||
"active": False,
|
||||
"to": [],
|
||||
"attachments": [{
|
||||
"base_path_id": "managed-inactive-files",
|
||||
"base_dir": "inactive-files",
|
||||
"file_filter": "missing.pdf",
|
||||
"required": True,
|
||||
}],
|
||||
},
|
||||
]},
|
||||
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
@@ -3677,12 +3828,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
||||
headers, _ = self._login()
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": [{"email": "global-from@example.org", "type": "to"}],
|
||||
"allow_individual_from": True,
|
||||
@@ -3743,12 +3895,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_multiple_from_addresses_are_rejected(self) -> None:
|
||||
headers, _ = self._login()
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": [
|
||||
{"email": "first@example.org", "type": "to"},
|
||||
@@ -3768,12 +3921,13 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
||||
headers, _ = self._login()
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||
"template": {"subject": "ZIP validation", "text": "Body"},
|
||||
"attachments": {
|
||||
@@ -3813,6 +3967,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
||||
headers, login = self._login()
|
||||
user_id = login["user"]["id"]
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery")
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
||||
@@ -3822,7 +3977,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
{"name": "global_zip_password", "type": "password", "required": True},
|
||||
],
|
||||
"global_values": {"global_zip_password": "campaign-secret"},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -4009,14 +4164,15 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
|
||||
|
||||
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
|
||||
def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None:
|
||||
headers, _ = self._login()
|
||||
campaign_id, version_id = self._create_built_delivery_campaign(
|
||||
headers,
|
||||
external_id="snapshot-freeze",
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
|
||||
with SessionLocal() as session:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
@@ -4024,23 +4180,24 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
assert version is not None
|
||||
snapshot = version.execution_snapshot
|
||||
self.assertIsInstance(snapshot, dict)
|
||||
self.assertEqual(snapshot["snapshot_version"], "3")
|
||||
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||
self.assertEqual(snapshot["job_count"], 1)
|
||||
self.assertEqual(snapshot["queueable_job_count"], 1)
|
||||
self.assertTrue(snapshot["job_manifest_sha256"])
|
||||
self.assertTrue(snapshot["smtp_config_fingerprint"])
|
||||
self.assertIsNone(snapshot["smtp"].get("password"))
|
||||
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||
self.assertNotIn("smtp", snapshot)
|
||||
self.assertNotIn("imap", snapshot)
|
||||
self.assertTrue(version.execution_snapshot_hash)
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertTrue(job.eml_sha256)
|
||||
self.assertTrue(job.message_id_header)
|
||||
|
||||
# Simulate accidental/config-drift mutation after the build. Sending
|
||||
# must still use the immutable mock SMTP snapshot, not raw_json.
|
||||
mutated = json.loads(json.dumps(version.raw_json))
|
||||
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
|
||||
version.raw_json = mutated
|
||||
session.add(version)
|
||||
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
|
||||
self.assertIsNotNone(profile)
|
||||
assert profile is not None
|
||||
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
|
||||
profile.smtp_transport_revision = "manually-rotated-transport-revision"
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
|
||||
sent = self.client.post(
|
||||
@@ -4054,13 +4211,17 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"enqueue_imap_task": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200, sent.text)
|
||||
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
|
||||
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
|
||||
self.assertEqual(sent.status_code, 422, sent.text)
|
||||
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertEqual(job.send_status, "smtp_accepted")
|
||||
self.assertEqual(job.queue_status, "draft")
|
||||
self.assertEqual(job.send_status, "not_queued")
|
||||
self.assertEqual(
|
||||
session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
||||
headers, _ = self._login()
|
||||
@@ -4124,29 +4285,26 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"enqueue_imap_task": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200, sent.text)
|
||||
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
||||
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
|
||||
self.assertEqual(sent.status_code, 422, sent.text)
|
||||
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||
self.assertEqual(list_records(kind="smtp"), [])
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertEqual(job.queue_status, "draft")
|
||||
self.assertEqual(job.send_status, "not_queued")
|
||||
|
||||
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
||||
headers, _ = self._login()
|
||||
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
||||
mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery")
|
||||
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "sender@example.org",
|
||||
"password": "test-secret",
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
@@ -4194,7 +4352,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(sent.status_code, 200, sent.text)
|
||||
result = sent.json()["result"]
|
||||
self.assertEqual(result["sent_count"], 1, sent.text)
|
||||
self.assertIn("refused recipients", result["results"][0]["message"])
|
||||
self.assertEqual(result["results"][0]["status"], "smtp_accepted")
|
||||
self.assertNotIn("message", result["results"][0])
|
||||
finally:
|
||||
set_failures(smtp_reject_recipients_containing=None)
|
||||
|
||||
@@ -5637,6 +5796,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
||||
owner_headers, _ = self._login()
|
||||
mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery")
|
||||
access_role = self._create_role(
|
||||
owner_headers,
|
||||
slug="campaign-collaborator",
|
||||
@@ -5648,7 +5808,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
password="reader-password",
|
||||
role_ids=[str(access_role["id"])],
|
||||
)
|
||||
other = self._create_user(
|
||||
self._create_user(
|
||||
owner_headers,
|
||||
email="campaign-other@example.local",
|
||||
password="other-password",
|
||||
@@ -5662,7 +5822,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
|
||||
"server": {"mail_profile_id": mail_profile_id},
|
||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
||||
"template": {"subject": "ACL", "text": "ACL"},
|
||||
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
||||
@@ -5860,7 +6020,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
json={"campaign_json": inline_json},
|
||||
)
|
||||
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
|
||||
self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"])
|
||||
self.assertIn("may only reference", blocked_inline.json()["detail"])
|
||||
|
||||
reusable_json = detail.json()["raw_json"]
|
||||
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
|
||||
@@ -5981,7 +6141,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
}
|
||||
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||
self.assertEqual(blocked.status_code, 422, blocked.text)
|
||||
self.assertIn("locked", blocked.json()["detail"])
|
||||
self.assertIn("may only reference", blocked.json()["detail"])
|
||||
|
||||
campaign_json["server"] = {"mail_profile_id": profile_id}
|
||||
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||
|
||||
48
tests/test_calendar_outbox_worker.py
Normal file
48
tests/test_calendar_outbox_worker.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||
|
||||
|
||||
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
def test_worker_commits_provider_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.dispatch_due.return_value = {
|
||||
"processed": 1,
|
||||
"succeeded": 1,
|
||||
"retrying": 0,
|
||||
"failed": 0,
|
||||
"operations": [{"id": "operation-1", "status": "succeeded"}],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
|
||||
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||
):
|
||||
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["succeeded"], 1)
|
||||
|
||||
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
|
||||
{"queue": "calendar"},
|
||||
)
|
||||
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
|
||||
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
|
||||
self.assertEqual(schedule["schedule"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
||||
for path in (
|
||||
"/api/v1/platform/status",
|
||||
"/api/v1/platform/modules",
|
||||
"/api/v1/platform/public-modules",
|
||||
):
|
||||
first = client.get(path)
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
etag = first.headers.get("etag")
|
||||
|
||||
@@ -4,8 +4,9 @@ import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
|
||||
cors_origins=("*",),
|
||||
)
|
||||
|
||||
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
|
||||
):
|
||||
app = create_govoplan_app(
|
||||
title="security header test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||
response = client.get("/health")
|
||||
|
||||
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
|
||||
self.assertEqual("DENY", response.headers["X-Frame-Options"])
|
||||
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
|
||||
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
|
||||
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
|
||||
|
||||
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
|
||||
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"GovOPlaN configuration validation: FAILED",
|
||||
):
|
||||
create_govoplan_app(
|
||||
title="unsafe production test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/body")
|
||||
async def body(request: Request):
|
||||
return {"size": len(await request.body())}
|
||||
|
||||
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
|
||||
app = create_govoplan_app(
|
||||
title="request body limit test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=router,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/body", content=b"12345678901")
|
||||
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
|
||||
|
||||
self.assertEqual(413, response.status_code, response.text)
|
||||
self.assertIn("10 bytes", response.json()["detail"])
|
||||
self.assertEqual(413, streamed.status_code, streamed.text)
|
||||
|
||||
def test_app_enforces_configured_trusted_hosts(self) -> None:
|
||||
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
|
||||
app = create_govoplan_app(
|
||||
title="trusted host test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||
allowed = client.get("/health")
|
||||
rejected = client.get("https://attacker.example.test/health")
|
||||
|
||||
self.assertEqual(200, allowed.status_code, allowed.text)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
|
||||
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
|
||||
|
||||
|
||||
class DatabaseMigrationTests(unittest.TestCase):
|
||||
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
previous_disabled = logger.disabled
|
||||
logger.disabled = False
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
|
||||
database = Path(directory) / "logging.db"
|
||||
command.stamp(
|
||||
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
|
||||
"heads",
|
||||
)
|
||||
|
||||
self.assertFalse(logger.disabled)
|
||||
finally:
|
||||
logger.disabled = previous_disabled
|
||||
|
||||
def test_migration_tracks_use_separate_version_locations(self) -> None:
|
||||
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
|
||||
dev_locations = alembic_config(
|
||||
@@ -184,6 +201,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertIsNone(result.reconciled_revision)
|
||||
self.assertEqual(current, configured_migration_heads(url))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("calendar_outbox_operations", tables)
|
||||
self.assertIn("calendar_sync_credentials", tables)
|
||||
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
||||
self.assertIn("file_connector_credentials", tables)
|
||||
@@ -216,6 +234,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("0f1e2d3c4b5a", current)
|
||||
self.assertIn("audit_outbox_events", tables)
|
||||
self.assertIn("calendar_outbox_operations", tables)
|
||||
self.assertIn("file_connector_profiles", tables)
|
||||
self.assertIn("core_scopes", tables)
|
||||
finally:
|
||||
|
||||
@@ -7,8 +7,22 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.devserver import redacted_database_url
|
||||
|
||||
|
||||
class DevserverSmokeTests(unittest.TestCase):
|
||||
def test_database_url_redaction_hides_passwords(self) -> None:
|
||||
rendered = redacted_database_url(
|
||||
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
rendered,
|
||||
"postgresql+psycopg://govoplan:***@db.example.test/govoplan",
|
||||
)
|
||||
self.assertNotIn("database-secret", rendered)
|
||||
self.assertEqual(redacted_database_url("://database-secret"), "<redacted>")
|
||||
|
||||
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
src_root = repo_root / "src"
|
||||
|
||||
85
tests/test_documentation_topic_contract.py
Normal file
85
tests/test_documentation_topic_contract.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
|
||||
def workflow_topic(
|
||||
*,
|
||||
conditions: tuple[DocumentationCondition, ...],
|
||||
documentation_types: tuple[str, ...] = ("user",),
|
||||
kind: str = "workflow",
|
||||
) -> DocumentationTopic:
|
||||
return DocumentationTopic(
|
||||
id="example.workflow.task",
|
||||
title="Complete a task",
|
||||
summary="Complete the example task.",
|
||||
documentation_types=documentation_types, # type: ignore[arg-type]
|
||||
conditions=conditions,
|
||||
metadata={"kind": kind},
|
||||
)
|
||||
|
||||
|
||||
class DocumentationTopicContractTests(unittest.TestCase):
|
||||
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||
topic = workflow_topic(conditions=())
|
||||
|
||||
self.assertEqual(
|
||||
user_workflow_scope_condition_issues(topic),
|
||||
("user workflow topics must declare at least one scope-conditioned alternative",),
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
|
||||
registry_for(topic).validate()
|
||||
|
||||
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
|
||||
topic = workflow_topic(
|
||||
conditions=(
|
||||
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||
DocumentationCondition(required_modules=("example",)),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
|
||||
registry_for(topic).validate()
|
||||
|
||||
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
|
||||
scoped = workflow_topic(
|
||||
conditions=(
|
||||
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
|
||||
)
|
||||
)
|
||||
admin_workflow = workflow_topic(
|
||||
conditions=(),
|
||||
documentation_types=("admin",),
|
||||
)
|
||||
user_reference = workflow_topic(conditions=(), kind="reference")
|
||||
|
||||
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
|
||||
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
|
||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||
|
||||
|
||||
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=topics,
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
|
||||
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
|
||||
from govoplan_core.security.outbound_http import (
|
||||
DEFAULT_FILE_TRANSFER_BYTES,
|
||||
DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||
OutboundHttpBlocked,
|
||||
OutboundResponseTooLarge,
|
||||
bounded_chunks_bytes,
|
||||
bounded_response_bytes,
|
||||
create_outbound_connection,
|
||||
outbound_http_policy,
|
||||
validate_outbound_http_url,
|
||||
validate_unpinned_sdk_host,
|
||||
validate_unpinned_sdk_http_url,
|
||||
)
|
||||
|
||||
|
||||
class HttpFetchTests(unittest.TestCase):
|
||||
@@ -22,6 +37,187 @@ class HttpFetchTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_http_url(value)
|
||||
|
||||
def test_outbound_policy_has_practical_bounded_defaults(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
self.assertFalse(policy.allow_private_networks)
|
||||
self.assertEqual(DEFAULT_STRUCTURED_RESPONSE_BYTES, policy.structured_response_bytes)
|
||||
self.assertEqual(DEFAULT_FILE_TRANSFER_BYTES, policy.file_transfer_bytes)
|
||||
|
||||
configured = outbound_http_policy(
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "1024",
|
||||
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "2048",
|
||||
}
|
||||
)
|
||||
self.assertTrue(configured.allow_private_networks)
|
||||
self.assertEqual(1024, configured.structured_response_bytes)
|
||||
self.assertEqual(2048, configured.file_transfer_bytes)
|
||||
|
||||
def test_private_network_policy_is_deployment_wide(self) -> None:
|
||||
denied_policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=denied_policy)
|
||||
|
||||
allowed_policy = outbound_http_policy(
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||
)
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
):
|
||||
self.assertEqual(
|
||||
"https://127.0.0.1/path",
|
||||
validate_outbound_http_url("https://127.0.0.1/path", policy=allowed_policy),
|
||||
)
|
||||
|
||||
def test_private_network_policy_still_blocks_non_peer_and_metadata_addresses(self) -> None:
|
||||
policy = outbound_http_policy(
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||
)
|
||||
for address in (
|
||||
"0.0.0.0",
|
||||
"169.254.169.254",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::",
|
||||
"::ffff:255.255.255.255",
|
||||
"64:ff9b::169.254.169.254",
|
||||
"64:ff9b::255.255.255.255",
|
||||
"fd00:ec2::254",
|
||||
"fe80::1",
|
||||
"ff02::1",
|
||||
):
|
||||
with self.subTest(address=address), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", (address, 443))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "forbidden special-purpose network"):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
|
||||
def test_public_policy_rejects_reserved_and_ipv4_embedded_ipv6_addresses(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
for address in (
|
||||
"fec0::1",
|
||||
"::127.0.0.1",
|
||||
"64:ff9b::8.8.8.8",
|
||||
"64:ff9b::127.0.0.1",
|
||||
"2002:7f00:1::",
|
||||
):
|
||||
with self.subTest(address=address), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(10, 1, 6, "", (address, 443, 0, 0))],
|
||||
), self.assertRaises(OutboundHttpBlocked):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
|
||||
def test_outbound_policy_rejects_mixed_public_and_private_dns_answers(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 443)),
|
||||
(2, 1, 6, "", ("10.0.0.4", 443)),
|
||||
],
|
||||
), self.assertRaises(OutboundHttpBlocked):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
|
||||
def test_bounded_response_rejects_declared_and_streamed_oversize_payloads(self) -> None:
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_response_bytes(io.BytesIO(b"small"), headers={"Content-Length": "20"}, max_bytes=10)
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_response_bytes(io.BytesIO(b"eleven-byte"), max_bytes=10)
|
||||
self.assertEqual(b"ten-bytes!", bounded_response_bytes(io.BytesIO(b"ten-bytes!"), max_bytes=10))
|
||||
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "5"}), self.assertRaises(
|
||||
OutboundResponseTooLarge
|
||||
):
|
||||
bounded_response_bytes(io.BytesIO(b"123456"), max_bytes=10)
|
||||
|
||||
def test_bounded_chunks_rejects_an_oversized_chunk_before_retaining_it(self) -> None:
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_chunks_bytes((b"one-large-chunk",), max_bytes=8)
|
||||
|
||||
def test_connection_time_resolution_blocks_dns_rebinding_before_socket_open(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
side_effect=(public, private),
|
||||
) as resolver, patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
with self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||
create_outbound_connection("connector.example.test", 443, policy=policy)
|
||||
|
||||
self.assertEqual(2, resolver.call_count)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
def test_unpinned_sdk_endpoints_fail_closed_in_public_and_private_modes(self) -> None:
|
||||
for allow_private, address in ((False, "93.184.216.34"), (True, "10.0.0.5")):
|
||||
with self.subTest(allow_private=allow_private):
|
||||
policy = outbound_http_policy(
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": str(allow_private).lower(),
|
||||
}
|
||||
)
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", (address, 443))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "until that transport supports.*DNS/IP pinning"):
|
||||
validate_unpinned_sdk_http_url(
|
||||
"https://objects.example.test",
|
||||
label="S3 endpoint",
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
def test_unpinned_host_sdk_fails_closed_for_dns_and_explicit_ip_targets(self) -> None:
|
||||
policy = outbound_http_policy(
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||
)
|
||||
for host in ("files.internal.example", "10.0.0.5"):
|
||||
with self.subTest(host=host), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("10.0.0.5", 445))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "redirects/referrals.*DNS/IP pinning"):
|
||||
validate_unpinned_sdk_host(host, port=445, label="SMB endpoint", policy=policy)
|
||||
|
||||
def test_core_redirects_strip_credentials_cross_origin_and_reject_https_downgrades(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://catalog.example.test/releases",
|
||||
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
):
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://cdn.example.test/releases",
|
||||
)
|
||||
downgrade = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"http://catalog.example.test/releases",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertIsNone(redirected.get_header("Authorization"))
|
||||
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||
self.assertIsNone(downgrade)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||
|
||||
identity_directory = SqlIdentityDirectory()
|
||||
idm_directory = SqlIdmDirectory()
|
||||
organization_directory = SqlOrganizationDirectory()
|
||||
idm_directory = SqlIdmDirectory(
|
||||
identities=identity_directory,
|
||||
organizations=organization_directory,
|
||||
)
|
||||
|
||||
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
|
||||
|
||||
@@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||
from govoplan_idm.backend.api.v1.routes import router as idm_router
|
||||
from govoplan_idm.backend.manifest import get_manifest
|
||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
|
||||
from tests.db_isolation import temporary_database
|
||||
|
||||
@@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def _app(self, scopes: set[str]) -> FastAPI:
|
||||
identity_directory = SqlIdentityDirectory()
|
||||
organization_directory = SqlOrganizationDirectory()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="identity",
|
||||
name="Identity",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
|
||||
CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
|
||||
},
|
||||
)
|
||||
)
|
||||
context = ModuleContext(registry=registry, settings=object())
|
||||
registry.configure_capability_context(context)
|
||||
configure_runtime(context)
|
||||
self.addCleanup(clear_runtime)
|
||||
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(idm_router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
|
||||
return app
|
||||
|
||||
@@ -3,11 +3,36 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
|
||||
from govoplan_core.settings import Settings
|
||||
|
||||
|
||||
class InstallConfigTests(unittest.TestCase):
|
||||
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
|
||||
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
|
||||
self.assertEqual(
|
||||
Settings(
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
|
||||
).calendar_outbox_terminal_retention_days,
|
||||
0,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||
|
||||
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||
self.assertEqual(
|
||||
Settings(
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
|
||||
).scheduling_cancellation_notice_days,
|
||||
14,
|
||||
)
|
||||
for invalid in ("0", "91"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||
|
||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||
|
||||
@@ -32,7 +57,9 @@ class InstallConfigTests(unittest.TestCase):
|
||||
"CELERY_ENABLED": "true",
|
||||
"REDIS_URL": "redis://redis.example.internal:6379/0",
|
||||
"CORS_ORIGINS": "https://govoplan.example.org",
|
||||
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
|
||||
"AUTH_COOKIE_SECURE": "true",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||
"FILE_STORAGE_BACKEND": "local",
|
||||
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
||||
},
|
||||
@@ -62,14 +89,59 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("DATABASE_URL", error_keys)
|
||||
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
|
||||
|
||||
def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None:
|
||||
result = validate_runtime_configuration(
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"GOVOPLAN_TRUSTED_HOSTS": "*",
|
||||
"FORWARDED_ALLOW_IPS": "*",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||
},
|
||||
profile="self-hosted",
|
||||
)
|
||||
|
||||
error_keys = {issue.key for issue in result.errors}
|
||||
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
|
||||
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
|
||||
|
||||
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
|
||||
result = validate_runtime_configuration(
|
||||
{
|
||||
"APP_ENV": "development",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name",
|
||||
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem",
|
||||
},
|
||||
profile="development",
|
||||
)
|
||||
|
||||
error_keys = {issue.key for issue in result.errors}
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys)
|
||||
|
||||
def test_env_templates_surface_install_profiles(self) -> None:
|
||||
self_hosted = env_template(profile="self-hosted")
|
||||
production_like = env_template(profile="production-like", generate_secrets=True)
|
||||
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
|
||||
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
||||
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
|
||||
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
|
||||
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,12 +6,13 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import tomllib
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
@@ -19,7 +20,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, Integer, MetaData, Table, inspect
|
||||
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Keep the default app import side effect from bootstrapping a development DB.
|
||||
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
|
||||
@@ -28,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
|
||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -97,9 +99,9 @@ from govoplan_core.core.module_package_catalog import (
|
||||
sign_module_package_catalog,
|
||||
validate_module_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -110,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import scope_grants
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import RouteCollisionError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
@@ -336,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||
if manifest.frontend.package_name is not None:
|
||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
|
||||
self.assertTrue(route.path.startswith("/"))
|
||||
self.assertTrue(route.component.strip())
|
||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||
@@ -357,6 +360,88 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
||||
registry.validate()
|
||||
|
||||
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
|
||||
for template, message in (
|
||||
(
|
||||
RoleTemplate(
|
||||
slug="invalid-system-default",
|
||||
name="Invalid",
|
||||
description="Invalid system default.",
|
||||
permissions=("system:*",),
|
||||
level="system",
|
||||
default_authenticated=True,
|
||||
),
|
||||
"must be tenant-level",
|
||||
),
|
||||
(
|
||||
RoleTemplate(
|
||||
slug="invalid-unmanaged-default",
|
||||
name="Invalid",
|
||||
description="Invalid unmanaged default.",
|
||||
permissions=("tenant:*",),
|
||||
managed=False,
|
||||
default_authenticated=True,
|
||||
),
|
||||
"must be managed",
|
||||
),
|
||||
):
|
||||
with self.subTest(template=template.slug):
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
role_templates=(template,),
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, message):
|
||||
registry.validate()
|
||||
|
||||
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="unsafe-default",
|
||||
name="Unsafe default",
|
||||
description="Must not grant broad authority automatically.",
|
||||
permissions=("tenant:*",),
|
||||
default_authenticated=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
|
||||
registry.validate()
|
||||
|
||||
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="shared-reader",
|
||||
name="Shared reader",
|
||||
description="A colliding role template.",
|
||||
permissions=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
@@ -373,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/example/public/:token",
|
||||
component="ExamplePublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="private",
|
||||
name="Private",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="private",
|
||||
package_name="@govoplan/private-webui",
|
||||
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
|
||||
),
|
||||
))
|
||||
registry.validate()
|
||||
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/public-modules")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||
public_module = response.json()["modules"][0]
|
||||
self.assertNotIn("dependencies", public_module)
|
||||
self.assertNotIn("nav", public_module["frontend"])
|
||||
self.assertNotIn("routes", public_module["frontend"])
|
||||
self.assertEqual(
|
||||
["/example/public/:token"],
|
||||
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||
)
|
||||
|
||||
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
registry.register(ModuleManifest(
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id=module_id,
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/shared/public/:token",
|
||||
component=f"{module_id.title()}PublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||
first = APIRouter()
|
||||
second = APIRouter()
|
||||
@@ -2039,6 +2192,25 @@ finally:
|
||||
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
|
||||
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
|
||||
|
||||
def test_registered_module_migration_tasks_dispose_replaced_database(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-dispose-", dir=_TEST_ROOT))
|
||||
previous = configure_database(f"sqlite:///{root / 'previous.db'}")
|
||||
previous_pool = previous.engine.pool
|
||||
manifest = ModuleManifest(id="taskmod", name="Task Module", version="1.0.0")
|
||||
|
||||
try:
|
||||
run_registered_module_migration_tasks(
|
||||
database_url=f"sqlite:///{root / 'target.db'}",
|
||||
enabled_modules=("taskmod",),
|
||||
phases=(),
|
||||
manifest_factories=(lambda: manifest,),
|
||||
)
|
||||
|
||||
self.assertIsNot(previous.engine.pool, previous_pool)
|
||||
self.assertEqual(f"sqlite:///{root / 'target.db'}", get_database().database_url)
|
||||
finally:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
|
||||
available = {
|
||||
"files": ModuleManifest(
|
||||
@@ -2350,6 +2522,31 @@ finally:
|
||||
self.assertEqual("example", record["retirements"][0]["module_id"])
|
||||
self.assertIn("database_backup", record["snapshot"])
|
||||
|
||||
def test_drop_table_retirement_is_atomic_with_session_writes(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-retirement-transaction-", dir=_TEST_ROOT))
|
||||
engine = create_engine(
|
||||
f"sqlite:///{root / 'retirement.db'}",
|
||||
connect_args={"timeout": 0.1},
|
||||
)
|
||||
metadata = MetaData()
|
||||
audit_table = Table("retirement_audit", metadata, Column("id", Integer, primary_key=True))
|
||||
owned_table = Table("retirement_owned", metadata, Column("id", Integer, primary_key=True))
|
||||
metadata.create_all(bind=engine)
|
||||
owned_model = type("OwnedModel", (), {"__table__": owned_table})
|
||||
|
||||
with Session(engine) as session:
|
||||
session.execute(insert(audit_table).values(id=1))
|
||||
plan = drop_table_retirement_provider(owned_model, label="Owned")(session, "owned")
|
||||
self.assertIsNotNone(plan.destroy_data_executor)
|
||||
plan.destroy_data_executor(session, "owned") # type: ignore[misc]
|
||||
self.assertFalse(inspect(session.connection()).has_table("retirement_owned"))
|
||||
session.rollback()
|
||||
|
||||
self.assertTrue(inspect(engine).has_table("retirement_owned"))
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual([], connection.execute(audit_table.select()).all())
|
||||
engine.dispose()
|
||||
|
||||
def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
@@ -2562,9 +2759,33 @@ finally:
|
||||
self.assertEqual("external", database_backup["metadata"]["snapshot"])
|
||||
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
|
||||
self.assertEqual("postgresql+psycopg://govoplan:***@db.example.invalid/govoplan", database_backup["stdout"].strip())
|
||||
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
|
||||
secret_path = result.record_path.parent / database_backup["database_url_secret"]
|
||||
self.assertEqual(database_url, secret_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(0o700, stat.S_IMODE(result.record_path.parent.stat().st_mode))
|
||||
self.assertEqual(0o600, stat.S_IMODE(secret_path.stat().st_mode))
|
||||
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
|
||||
|
||||
def test_installer_secret_creation_refuses_an_existing_path(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-secret-existing-", dir=_TEST_ROOT))
|
||||
secret_path = root / "database-url.secret"
|
||||
secret_path.write_text("existing-value", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Could not create private installer secret file"):
|
||||
module_installer_module._write_installer_secret(secret_path, "replacement-value")
|
||||
|
||||
self.assertEqual("existing-value", secret_path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_installer_run_directory_remains_usable_with_a_restrictive_umask(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-run-umask-", dir=_TEST_ROOT))
|
||||
run_dir = root / "run"
|
||||
previous_umask = os.umask(0o777)
|
||||
try:
|
||||
module_installer_module._create_private_installer_run_dir(run_dir)
|
||||
finally:
|
||||
os.umask(previous_umask)
|
||||
|
||||
self.assertEqual(0o700, stat.S_IMODE(run_dir.stat().st_mode))
|
||||
|
||||
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
@@ -3035,27 +3256,21 @@ finally:
|
||||
self.assertEqual("stable", validation["channel"])
|
||||
|
||||
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
|
||||
catalog_path = root / "catalog.json"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "generate-release-catalog.py"),
|
||||
"--version",
|
||||
"0.1.7",
|
||||
"--catalog-output",
|
||||
str(catalog_path),
|
||||
],
|
||||
cwd=str(Path(__file__).resolve().parents[1]),
|
||||
env={**os.environ.copy(), "GOVOPLAN_CORE_ROOT": str(Path(__file__).resolve().parents[1])},
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
generator = runpy.run_path(
|
||||
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
|
||||
run_name="govoplan_release_catalog_contract_test",
|
||||
)
|
||||
generated_at = generator["datetime"].now(tz=generator["UTC"])
|
||||
catalog = generator["_catalog_payload"](
|
||||
version="0.1.9",
|
||||
tag="v0.1.9",
|
||||
channel="test",
|
||||
sequence=1,
|
||||
generated_at=generated_at,
|
||||
expires_at=generated_at + generator["timedelta"](days=1),
|
||||
repository_base="git+ssh://git@example.test/add-ideas",
|
||||
public_base_url="https://example.test",
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
modules = {item["module_id"]: item for item in catalog["modules"]}
|
||||
|
||||
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
|
||||
@@ -3066,18 +3281,30 @@ finally:
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["files"]["requires_interfaces"])
|
||||
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "campaigns.access",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["mail"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "files.campaign_attachments",
|
||||
"optional": True,
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertIn({
|
||||
"name": "mail.campaign_delivery",
|
||||
"optional": True,
|
||||
"version_min": "0.2.0",
|
||||
"version_max_exclusive": "0.3.0",
|
||||
}, modules["campaigns"]["requires_interfaces"])
|
||||
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||
self.assertEqual("0.1.8", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
|
||||
self.assertEqual("0.1.9", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
|
||||
|
||||
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
||||
|
||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.people import (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
people_search_providers,
|
||||
person_selection_key,
|
||||
search_visible_people,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class FakePeopleSearchProvider:
|
||||
def __init__(self, *groups: PeopleSearchGroup) -> None:
|
||||
self.groups = groups
|
||||
self.calls: list[tuple[object, object, str, int]] = []
|
||||
|
||||
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
|
||||
self.calls.append((session, principal, query, limit))
|
||||
return self.groups
|
||||
|
||||
|
||||
class PeopleSearchContractTests(unittest.TestCase):
|
||||
def test_collects_optional_providers_in_stable_group_order(self) -> None:
|
||||
duplicate = PersonSearchCandidate(
|
||||
selection_key="account:1",
|
||||
kind="account",
|
||||
reference_id="1",
|
||||
display_name="Ada Lovelace",
|
||||
)
|
||||
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
|
||||
addresses = FakePeopleSearchProvider(
|
||||
PeopleSearchGroup(
|
||||
key="contacts",
|
||||
label="Contacts",
|
||||
candidates=(
|
||||
PersonSearchCandidate(
|
||||
selection_key="contact:2:ada@example.test",
|
||||
kind="contact",
|
||||
reference_id="2",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="addresses",
|
||||
name="Addresses",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
session = object()
|
||||
principal = object()
|
||||
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
|
||||
|
||||
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
|
||||
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
|
||||
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(people_search_providers(None), ())
|
||||
|
||||
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
|
||||
|
||||
def test_selection_key_is_stable_and_validated(self) -> None:
|
||||
self.assertEqual(
|
||||
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
|
||||
"account:account-1:ada@example.test",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
person_selection_key("", "account-1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
84
tests/test_poll_contract.py
Normal file
84
tests/test_poll_contract.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_core.core.poll import (
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
PollAnswerRef,
|
||||
PollOptionUpdateCommand,
|
||||
PollResponseRef,
|
||||
PollResponseRetirementCommand,
|
||||
PollResponseRetirementProvider,
|
||||
poll_response_retirement_provider,
|
||||
)
|
||||
|
||||
|
||||
class _RetirementProvider:
|
||||
def retire_responses(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_SCHEDULING
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollContractTests(unittest.TestCase):
|
||||
def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None:
|
||||
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||
|
||||
legacy = PollResponseRef(None, submitted_at)
|
||||
detailed = PollResponseRef(
|
||||
invitation_id="invitation-1",
|
||||
submitted_at=submitted_at,
|
||||
respondent_id="person-1",
|
||||
answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),),
|
||||
)
|
||||
|
||||
self.assertEqual(legacy.answers, ())
|
||||
self.assertEqual(detailed.answers[0].value, "available")
|
||||
|
||||
def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None:
|
||||
command = PollOptionUpdateCommand(
|
||||
label="Tuesday 10:00",
|
||||
value={"slot_id": "slot-1"},
|
||||
metadata={"source": "scheduling"},
|
||||
)
|
||||
|
||||
self.assertEqual(command.label, "Tuesday 10:00")
|
||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||
|
||||
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||
command = PollResponseRetirementCommand(
|
||||
respondent_ids=("account-1", "alice@example.test"),
|
||||
invitation_id="invitation-1",
|
||||
reason="scheduling_participant_removed",
|
||||
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||
metadata={"source_module": "scheduling"},
|
||||
)
|
||||
provider = _RetirementProvider()
|
||||
|
||||
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||
self.assertIs(
|
||||
poll_response_retirement_provider(_Registry(provider)),
|
||||
provider,
|
||||
)
|
||||
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_response_retirement_provider(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.poll_participation import (
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
PollParticipationGatewayProvider,
|
||||
PollParticipationPolicy,
|
||||
participation_token_fingerprint,
|
||||
poll_participation_gateway_provider,
|
||||
)
|
||||
|
||||
|
||||
class _CompleteGateway:
|
||||
def create_governed_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_governed_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_authenticated_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_authenticated_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def add_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def remove_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revoke_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_invitation_expiry(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollParticipationContractTests(unittest.TestCase):
|
||||
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
|
||||
policy = PollParticipationPolicy()
|
||||
|
||||
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
self.assertEqual(1, policy.version)
|
||||
self.assertTrue(policy.allow_maybe)
|
||||
|
||||
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
|
||||
fingerprint = participation_token_fingerprint("secret-token")
|
||||
|
||||
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
|
||||
self.assertEqual(64, len(fingerprint))
|
||||
self.assertNotIn("secret-token", fingerprint)
|
||||
|
||||
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
|
||||
provider = _CompleteGateway()
|
||||
|
||||
self.assertIsInstance(provider, PollParticipationGatewayProvider)
|
||||
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
|
||||
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_participation_gateway_provider(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
435
tests/test_postgres_retirement_atomicity.py
Normal file
435
tests/test_postgres_retirement_atomicity.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine, event, inspect, text
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.schema import CreateSchema, DropSchema
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
_CREDENTIAL_ID = "retirement-credential"
|
||||
_TENANT_ID = "retirement-tenant"
|
||||
_PRIVATE_FIXTURE_VALUES = (
|
||||
"opaque-password-ciphertext",
|
||||
"opaque-token-ciphertext",
|
||||
"vault:test-only:credential",
|
||||
"private-test-metadata",
|
||||
)
|
||||
_ROW_COUNT_QUERIES = {
|
||||
"audit_log": text("SELECT count(*) FROM audit_log"),
|
||||
"core_change_sequence": text("SELECT count(*) FROM core_change_sequence"),
|
||||
"retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"),
|
||||
}
|
||||
|
||||
|
||||
def _postgres_database_url() -> str:
|
||||
candidate = (
|
||||
os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or ""
|
||||
).strip()
|
||||
return candidate if candidate.startswith("postgresql") else ""
|
||||
|
||||
|
||||
def _proof_is_required() -> bool:
|
||||
return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
class _AuditCapabilityRegistry:
|
||||
def __init__(self, recorder: object) -> None:
|
||||
self._recorder = recorder
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_AUDIT_RECORDER
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise LookupError(name)
|
||||
return self._recorder
|
||||
|
||||
|
||||
class PostgreSQLRetirementAtomicityTests(unittest.TestCase):
|
||||
"""Production-reference proof for destructive module retirement.
|
||||
|
||||
Every test owns a random PostgreSQL schema and removes it afterward. The
|
||||
fixture stores only synthetic credential material. Assertions and audit
|
||||
inspection expose booleans and allow-listed metadata, never stored values
|
||||
or the database URL.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
database_url = _postgres_database_url()
|
||||
if not database_url:
|
||||
if _proof_is_required():
|
||||
self.fail("the required PostgreSQL retirement proof has no PostgreSQL database")
|
||||
self.skipTest("a PostgreSQL integration database is not configured")
|
||||
|
||||
try:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
from govoplan_audit.backend.recording import SqlAuditRecorder
|
||||
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||
from govoplan_files.backend.manifest import manifest as files_manifest
|
||||
except ModuleNotFoundError as exc:
|
||||
if _proof_is_required():
|
||||
self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}")
|
||||
self.skipTest(f"the full-stack integration packages are not installed: {exc.name}")
|
||||
|
||||
self.audit_log_model = AuditLog
|
||||
self.credential_model = FileConnectorCredential
|
||||
self.profile_model = FileConnectorProfile
|
||||
self.files_manifest = files_manifest
|
||||
self.previous_runtime = get_runtime_context()
|
||||
self.admin_engine: Engine | None = None
|
||||
self.engine: Engine | None = None
|
||||
self.schema_name = f"govoplan_retirement_{uuid4().hex}"
|
||||
self.capture_retirement_ddl = False
|
||||
self.retirement_ddl_backend_pids: list[int] = []
|
||||
self.ddl_listener_registered = False
|
||||
|
||||
try:
|
||||
self.admin_engine = create_engine(database_url, pool_pre_ping=True)
|
||||
with self.admin_engine.begin() as connection:
|
||||
if connection.dialect.name != "postgresql":
|
||||
self.skipTest("the configured integration database is not PostgreSQL")
|
||||
connection.execute(CreateSchema(self.schema_name))
|
||||
|
||||
self.engine = create_engine(
|
||||
database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=0,
|
||||
connect_args={
|
||||
"options": (
|
||||
f"-csearch_path={self.schema_name},pg_catalog "
|
||||
"-clock_timeout=500ms -cstatement_timeout=10s "
|
||||
"-capplication_name=govoplan-retirement-atomicity"
|
||||
),
|
||||
},
|
||||
)
|
||||
event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||
self.ddl_listener_registered = True
|
||||
self._create_fixture_schema()
|
||||
self._seed_credential()
|
||||
|
||||
recorder = SqlAuditRecorder()
|
||||
configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object()))
|
||||
except Exception:
|
||||
self._cleanup()
|
||||
raise
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._cleanup()
|
||||
|
||||
def test_scrub_audit_and_table_retirement_commit_together(self) -> None:
|
||||
assert self.engine is not None
|
||||
with Session(self.engine) as session:
|
||||
installer_backend_pid = self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
self._execute_retirement(session, plan)
|
||||
session.commit()
|
||||
|
||||
self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement")
|
||||
self.assertEqual(
|
||||
{installer_backend_pid},
|
||||
set(self.retirement_ddl_backend_pids),
|
||||
"destructive DDL did not use the installer Session connection",
|
||||
)
|
||||
with self.engine.connect() as connection:
|
||||
self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__))
|
||||
self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__))
|
||||
|
||||
probe = connection.execute(text(
|
||||
"SELECT password_removed, token_removed, username_removed, "
|
||||
"secret_reference_removed, metadata_removed "
|
||||
"FROM retirement_scrub_probe"
|
||||
)).mappings().one()
|
||||
self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal")
|
||||
|
||||
audit_rows = connection.execute(text(
|
||||
"SELECT action, object_type, object_id, details "
|
||||
"FROM audit_log ORDER BY created_at, id"
|
||||
)).mappings().all()
|
||||
self.assertEqual(1, len(audit_rows))
|
||||
audit_row = audit_rows[0]
|
||||
self.assertEqual("files.connector_credential_deleted", audit_row["action"])
|
||||
self.assertEqual("file_connector_credential", audit_row["object_type"])
|
||||
self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"])
|
||||
self._assert_non_secret_audit_details(dict(audit_row["details"] or {}))
|
||||
self.assertEqual(1, self._row_count(connection, "core_change_sequence"))
|
||||
|
||||
def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION reject_retirement_audit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.action = 'files.connector_credential_deleted' THEN
|
||||
RAISE EXCEPTION 'injected retirement audit failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TRIGGER reject_retirement_audit_insert
|
||||
BEFORE INSERT ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit()
|
||||
"""
|
||||
)
|
||||
|
||||
with Session(self.engine) as session:
|
||||
self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
with self.assertRaises(DBAPIError) as caught:
|
||||
self._execute_retirement(session, plan)
|
||||
self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None))
|
||||
session.rollback()
|
||||
|
||||
self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure")
|
||||
self._assert_failed_retirement_rolled_back()
|
||||
|
||||
def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE retirement_drop_guard (
|
||||
id bigint PRIMARY KEY,
|
||||
credential_id varchar(255) NOT NULL
|
||||
REFERENCES file_connector_credentials(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"),
|
||||
{"credential_id": _CREDENTIAL_ID},
|
||||
)
|
||||
|
||||
with Session(self.engine) as session:
|
||||
installer_backend_pid = self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
with self.assertRaises(DBAPIError) as caught:
|
||||
self._execute_retirement(session, plan)
|
||||
self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None))
|
||||
session.rollback()
|
||||
|
||||
self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached")
|
||||
self.assertEqual(
|
||||
{installer_backend_pid},
|
||||
set(self.retirement_ddl_backend_pids),
|
||||
"destructive DDL did not use the installer Session connection",
|
||||
)
|
||||
self._assert_failed_retirement_rolled_back()
|
||||
|
||||
def _create_fixture_schema(self) -> None:
|
||||
assert self.engine is not None
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
with self.engine.begin() as connection:
|
||||
# Audit and Files reference Access actors. The proof does not need
|
||||
# actor rows, so small FK targets keep its schema bounded.
|
||||
connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)")
|
||||
connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)")
|
||||
Base.metadata.create_all(
|
||||
bind=connection,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
self.audit_log_model.__table__,
|
||||
self.credential_model.__table__,
|
||||
self.profile_model.__table__,
|
||||
],
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE retirement_scrub_probe (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
password_removed boolean NOT NULL,
|
||||
token_removed boolean NOT NULL,
|
||||
username_removed boolean NOT NULL,
|
||||
secret_reference_removed boolean NOT NULL,
|
||||
metadata_removed boolean NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION record_retirement_scrub() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO retirement_scrub_probe (
|
||||
password_removed,
|
||||
token_removed,
|
||||
username_removed,
|
||||
secret_reference_removed,
|
||||
metadata_removed
|
||||
) VALUES (
|
||||
OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL,
|
||||
OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL,
|
||||
OLD.username IS NOT NULL AND NEW.username IS NULL,
|
||||
OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL,
|
||||
OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TRIGGER record_retirement_credential_scrub
|
||||
BEFORE UPDATE ON file_connector_credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub()
|
||||
"""
|
||||
)
|
||||
|
||||
def _seed_credential(self) -> None:
|
||||
assert self.engine is not None
|
||||
with Session(self.engine) as session:
|
||||
session.add(self.credential_model(
|
||||
id=_CREDENTIAL_ID,
|
||||
tenant_id=_TENANT_ID,
|
||||
scope_type="tenant",
|
||||
scope_id=_TENANT_ID,
|
||||
label="Retirement integration credential",
|
||||
provider="webdav",
|
||||
enabled=True,
|
||||
credential_mode="basic",
|
||||
username="integration-user",
|
||||
password_encrypted=_PRIVATE_FIXTURE_VALUES[0],
|
||||
token_encrypted=_PRIVATE_FIXTURE_VALUES[1],
|
||||
password_env="INTEGRATION_PASSWORD",
|
||||
token_env="INTEGRATION_TOKEN",
|
||||
secret_ref=_PRIVATE_FIXTURE_VALUES[2],
|
||||
policy={},
|
||||
metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]},
|
||||
))
|
||||
session.commit()
|
||||
|
||||
def _retirement_plan(self, session: Session) -> MigrationRetirementPlan:
|
||||
migration = self.files_manifest.migration_spec
|
||||
self.assertIsNotNone(migration)
|
||||
assert migration is not None
|
||||
self.assertIsNotNone(migration.retirement_provider)
|
||||
assert migration.retirement_provider is not None
|
||||
plan = migration.retirement_provider(session, "files")
|
||||
self.assertTrue(plan.destroy_data_supported)
|
||||
self.assertIsNotNone(plan.destroy_data_executor)
|
||||
return plan
|
||||
|
||||
def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None:
|
||||
executor = plan.destroy_data_executor
|
||||
self.assertIsNotNone(executor)
|
||||
self.capture_retirement_ddl = True
|
||||
try:
|
||||
executor(session, "files")
|
||||
finally:
|
||||
self.capture_retirement_ddl = False
|
||||
|
||||
def _capture_ddl_connection(
|
||||
self,
|
||||
connection: Connection,
|
||||
_cursor: object,
|
||||
statement: str,
|
||||
_parameters: object,
|
||||
_context: object,
|
||||
_executemany: bool,
|
||||
) -> None:
|
||||
if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"):
|
||||
return
|
||||
self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection))
|
||||
|
||||
def _session_backend_pid(self, session: Session) -> int:
|
||||
connection = session.connection()
|
||||
sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one())
|
||||
driver_pid = self._connection_backend_pid(connection)
|
||||
self.assertEqual(sql_pid, driver_pid)
|
||||
return sql_pid
|
||||
|
||||
@staticmethod
|
||||
def _connection_backend_pid(connection: Connection) -> int:
|
||||
driver_connection = connection.connection.driver_connection
|
||||
return int(driver_connection.info.backend_pid)
|
||||
|
||||
def _assert_failed_retirement_rolled_back(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertTrue(inspector.has_table(self.credential_model.__tablename__))
|
||||
self.assertTrue(inspector.has_table(self.profile_model.__tablename__))
|
||||
state = connection.execute(
|
||||
text(
|
||||
"SELECT enabled, password_encrypted IS NOT NULL AS has_password, "
|
||||
"token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, "
|
||||
"secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata "
|
||||
"FROM file_connector_credentials WHERE id = :credential_id"
|
||||
),
|
||||
{"credential_id": _CREDENTIAL_ID},
|
||||
).mappings().one()
|
||||
self.assertTrue(all(state.values()), "credential state was not fully restored")
|
||||
self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe"))
|
||||
self.assertEqual(0, self._row_count(connection, "audit_log"))
|
||||
self.assertEqual(0, self._row_count(connection, "core_change_sequence"))
|
||||
|
||||
def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None:
|
||||
self.assertEqual("module_data_retired", details.get("deletion_reason"))
|
||||
self.assertEqual("unowned_external_reference_detached", details.get("storage_backend"))
|
||||
self.assertEqual("<redacted>", details.get("deleted_secret_kinds"))
|
||||
self.assertEqual(
|
||||
["password_env", "token_env", "unowned_external_secret_ref"],
|
||||
details.get("removed_reference_kinds"),
|
||||
)
|
||||
self.assertTrue(details.get("removed_metadata"))
|
||||
serialized = json.dumps(details, sort_keys=True)
|
||||
self.assertFalse(
|
||||
any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES),
|
||||
"audit diagnostics contain fixture-private material",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_count(connection: Connection, table_name: str) -> int:
|
||||
query = _ROW_COUNT_QUERIES.get(table_name)
|
||||
if query is None:
|
||||
raise ValueError("unsupported retirement proof table")
|
||||
return int(connection.execute(query).scalar_one())
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if getattr(self, "previous_runtime", None) is None:
|
||||
clear_runtime()
|
||||
else:
|
||||
configure_runtime(self.previous_runtime)
|
||||
if self.engine is not None:
|
||||
if self.ddl_listener_registered:
|
||||
event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||
self.engine.dispose()
|
||||
self.engine = None
|
||||
if self.admin_engine is not None:
|
||||
try:
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True))
|
||||
finally:
|
||||
self.admin_engine.dispose()
|
||||
self.admin_engine = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,187 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "release-doctor.py"
|
||||
|
||||
|
||||
def load_doctor_module():
|
||||
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ReleaseDoctorTests(unittest.TestCase):
|
||||
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
|
||||
names = set(doctor.release_repo_names(ROOT.parent))
|
||||
|
||||
self.assertIn("govoplan-core", names)
|
||||
self.assertIn("govoplan-files", names)
|
||||
self.assertIn("govoplan-idm", names)
|
||||
|
||||
def test_repo_findings_detect_dirty_repositories(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
name="govoplan-files",
|
||||
path="/tmp/govoplan-files",
|
||||
exists=True,
|
||||
dirty_entries=(" M pyproject.toml",),
|
||||
pyproject_version="0.1.7",
|
||||
local_tag_exists=True,
|
||||
)
|
||||
|
||||
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
||||
|
||||
self.assertEqual("blocker", findings[0].severity)
|
||||
self.assertEqual("repo-dirty", findings[0].check_id)
|
||||
self.assertIn("git status --short", [item.command for item in findings[0].commands])
|
||||
|
||||
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
name="govoplan-files",
|
||||
path="/tmp/govoplan-files",
|
||||
exists=True,
|
||||
safe_directory_required=True,
|
||||
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
|
||||
errors=("fatal: detected dubious ownership in repository",),
|
||||
)
|
||||
|
||||
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
||||
|
||||
self.assertEqual("repo-safe-directory", findings[0].check_id)
|
||||
self.assertEqual("blocker", findings[0].severity)
|
||||
self.assertTrue(findings[0].commands[0].mutating)
|
||||
self.assertIn("safe.directory", findings[0].commands[0].command)
|
||||
|
||||
def test_dubious_ownership_detection_matches_git_error(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
|
||||
self.assertTrue(
|
||||
doctor.is_dubious_ownership_error(
|
||||
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
|
||||
"git config --global --add safe.directory /workspace/repo"
|
||||
)
|
||||
)
|
||||
|
||||
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
|
||||
findings = doctor.migration_findings(
|
||||
{
|
||||
"release": {
|
||||
"ok": True,
|
||||
"graph_errors": [],
|
||||
"strict_errors": ["current migration heads are not recorded"],
|
||||
},
|
||||
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
|
||||
},
|
||||
target_version="0.2.0",
|
||||
)
|
||||
|
||||
self.assertEqual("migration-release-baseline", findings[0].check_id)
|
||||
self.assertEqual("blocker", findings[0].severity)
|
||||
commands = [item.command for item in findings[0].commands]
|
||||
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
|
||||
|
||||
def test_catalog_findings_detect_missing_signatures(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
|
||||
findings = doctor.catalog_findings(
|
||||
{
|
||||
"catalog_exists": True,
|
||||
"catalog_path": "/tmp/stable.json",
|
||||
"core_release_version": "0.1.7",
|
||||
"signature_count": 0,
|
||||
"keyring_exists": True,
|
||||
"key_count": 1,
|
||||
},
|
||||
target_version="0.1.7",
|
||||
online=False,
|
||||
)
|
||||
|
||||
self.assertEqual("catalog-signatures", findings[0].check_id)
|
||||
self.assertEqual("blocker", findings[0].severity)
|
||||
|
||||
def test_suggested_commands_are_deduplicated(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
|
||||
report = doctor.DoctorReport(
|
||||
generated_at="2026-07-11T00:00:00Z",
|
||||
workspace_root="/tmp",
|
||||
target_version="0.1.7",
|
||||
target_tag="v0.1.7",
|
||||
online=False,
|
||||
overall_status="blocked",
|
||||
repos=(),
|
||||
findings=(
|
||||
doctor.Finding("a", "blocker", "A", "", (suggested,)),
|
||||
doctor.Finding("b", "warning", "B", "", (suggested,)),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
|
||||
|
||||
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
name="govoplan-files",
|
||||
path="/tmp/govoplan-files",
|
||||
exists=True,
|
||||
dirty_entries=(" M pyproject.toml",),
|
||||
)
|
||||
report = doctor.DoctorReport(
|
||||
generated_at="2026-07-11T00:00:00Z",
|
||||
workspace_root="/tmp",
|
||||
target_version="0.1.7",
|
||||
target_tag="v0.1.7",
|
||||
online=False,
|
||||
overall_status="blocked",
|
||||
repos=(repo,),
|
||||
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
|
||||
)
|
||||
|
||||
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
|
||||
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
|
||||
|
||||
self.assertIn("dirty=1", concise)
|
||||
self.assertNotIn("M pyproject.toml", concise)
|
||||
self.assertIn("M pyproject.toml", detailed)
|
||||
|
||||
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
name="govoplan-files",
|
||||
path="/tmp/govoplan-files",
|
||||
exists=True,
|
||||
dirty_entries=(" M pyproject.toml",),
|
||||
)
|
||||
report = doctor.DoctorReport(
|
||||
generated_at="2026-07-11T00:00:00Z",
|
||||
workspace_root="/tmp",
|
||||
target_version="0.1.7",
|
||||
target_tag="v0.1.7",
|
||||
online=False,
|
||||
overall_status="blocked",
|
||||
repos=(repo,),
|
||||
findings=(),
|
||||
)
|
||||
|
||||
actions = dict(doctor.interactive_actions(report))
|
||||
|
||||
self.assertIn("commit-push-dirty", actions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,190 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
|
||||
|
||||
|
||||
def load_audit_module():
|
||||
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ReleaseMigrationAuditTests(unittest.TestCase):
|
||||
def test_typed_alembic_variables_are_parsed(self) -> None:
|
||||
audit = load_audit_module()
|
||||
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||
path = Path(directory) / "1234_example.py"
|
||||
path.write_text(
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "1234"
|
||||
down_revision: Union[str, None] = "base"
|
||||
depends_on: Union[str, None] = "core"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migration = audit.parse_migration_file("govoplan-core", path)
|
||||
|
||||
self.assertIsNotNone(migration)
|
||||
self.assertEqual(migration.revision, "1234")
|
||||
self.assertEqual(migration.down_revisions, ("base",))
|
||||
self.assertEqual(migration.depends_on, ("core",))
|
||||
self.assertEqual(migration.branch_labels, ())
|
||||
|
||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={
|
||||
"version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"release": "0.1.0",
|
||||
"heads": [{"owner": "govoplan-core", "revision": "head"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertEqual(report["current_heads"], ["head"])
|
||||
self.assertEqual(report["graph_errors"], [])
|
||||
self.assertEqual(report["strict_errors"], [])
|
||||
|
||||
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={
|
||||
"version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"release": "0.1.0",
|
||||
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
|
||||
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
|
||||
}
|
||||
],
|
||||
},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertEqual(report["current_heads"], ["core-head"])
|
||||
self.assertEqual(report["strict_errors"], [])
|
||||
|
||||
def test_records_current_release_baseline(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
||||
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
baseline = audit.record_release_baseline(
|
||||
{"version": 1, "releases": []},
|
||||
report,
|
||||
release="0.2.0",
|
||||
replace=False,
|
||||
)
|
||||
|
||||
release = baseline["releases"][0]
|
||||
self.assertEqual(release["release"], "0.2.0")
|
||||
self.assertEqual(release["squash_policy"], "reviewed-manual")
|
||||
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
|
||||
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
|
||||
|
||||
def test_missing_down_revision_is_a_graph_error(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertIn("references missing down_revision", report["graph_errors"][0])
|
||||
|
||||
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
|
||||
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertEqual(report["graph_errors"], [])
|
||||
self.assertEqual(report["current_heads"], ["files"])
|
||||
|
||||
def test_missing_depends_on_is_a_graph_error(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertIn("references missing depends_on", report["graph_errors"][0])
|
||||
|
||||
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
track="dev",
|
||||
)
|
||||
|
||||
self.assertEqual(report["track"], "dev")
|
||||
self.assertEqual(report["strict_errors"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
95
tests/test_scheduling_privacy_contract.py
Normal file
95
tests/test_scheduling_privacy_contract.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
PolicySourceStep,
|
||||
SchedulingParticipantPrivacyDecision,
|
||||
SchedulingParticipantPrivacyPolicy,
|
||||
SchedulingParticipantPrivacyRequest,
|
||||
scheduling_participant_privacy_policy,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _PrivacyPolicy:
|
||||
def resolve_scheduling_participant_visibility(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SchedulingParticipantPrivacyRequest,
|
||||
) -> SchedulingParticipantPrivacyDecision:
|
||||
del session
|
||||
return SchedulingParticipantPrivacyDecision(
|
||||
effective_visibility="aggregates_only",
|
||||
reason=f"Participant roster is restricted for {request.scheduling_request_id}.",
|
||||
details={"requested_visibility": request.requested_visibility},
|
||||
)
|
||||
|
||||
|
||||
class SchedulingPrivacyContractTests(unittest.TestCase):
|
||||
def test_capability_name_and_request_context_are_stable(self) -> None:
|
||||
self.assertEqual(
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
"policy.schedulingParticipantPrivacy",
|
||||
)
|
||||
request = SchedulingParticipantPrivacyRequest(
|
||||
tenant_id="tenant-1",
|
||||
scheduling_request_id="request-1",
|
||||
participant_id="participant-1",
|
||||
actor_user_id="user-1",
|
||||
requested_visibility="names_and_statuses",
|
||||
context={"request_status": "open"},
|
||||
)
|
||||
|
||||
self.assertEqual(request.requested_visibility, "names_and_statuses")
|
||||
self.assertEqual(request.context["request_status"], "open")
|
||||
|
||||
def test_decision_serializes_policy_provenance(self) -> None:
|
||||
decision = SchedulingParticipantPrivacyDecision(
|
||||
effective_visibility="aggregates_only",
|
||||
reason="Tenant privacy policy restricts the participant roster.",
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
label="Tenant",
|
||||
applied_fields=("scheduling_participant_visibility",),
|
||||
),
|
||||
),
|
||||
details={"requested_visibility": "names_and_statuses"},
|
||||
)
|
||||
|
||||
payload = decision.to_dict()
|
||||
self.assertEqual(payload["effective_visibility"], "aggregates_only")
|
||||
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
|
||||
self.assertEqual(payload["details"]["requested_visibility"], "names_and_statuses")
|
||||
|
||||
def test_optional_provider_resolves_through_platform_registry(self) -> None:
|
||||
provider = _PrivacyPolicy()
|
||||
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="policy_test",
|
||||
name="Policy test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
|
||||
},
|
||||
),
|
||||
)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(scheduling_participant_privacy_policy(registry), provider)
|
||||
|
||||
def test_optional_provider_lookup_does_not_choose_a_fallback(self) -> None:
|
||||
self.assertIsNone(scheduling_participant_privacy_policy(None))
|
||||
self.assertIsNone(scheduling_participant_privacy_policy(PlatformRegistry()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
130
tests/test_throttling.py
Normal file
130
tests/test_throttling.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from govoplan_core.core.throttling import (
|
||||
FixedWindowBucket,
|
||||
FixedWindowThrottle,
|
||||
InMemoryFixedWindowStore,
|
||||
ResilientFixedWindowStore,
|
||||
ThrottleDimension,
|
||||
build_fixed_window_throttle,
|
||||
)
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 100.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
|
||||
class _RecordingStore:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, int] = {}
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
return FixedWindowBucket(self.values.get(key, 0), 60)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
del window_seconds
|
||||
self.values[key] = self.values.get(key, 0) + 1
|
||||
return FixedWindowBucket(self.values[key], 60)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self.values.pop(key, None)
|
||||
|
||||
|
||||
class _FailingStore(_RecordingStore):
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
del key
|
||||
raise RedisError("offline")
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
del key, window_seconds
|
||||
raise RedisError("offline")
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
del key
|
||||
raise RedisError("offline")
|
||||
|
||||
|
||||
class FixedWindowThrottleTests(unittest.TestCase):
|
||||
def test_blocks_at_limit_and_expires_without_sleeping(self) -> None:
|
||||
clock = _Clock()
|
||||
store = InMemoryFixedWindowStore(clock=clock)
|
||||
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||
dimensions = (ThrottleDimension("password", "secret-subject", 3),)
|
||||
|
||||
self.assertTrue(throttle.check(dimensions).allowed)
|
||||
self.assertTrue(throttle.record(dimensions).allowed)
|
||||
self.assertTrue(throttle.record(dimensions).allowed)
|
||||
blocked = throttle.record(dimensions)
|
||||
self.assertFalse(blocked.allowed)
|
||||
self.assertEqual(blocked.retry_after_seconds, 60)
|
||||
self.assertFalse(throttle.check(dimensions).allowed)
|
||||
|
||||
clock.value += 61
|
||||
self.assertTrue(throttle.check(dimensions).allowed)
|
||||
|
||||
def test_subject_is_hashed_and_success_can_reset_it(self) -> None:
|
||||
store = _RecordingStore()
|
||||
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||
dimensions = (ThrottleDimension("poll-participation-password", "tenant:token-secret", 2),)
|
||||
|
||||
throttle.record(dimensions)
|
||||
|
||||
self.assertEqual(len(store.values), 1)
|
||||
stored_key = next(iter(store.values))
|
||||
self.assertNotIn("tenant:token-secret", stored_key)
|
||||
self.assertIn(":poll-participation-password:", stored_key)
|
||||
throttle.reset(dimensions)
|
||||
self.assertEqual(store.values, {})
|
||||
|
||||
def test_resilient_store_uses_bounded_local_fallback(self) -> None:
|
||||
clock = _Clock()
|
||||
fallback = InMemoryFixedWindowStore(max_entries=2, clock=clock)
|
||||
store = ResilientFixedWindowStore(
|
||||
_FailingStore(),
|
||||
fallback,
|
||||
retry_seconds=30,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
first = store.increment("first", window_seconds=60)
|
||||
second = store.increment("first", window_seconds=60)
|
||||
store.increment("second", window_seconds=60)
|
||||
store.increment("third", window_seconds=60)
|
||||
|
||||
self.assertEqual(first.count, 1)
|
||||
self.assertEqual(second.count, 2)
|
||||
self.assertEqual(fallback.read("first").count, 0)
|
||||
self.assertEqual(fallback.read("third").count, 1)
|
||||
|
||||
def test_builder_operates_without_redis(self) -> None:
|
||||
throttle = build_fixed_window_throttle(
|
||||
redis_url=None,
|
||||
window_seconds=30,
|
||||
max_local_entries=10,
|
||||
)
|
||||
dimensions = (ThrottleDimension("test", "subject", 1),)
|
||||
|
||||
self.assertFalse(throttle.record(dimensions).allowed)
|
||||
self.assertFalse(throttle.check(dimensions).allowed)
|
||||
|
||||
def test_rejects_invalid_dimensions(self) -> None:
|
||||
throttle = FixedWindowThrottle(_RecordingStore(), window_seconds=60)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "At least one"):
|
||||
throttle.check(())
|
||||
with self.assertRaisesRegex(ValueError, "positive"):
|
||||
throttle.check((ThrottleDimension("test", "subject", 0),))
|
||||
with self.assertRaisesRegex(ValueError, "namespaces"):
|
||||
throttle.check((ThrottleDimension("Invalid namespace", "subject", 1),))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
143
tests/test_wheel_runtime.py
Normal file
143
tests/test_wheel_runtime.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class WheelRuntimeTests(unittest.TestCase):
|
||||
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
access_repository = repository_root.parent / "govoplan-access"
|
||||
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
|
||||
temporary_root = Path(directory)
|
||||
wheel_directory = temporary_root / "wheels"
|
||||
wheel_directory.mkdir()
|
||||
self._run(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"wheel",
|
||||
"--no-deps",
|
||||
"--wheel-dir",
|
||||
str(wheel_directory),
|
||||
str(repository_root),
|
||||
cwd=temporary_root,
|
||||
)
|
||||
self._run(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"wheel",
|
||||
"--no-deps",
|
||||
"--wheel-dir",
|
||||
str(wheel_directory),
|
||||
str(access_repository),
|
||||
cwd=temporary_root,
|
||||
)
|
||||
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
|
||||
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
|
||||
self.assertEqual(1, len(core_wheels))
|
||||
self.assertEqual(1, len(access_wheels))
|
||||
|
||||
virtual_environment = temporary_root / "venv"
|
||||
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
|
||||
venv_python = virtual_environment / "bin" / "python"
|
||||
self._run(
|
||||
str(venv_python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-deps",
|
||||
str(core_wheels[0]),
|
||||
str(access_wheels[0]),
|
||||
cwd=temporary_root,
|
||||
)
|
||||
|
||||
database_path = temporary_root / "wheel-runtime.db"
|
||||
probe = self._run(
|
||||
str(venv_python),
|
||||
"-c",
|
||||
self._probe_script(),
|
||||
str(database_path),
|
||||
cwd=temporary_root,
|
||||
env={
|
||||
**os.environ,
|
||||
# Reuse only third-party test dependencies from the parent
|
||||
# environment. Editable .pth files are not processed from
|
||||
# PYTHONPATH, so Core itself can only come from the wheel.
|
||||
"PYTHONPATH": sysconfig.get_path("purelib"),
|
||||
"GOVOPLAN_CORE_SOURCE_ROOT": "",
|
||||
"GOVOPLAN_ENABLED_MODULES": "",
|
||||
},
|
||||
)
|
||||
result = json.loads(probe.stdout)
|
||||
installed_package = Path(result["package_file"])
|
||||
runtime_root = Path(result["runtime_root"])
|
||||
self.assertTrue(installed_package.is_relative_to(virtual_environment))
|
||||
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
|
||||
self.assertNotEqual(repository_root, runtime_root)
|
||||
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||
self.assertIn("4f2a9c8e7b6d", result["heads"])
|
||||
self.assertIn("core_scopes", result["tables"])
|
||||
self.assertIn("core_system_settings", result["tables"])
|
||||
|
||||
@staticmethod
|
||||
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AssertionError(
|
||||
f"Command failed ({result.returncode}): {' '.join(command)}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _probe_script() -> str:
|
||||
return """
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from alembic import command
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
import govoplan_core
|
||||
from govoplan_core.db.migrations import _repo_root, alembic_config
|
||||
|
||||
database_url = f"sqlite:///{Path(sys.argv[1])}"
|
||||
config = alembic_config(database_url=database_url, enabled_modules=())
|
||||
command.upgrade(config, "heads")
|
||||
script = ScriptDirectory.from_config(config)
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
tables = sorted(inspect(connection).get_table_names())
|
||||
finally:
|
||||
engine.dispose()
|
||||
print(json.dumps({
|
||||
"package_file": govoplan_core.__file__,
|
||||
"runtime_root": str(_repo_root()),
|
||||
"heads": sorted(script.get_heads()),
|
||||
"tables": tables,
|
||||
}))
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
48
webui/package-lock.json
generated
48
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -46,12 +46,12 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -65,9 +65,9 @@
|
||||
},
|
||||
"../../govoplan-addresses/webui": {
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -86,7 +86,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"name": "@govoplan/audit-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -118,7 +118,7 @@
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -173,9 +173,9 @@
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -192,9 +192,9 @@
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -213,7 +213,7 @@
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -230,12 +230,12 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -251,7 +251,7 @@
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -289,7 +289,7 @@
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -306,9 +306,9 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -322,9 +322,9 @@
|
||||
},
|
||||
"../../govoplan-scheduling/webui": {
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||
@@ -787,13 +787,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
|
||||
"version": "0.1.11",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -21,9 +21,18 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -24,7 +24,6 @@
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||
@@ -32,8 +31,7 @@
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
||||
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
||||
|
||||
22
webui/scripts/test-dialog-focus-structure.mjs
Normal file
22
webui/scripts/test-dialog-focus-structure.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = readFileSync("src/components/Dialog.tsx", "utf8");
|
||||
const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
|
||||
|
||||
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
|
||||
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
|
||||
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
|
||||
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
|
||||
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
|
||||
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
|
||||
assert(stackSource.includes('panel.setAttribute("inert", "")'), "underlying dialog panels become inert");
|
||||
assert(stackSource.includes('panel.setAttribute("aria-hidden", "true")'), "underlying dialogs leave the accessibility tree");
|
||||
assert(source.includes('data-dialog-stack-state="topmost"'), "Dialog exposes stack state for custom keyboard interactions");
|
||||
assert(source.includes("dialogIsTopmost(stackIdRef.current)"), "underlying backdrops cannot close their dialog");
|
||||
assert(source.includes("shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)"), "Dialog preserves explicit backdrop-close conditions");
|
||||
|
||||
console.log("Dialog focus and stack lifecycle structure checks passed.");
|
||||
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
|
||||
const normalized = source.replace(/\s+/g, " ");
|
||||
|
||||
function assertIncludes(fragment, message) {
|
||||
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
|
||||
}
|
||||
|
||||
assertIncludes(
|
||||
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
|
||||
"drag enter/over must prevent the browser default and stop propagation"
|
||||
);
|
||||
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
|
||||
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
|
||||
assertIncludes(
|
||||
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
|
||||
"drag leave must prevent the browser default and stop propagation"
|
||||
);
|
||||
assertIncludes(
|
||||
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
|
||||
"drop must prevent the browser default and stop propagation before resolving files"
|
||||
);
|
||||
assertIncludes(
|
||||
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
|
||||
"disabled drops must route the disabled rejection reason"
|
||||
);
|
||||
assertIncludes(
|
||||
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
|
||||
"drop must route the event DataTransfer through the shared resolver"
|
||||
);
|
||||
assertIncludes(
|
||||
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
|
||||
"resolved files and empty drops must be routed through their explicit callbacks"
|
||||
);
|
||||
assertIncludes(
|
||||
".catch(() => onRejectedDrop?.(\"unreadable\"))",
|
||||
"resolver failures must route the unreadable rejection reason"
|
||||
);
|
||||
|
||||
console.log("FileDropZone event and result routing structure is intact.");
|
||||
@@ -5,12 +5,14 @@ const packageByModule = {
|
||||
admin: "@govoplan/admin-webui",
|
||||
addresses: "@govoplan/addresses-webui",
|
||||
audit: "@govoplan/audit-webui",
|
||||
calendar: "@govoplan/calendar-webui",
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
dashboard: "@govoplan/dashboard-webui",
|
||||
docs: "@govoplan/docs-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
idm: "@govoplan/idm-webui",
|
||||
mail: "@govoplan/mail-webui",
|
||||
notifications: "@govoplan/notifications-webui",
|
||||
organizations: "@govoplan/organizations-webui",
|
||||
ops: "@govoplan/ops-webui",
|
||||
policy: "@govoplan/policy-webui",
|
||||
@@ -25,8 +27,10 @@ const cases = [
|
||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||
{ name: "calendar-only", modules: ["calendar"] },
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
{ name: "notifications-only", modules: ["notifications"] },
|
||||
{ name: "organizations-only", modules: ["organizations"] },
|
||||
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
||||
{ name: "campaign-only", modules: ["campaigns"] },
|
||||
@@ -35,7 +39,7 @@ const cases = [
|
||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] }
|
||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
@@ -30,16 +30,23 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
@@ -87,6 +94,7 @@ export default function App() {
|
||||
try {
|
||||
const response = await fetchPlatformStatus(settings);
|
||||
if (cancelled) return;
|
||||
setBackendReachable(true);
|
||||
setMaintenanceMode(response.maintenance_mode);
|
||||
if (response.i18n) {
|
||||
setSystemLanguages({
|
||||
@@ -100,7 +108,10 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
|
||||
if (!cancelled) {
|
||||
setBackendReachable(false);
|
||||
setMaintenanceMode({ enabled: false, message: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +125,14 @@ export default function App() {
|
||||
};
|
||||
}, [settings.apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPlatformPublicModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||
return () => {cancelled = true;};
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -121,9 +140,13 @@ export default function App() {
|
||||
async function bootstrapAuth() {
|
||||
try {
|
||||
const shellAuth = await fetchShellAuth(settings);
|
||||
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setBackendReachable(true);
|
||||
setAuth(normalizeAuthInfo(shellAuth));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setBackendReachable(isApiError(error));
|
||||
const cleared = { ...settings, accessToken: "" };
|
||||
setSettings(cleared);
|
||||
saveApiSettings(cleared);
|
||||
@@ -224,6 +247,18 @@ export default function App() {
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!platformPublicModules?.length) {
|
||||
setRemotePublicWebModules([]);
|
||||
return () => {cancelled = true;};
|
||||
}
|
||||
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [platformPublicModules, localPublicWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
@@ -243,6 +278,7 @@ export default function App() {
|
||||
try {
|
||||
const sessionInfo = await fetchSession(settings);
|
||||
if (cancelled) return;
|
||||
setBackendReachable(true);
|
||||
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
||||
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
||||
const shellAuth = await fetchShellAuth(settings);
|
||||
@@ -252,7 +288,10 @@ export default function App() {
|
||||
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
||||
: normalizeAuthInfo(shellAuth));
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!cancelled && !isApiError(error)) {
|
||||
setBackendReachable(false);
|
||||
}
|
||||
|
||||
|
||||
// A background refresh must not log the user out on a transient network error.
|
||||
@@ -271,9 +310,9 @@ export default function App() {
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||
@@ -291,10 +330,21 @@ export default function App() {
|
||||
if (!auth) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes>
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
@@ -324,11 +374,18 @@ export default function App() {
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
{moduleRoutes.map((route) =>
|
||||
<Route
|
||||
key={route.path}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo } from "../types";
|
||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||
|
||||
export type PlatformStatusResponse = {
|
||||
maintenance_mode: {
|
||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||
}
|
||||
|
||||
35
webui/src/api/resourceAccess.ts
Normal file
35
webui/src/api/resourceAccess.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch, apiPath } from "./client";
|
||||
import type {
|
||||
AccessDecisionProvenanceItem,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse,
|
||||
ResourceAccessExplanationUser
|
||||
} from "./resourceAccessContracts";
|
||||
export type {
|
||||
AccessDecisionProvenanceItem,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse,
|
||||
ResourceAccessExplanationUser
|
||||
} from "./resourceAccessContracts";
|
||||
|
||||
/**
|
||||
* Fetches the platform's optional resource-access explanation endpoint.
|
||||
* Core owns the transport contract without requiring the Access module that
|
||||
* contributes the endpoint at runtime.
|
||||
*/
|
||||
export function fetchResourceAccessExplanation<
|
||||
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
|
||||
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
|
||||
>(
|
||||
settings: ApiSettings,
|
||||
options: ResourceAccessExplanationOptions
|
||||
): Promise<ResourceAccessExplanationResponse<TUser, TProvenance>> {
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action,
|
||||
tenant_id: options.tenantId
|
||||
}));
|
||||
}
|
||||
34
webui/src/api/resourceAccessContracts.ts
Normal file
34
webui/src/api/resourceAccessContracts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type ResourceAccessExplanationUser = {
|
||||
id: string;
|
||||
account_id?: string | null;
|
||||
email?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type AccessDecisionProvenanceItem = {
|
||||
kind: string;
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tenant_id?: string | null;
|
||||
source?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationResponse<
|
||||
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
|
||||
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
|
||||
> = {
|
||||
user: TUser;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
provenance: TProvenance[];
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationOptions = {
|
||||
userId: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
action: string;
|
||||
tenantId?: string | null;
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import DisabledActionTooltip from "./DisabledActionTooltip";
|
||||
|
||||
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
|
||||
return <button className={`btn btn-${variant} ${className}`} {...props} />;
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
disabledReason?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
|
||||
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { Palette } from "lucide-react";
|
||||
import { Palette } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";
|
||||
|
||||
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
value: string;
|
||||
@@ -20,27 +22,6 @@ function normalizeColor(value: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export default function ColorPickerField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -51,7 +32,7 @@ export default function ColorPickerField({
|
||||
...props
|
||||
}: ColorPickerFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const rootRef = useOutsideDismiss(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const normalizedValue = normalizeColor(value || "");
|
||||
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||
|
||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
||||
value: string;
|
||||
@@ -48,32 +49,11 @@ function combineDateTime(date: string, time: string): string {
|
||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
||||
const selectedDate = parseDate(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const rootRef = useOutsideDismiss(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
|
||||
import {
|
||||
dialogIsTopmost,
|
||||
nextDialogActivationOrder,
|
||||
registerDialog,
|
||||
type DialogStackId
|
||||
} from "./dialogStack";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
@@ -46,6 +53,20 @@ export default function Dialog({
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const stackIdRef = useRef<DialogStackId>(Symbol("govoplan-dialog"));
|
||||
const activationOrderRef = useRef<number | null>(null);
|
||||
const canCloseRef = useRef(canClose);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(
|
||||
typeof document !== "undefined" && typeof HTMLElement !== "undefined" && document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
);
|
||||
canCloseRef.current = canClose;
|
||||
onCloseRef.current = onClose;
|
||||
if (!open) activationOrderRef.current = null;
|
||||
else if (activationOrderRef.current === null) activationOrderRef.current = nextDialogActivationOrder();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedTitle = translateReactNode(title, translateText);
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
@@ -53,15 +74,36 @@ export default function Dialog({
|
||||
const translatedCloseLabel = translateText(closeLabel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canClose) return undefined;
|
||||
if (open || typeof document === "undefined") return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose?.();
|
||||
const rememberFocusedElement = () => {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return;
|
||||
if (panelRef.current?.contains(activeElement)) return;
|
||||
restoreFocusRef.current = activeElement;
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [canClose, onClose, open]);
|
||||
rememberFocusedElement();
|
||||
document.addEventListener("focusin", rememberFocusedElement);
|
||||
return () => document.removeEventListener("focusin", rememberFocusedElement);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === "undefined") return undefined;
|
||||
|
||||
const panel = panelRef.current;
|
||||
const activationOrder = activationOrderRef.current;
|
||||
if (!panel || activationOrder === null) return undefined;
|
||||
|
||||
return registerDialog({
|
||||
id: stackIdRef.current,
|
||||
activationOrder,
|
||||
panel,
|
||||
restoreFocus: restoreFocusRef.current,
|
||||
canClose: () => canCloseRef.current,
|
||||
onClose: () => onCloseRef.current?.()
|
||||
}, document.activeElement);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -70,13 +112,19 @@ export default function Dialog({
|
||||
className={joinClasses("dialog-backdrop", backdropClassName)}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
|
||||
if (
|
||||
dialogIsTopmost(stackIdRef.current)
|
||||
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
|
||||
) onClose?.();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={joinClasses("dialog-panel", className)}
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
data-dialog-stack-state="topmost"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
|
||||
@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type ExplorerTreeProps<T> = {
|
||||
type ExplorerTreeCommonProps<T> = {
|
||||
nodes: T[];
|
||||
getNodeId: (node: T) => string;
|
||||
getNodeLabel: (node: T) => string;
|
||||
getNodeChildren: (node: T) => T[];
|
||||
activeId?: string;
|
||||
expandedIds: Set<string>;
|
||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
|
||||
nodeButtonBaseClassName?: string;
|
||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
||||
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
@@ -41,15 +40,37 @@ export type ExplorerTreeProps<T> = {
|
||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
export default function ExplorerTree<T>({
|
||||
type CollapsibleExplorerTreeProps<T> = {
|
||||
collapsible?: true;
|
||||
expandedIds: ReadonlySet<string>;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
type StaticExplorerTreeProps = {
|
||||
collapsible: false;
|
||||
expandedIds?: never;
|
||||
onToggle?: never;
|
||||
};
|
||||
|
||||
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
|
||||
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
|
||||
);
|
||||
|
||||
type SafeNode<T> = {
|
||||
id: string;
|
||||
node: T;
|
||||
};
|
||||
|
||||
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
|
||||
const {
|
||||
nodes,
|
||||
getNodeId,
|
||||
getNodeLabel,
|
||||
getNodeChildren,
|
||||
activeId = "",
|
||||
expandedIds,
|
||||
onOpen,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
depth = 1,
|
||||
className = "",
|
||||
@@ -60,6 +81,7 @@ export default function ExplorerTree<T>({
|
||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||
renderToggleIcon,
|
||||
renderNodeContent,
|
||||
renderNodeActions,
|
||||
getNodeWrapClassName,
|
||||
getNodeWrapStyle,
|
||||
getNodeButtonClassName,
|
||||
@@ -70,19 +92,40 @@ export default function ExplorerTree<T>({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}: ExplorerTreeProps<T>) {
|
||||
if (nodes.length === 0) return null;
|
||||
} = props;
|
||||
const collapsible = props.collapsible !== false;
|
||||
const expandedIds = props.collapsible === false ? EMPTY_EXPANDED_IDS : props.expandedIds;
|
||||
const onToggle = props.collapsible === false ? undefined : props.onToggle;
|
||||
|
||||
function pathSafeNodes(levelNodes: T[], ancestorIds: ReadonlySet<string>): SafeNode<T>[] {
|
||||
const siblingIds = new Set<string>();
|
||||
const safeNodes: SafeNode<T>[] = [];
|
||||
for (const node of levelNodes) {
|
||||
const id = getNodeId(node);
|
||||
if (ancestorIds.has(id) || siblingIds.has(id)) continue;
|
||||
siblingIds.add(id);
|
||||
safeNodes.push({ id, node });
|
||||
}
|
||||
return safeNodes;
|
||||
}
|
||||
|
||||
function renderLevel(levelNodes: T[], currentDepth: number, ancestorIds: ReadonlySet<string>, root = false): ReactNode {
|
||||
const safeNodes = pathSafeNodes(levelNodes, ancestorIds);
|
||||
if (safeNodes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
<div className={[childrenBaseClassName, root ? className : ""].filter(Boolean).join(" ")}>
|
||||
{safeNodes.map(({ id: nodeId, node }) => {
|
||||
const nextAncestorIds = new Set(ancestorIds);
|
||||
nextAncestorIds.add(nodeId);
|
||||
const children = getNodeChildren(node);
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = expandedIds.has(nodeId);
|
||||
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
|
||||
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
|
||||
const active = activeId === nodeId;
|
||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
||||
const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled };
|
||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||
const nodeActions = renderNodeActions?.(node, context);
|
||||
const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false;
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={nodeContainerClassName}>
|
||||
@@ -90,73 +133,69 @@ export default function ExplorerTree<T>({
|
||||
className={[
|
||||
nodeWrapBaseClassName,
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""].
|
||||
filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
hasNodeActions ? "has-actions" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""
|
||||
].filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(currentDepth * 14, 56)}px` }}
|
||||
draggable={draggable && !disabled}
|
||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
|
||||
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||
>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBaseClassName}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node, context);
|
||||
if (hasChildren) onToggle?.(node, context);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506", value1: getNodeLabel(node) })}
|
||||
aria-expanded={hasChildren ? expanded : undefined}>
|
||||
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||
value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506",
|
||||
value1: getNodeLabel(node)
|
||||
})}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
>
|
||||
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`${toggleBaseClassName} explorer-tree-toggle-static`} aria-hidden="true">
|
||||
{renderToggleIcon?.(node, context) ?? (hasChildren ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
onClick={() => onOpen(node, context)}
|
||||
disabled={disabled}>
|
||||
|
||||
disabled={disabled}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded &&
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
childrenBaseClassName={childrenBaseClassName}
|
||||
nodeContainerClassName={nodeContainerClassName}
|
||||
nodeWrapBaseClassName={nodeWrapBaseClassName}
|
||||
toggleBaseClassName={toggleBaseClassName}
|
||||
nodeButtonBaseClassName={nodeButtonBaseClassName}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
{hasNodeActions && (
|
||||
<div
|
||||
className="explorer-tree-actions"
|
||||
role="group"
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||
value0: "i18n:govoplan-core.actions.c3cd636a",
|
||||
value1: getNodeLabel(node)
|
||||
})}
|
||||
</div>);
|
||||
>
|
||||
{nodeActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderLevel(nodes, depth, new Set(), true);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
import { resolveDroppedFiles } from "./resolveDroppedFiles";
|
||||
|
||||
type RejectedDropReason = "disabled" | "empty" | "unreadable";
|
||||
|
||||
export type FileDropZoneProps = {
|
||||
accept?: string;
|
||||
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
|
||||
note?: ReactNode;
|
||||
className?: string;
|
||||
inputLabel?: string;
|
||||
onRejectedDrop?: (reason: RejectedDropReason) => void;
|
||||
onFiles: (files: File[]) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -30,6 +34,7 @@ export default function FileDropZone({
|
||||
note,
|
||||
className = "",
|
||||
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
||||
onRejectedDrop,
|
||||
onFiles
|
||||
}: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -52,6 +57,13 @@ export default function FileDropZone({
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
|
||||
if (!interactionDisabled) setDragActive(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -70,15 +82,29 @@ export default function FileDropZone({
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
onDragEnter={prepareFileDrag}
|
||||
onDragOver={prepareFileDrag}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
if (!interactionDisabled) setDragActive(true);
|
||||
event.stopPropagation();
|
||||
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||
setDragActive(false);
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
|
||||
if (interactionDisabled) {
|
||||
onRejectedDrop?.("disabled");
|
||||
return;
|
||||
}
|
||||
void resolveDroppedFiles(event.dataTransfer).then((files) => {
|
||||
if (files.length === 0) {
|
||||
onRejectedDrop?.("empty");
|
||||
return;
|
||||
}
|
||||
void handleFiles(files);
|
||||
}).catch(() => onRejectedDrop?.("unreadable"));
|
||||
}}>
|
||||
|
||||
{showProgress ?
|
||||
|
||||
31
webui/src/components/IconButton.tsx
Normal file
31
webui/src/components/IconButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children" | "title"> & {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export default function IconButton({
|
||||
label,
|
||||
icon,
|
||||
className = "",
|
||||
type = "button",
|
||||
...buttonProps
|
||||
}: IconButtonProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
type={type}
|
||||
className={["icon-button", className].filter(Boolean).join(" ")}
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
>
|
||||
<span className="icon-button-icon" aria-hidden="true">{icon}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
detail?: string | null;
|
||||
linkedToCampaign?: boolean | null;
|
||||
archiveGroup?: string | null;
|
||||
archiveLabel?: string | null;
|
||||
protected?: boolean | null;
|
||||
@@ -22,6 +23,32 @@ export type MessageDisplayAttachment = {
|
||||
|
||||
type MessageDisplayBodyMode = "text" | "html";
|
||||
|
||||
const MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"connect-src 'none'",
|
||||
"font-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-src 'none'",
|
||||
"img-src data: cid:",
|
||||
"manifest-src 'none'",
|
||||
"media-src 'none'",
|
||||
"object-src 'none'",
|
||||
"prefetch-src 'none'",
|
||||
"script-src 'none'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"worker-src 'none'"
|
||||
].join("; ");
|
||||
|
||||
const URL_ATTRIBUTE_PATTERN = /(\s)(src|href|xlink:href|poster|background|action|formaction|cite|longdesc|ping|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||
const SOURCE_SET_ATTRIBUTE_PATTERN = /\s(?:srcset|imagesrcset)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||
const EVENT_HANDLER_ATTRIBUTE_PATTERN = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)|"[^"]*"|'[^']*')[^;]*;?/gi;
|
||||
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
|
||||
const UNSAFE_DOCUMENT_ELEMENT_PATTERN = /<\s*(?:base|link|meta)\b[^>]*>/gi;
|
||||
const SCRIPT_BLOCK_PATTERN = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi;
|
||||
const SCRIPT_TAG_PATTERN = /<\s*\/?\s*script\b[^>]*>/gi;
|
||||
|
||||
type MessageDisplayPanelProps = {
|
||||
title?: string | null;
|
||||
fields?: MessageDisplayField[];
|
||||
@@ -70,6 +97,7 @@ export default function MessageDisplayPanel({
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||
const safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
@@ -103,7 +131,12 @@ export default function MessageDisplayPanel({
|
||||
}
|
||||
</div>
|
||||
{activeBodyMode === "html" ?
|
||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
||||
<iframe
|
||||
className="message-display-html-frame"
|
||||
title="i18n:govoplan-core.rendered_html_message_body.8638b634"
|
||||
sandbox=""
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={safeHtmlDocument} /> :
|
||||
|
||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||
}
|
||||
@@ -159,9 +192,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
|
||||
const linkStateClass = attachment.linkedToCampaign === true ?
|
||||
" is-linked" :
|
||||
attachment.linkedToCampaign === false ?
|
||||
" is-unlinked" :
|
||||
"";
|
||||
return (
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<div className={`message-display-attachment-row${linkStateClass}`}>
|
||||
<LinkIcon size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
@@ -271,3 +310,67 @@ function formatBytes(value?: number | null): string {
|
||||
function stripHtml(value: string): string {
|
||||
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the isolated document used for untrusted email HTML previews.
|
||||
*
|
||||
* Attribute neutralization keeps common clients from even attempting remote
|
||||
* resource resolution. The document-local CSP is the authoritative backstop
|
||||
* for malformed markup and parser edge cases: no network-capable resource is
|
||||
* permitted, while inline presentation plus embedded CID/raster data images
|
||||
* remain usable inside the already scriptless sandbox.
|
||||
*/
|
||||
export function buildSafeMessageHtmlDocument(value: string): string {
|
||||
const neutralized = neutralizeRemoteMessageContent(value);
|
||||
return [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
'<meta charset="utf-8">',
|
||||
`<meta http-equiv="Content-Security-Policy" content="${MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY}">`,
|
||||
'<meta name="referrer" content="no-referrer">',
|
||||
"</head>",
|
||||
`<body>${neutralized}</body>`,
|
||||
"</html>"
|
||||
].join("");
|
||||
}
|
||||
|
||||
function neutralizeRemoteMessageContent(value: string): string {
|
||||
return value.
|
||||
replace(SCRIPT_BLOCK_PATTERN, "").
|
||||
replace(SCRIPT_TAG_PATTERN, "").
|
||||
replace(UNSAFE_DOCUMENT_ELEMENT_PATTERN, "").
|
||||
replace(EVENT_HANDLER_ATTRIBUTE_PATTERN, "").
|
||||
replace(SOURCE_SET_ATTRIBUTE_PATTERN, "").
|
||||
replace(URL_ATTRIBUTE_PATTERN, (_match, spacing: string, attributeName: string, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||
const url = doubleQuoted ?? singleQuoted ?? unquoted ?? "";
|
||||
if (!messagePreviewUrlAllowed(attributeName, url)) return "";
|
||||
return `${spacing}${attributeName.toLowerCase()}="${escapeHtmlAttribute(url.trim())}"`;
|
||||
}).
|
||||
replace(CSS_IMPORT_PATTERN, "").
|
||||
replace(CSS_URL_PATTERN, (_match, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||
const url = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").trim();
|
||||
return isSafeEmbeddedMessageResource(url) ? `url("${escapeCssString(url)}")` : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function messagePreviewUrlAllowed(attributeName: string, value: string): boolean {
|
||||
const attribute = attributeName.toLowerCase();
|
||||
if (["action", "formaction", "ping"].includes(attribute)) return false;
|
||||
const url = value.trim();
|
||||
if ((attribute === "href" || attribute === "xlink:href") && /^#[^\s]*$/.test(url)) return true;
|
||||
return isSafeEmbeddedMessageResource(url);
|
||||
}
|
||||
|
||||
function isSafeEmbeddedMessageResource(value: string): boolean {
|
||||
if (/^cid:[^\s"'<>]+$/i.test(value)) return true;
|
||||
return /^data:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i.test(value);
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeCssString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedValue = typeof value === "string" ? translateText(value) : value;
|
||||
|
||||
return (
|
||||
<div className={`metric-card metric-${tone}`}>
|
||||
<div className="metric-label">{label}</div>
|
||||
<div className="metric-value">{value}</div>
|
||||
{detail && <div className="metric-detail">{detail}</div>}
|
||||
<div className="metric-label">{translateText(label)}</div>
|
||||
<div className="metric-value">{renderedValue}</div>
|
||||
{detail && <div className="metric-detail">{translateText(detail)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
84
webui/src/components/ResourceAccessExplanation.tsx
Normal file
84
webui/src/components/ResourceAccessExplanation.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
|
||||
|
||||
export type ResourceAccessExplanationProps = {
|
||||
loading?: boolean;
|
||||
explanation?: ResourceAccessExplanationResponse | null;
|
||||
fallbackResourceLabel?: string;
|
||||
};
|
||||
|
||||
export default function ResourceAccessExplanation({
|
||||
loading = false,
|
||||
explanation,
|
||||
fallbackResourceLabel = ""
|
||||
}: ResourceAccessExplanationProps) {
|
||||
if (loading) {
|
||||
return <p className="muted small-note">i18n:govoplan-core.loading_access_explanation.04a7c934</p>;
|
||||
}
|
||||
if (!explanation) return null;
|
||||
|
||||
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
||||
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label ||
|
||||
fallbackResourceLabel ||
|
||||
explanation.resource_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-core.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
||||
<div><span className="form-label">i18n:govoplan-core.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
||||
</div>
|
||||
{explanation.provenance.length === 0 ? (
|
||||
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
|
||||
) : (
|
||||
<div className="admin-assignment-grid">
|
||||
{explanation.provenance.map((item, index) => (
|
||||
<div key={`${item.kind}:${item.id ?? index}`}>
|
||||
<strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong>
|
||||
<div className="muted small-note">
|
||||
{item.source || "i18n:govoplan-core.no_source.6dcf9723"}
|
||||
{item.id && <> · <code>{item.id}</code></>}
|
||||
</div>
|
||||
{item.label && <p>{item.label}</p>}
|
||||
{Object.keys(item.details ?? {}).length > 0 && (
|
||||
<p className="muted small-note">{formatResourceAccessProvenanceDetails(item.details ?? {})}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function resourceAccessProvenanceKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "resource":
|
||||
return "i18n:govoplan-core.resource.d1c626a9";
|
||||
case "owner":
|
||||
return "i18n:govoplan-core.owner.89ff3122";
|
||||
case "share":
|
||||
return "i18n:govoplan-core.share.09ca55ca";
|
||||
case "policy":
|
||||
return "i18n:govoplan-core.policy.0b779a05";
|
||||
case "role":
|
||||
return "i18n:govoplan-core.role.b5b4a5a2";
|
||||
case "right":
|
||||
return "i18n:govoplan-core.permission.2f81a22d";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatResourceAccessProvenanceDetails(details: Record<string, unknown>): string {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${formatResourceAccessProvenanceValue(value)}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function formatResourceAccessProvenanceValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "i18n:govoplan-core.none.6eef6648";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
67
webui/src/components/SelectionList.tsx
Normal file
67
webui/src/components/SelectionList.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "role"> & {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type SelectionListItemProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"aria-selected" | "children" | "role"
|
||||
> & {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export default function SelectionList({
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
className = "",
|
||||
label,
|
||||
...props
|
||||
}: SelectionListProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const rootClassName = ["selection-list", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={rootClassName}
|
||||
role="listbox"
|
||||
aria-label={label ? translateText(label) : ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectionListItem({
|
||||
children,
|
||||
className = "",
|
||||
disabled = false,
|
||||
selected,
|
||||
type = "button",
|
||||
...props
|
||||
}: SelectionListItemProps) {
|
||||
const itemClassName = [
|
||||
"selection-list-item",
|
||||
selected ? "is-selected" : "",
|
||||
disabled ? "is-disabled" : "",
|
||||
className
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={itemClassName}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type ToggleSwitchProps = {
|
||||
label: ReactNode;
|
||||
activeLabel?: ReactNode;
|
||||
inactiveLabel?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
help?: ReactNode;
|
||||
};
|
||||
|
||||
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
|
||||
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
||||
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||
return (
|
||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
aria-label={inputLabel}
|
||||
onChange={(event) => onChange?.(event.target.checked)}
|
||||
/>
|
||||
{hasStateLabels && inactiveLabel !== undefined &&
|
||||
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
|
||||
{renderedInactiveLabel}
|
||||
</span>
|
||||
}
|
||||
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
||||
{hasStateLabels && activeLabel !== undefined &&
|
||||
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
|
||||
{renderedActiveLabel}
|
||||
</span>
|
||||
}
|
||||
{!hasStateLabels &&
|
||||
<span className="toggle-switch-copy">
|
||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||
/**
|
||||
* Route an explicit Discard button through the same confirmation used for
|
||||
* dirty navigation. The action runs after either saving or discarding.
|
||||
*/
|
||||
requestDiscard: (action: UnsavedNavigationAction) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
setPendingAction(() => action);
|
||||
}, []);
|
||||
|
||||
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||
requestNavigation(action);
|
||||
}, [requestNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
const active = registrationRef.current;
|
||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChanges,
|
||||
requestNavigation
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
||||
requestNavigation,
|
||||
requestDiscard
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
requestNavigation: (action) => action(),
|
||||
requestDiscard: (action) => action()
|
||||
};
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
|
||||
@@ -1,29 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import IconButton, { type IconButtonProps } from "../IconButton";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
export type AdminIconButtonProps = IconButtonProps;
|
||||
|
||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
className="admin-icon-button"
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
/** @deprecated Prefer the neutral IconButton export for new consumers. */
|
||||
export default function AdminIconButton({ className = "", ...props }: AdminIconButtonProps) {
|
||||
return <IconButton {...props} className={["admin-icon-button", className].filter(Boolean).join(" ")} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import type { ReactNode } from "react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -37,8 +38,8 @@ export default function AdminSelectionList({
|
||||
}} />
|
||||
|
||||
<span>
|
||||
<strong>{translateText(option.label)}</strong>
|
||||
{option.description && <small>{translateText(option.description)}</small>}
|
||||
<strong>{translateReactNode(option.label, translateText)}</strong>
|
||||
{option.description && <small>{translateReactNode(option.description, translateText)}</small>}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
106
webui/src/components/dialogInteractions.ts
Normal file
106
webui/src/components/dialogInteractions.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"area[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled]):not([type=\"hidden\"])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"details > summary:first-of-type",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"[contenteditable]:not([contenteditable=\"false\"])",
|
||||
"[tabindex]:not([tabindex=\"-1\"])"
|
||||
].join(",");
|
||||
|
||||
export type DialogKeyboardEvent = Pick<KeyboardEvent, "key" | "shiftKey" | "preventDefault">;
|
||||
|
||||
function elementIsAvailable(element: HTMLElement): boolean {
|
||||
if (element.tabIndex < 0 || element.hasAttribute("disabled")) return false;
|
||||
if (element.closest('[hidden], [inert], [aria-hidden="true"]')) return false;
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function focusWithoutScrolling(element: HTMLElement): void {
|
||||
try {
|
||||
element.focus({ preventScroll: true });
|
||||
} catch {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function dialogFocusableElements(panel: HTMLElement): HTMLElement[] {
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(elementIsAvailable);
|
||||
}
|
||||
|
||||
export function focusDialogOnOpen(panel: HTMLElement, activeElement: Element | null): HTMLElement {
|
||||
if (activeElement && panel.contains(activeElement)) return activeElement as HTMLElement;
|
||||
const focusable = dialogFocusableElements(panel);
|
||||
const requested = panel.querySelector<HTMLElement>("[autofocus]");
|
||||
const target = requested && focusable.includes(requested) ? requested : (focusable[0] ?? panel);
|
||||
focusWithoutScrolling(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function trapDialogFocus(
|
||||
panel: HTMLElement,
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
if (event.key !== "Tab") return false;
|
||||
|
||||
const focusable = dialogFocusableElements(panel);
|
||||
let target: HTMLElement | null = null;
|
||||
|
||||
if (focusable.length === 0) {
|
||||
target = panel;
|
||||
} else {
|
||||
const activeIndex = activeElement ? focusable.indexOf(activeElement as HTMLElement) : -1;
|
||||
if (activeIndex < 0) {
|
||||
target = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
|
||||
} else if (event.shiftKey && activeIndex === 0) {
|
||||
target = focusable[focusable.length - 1];
|
||||
} else if (!event.shiftKey && activeIndex === focusable.length - 1) {
|
||||
target = focusable[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) return false;
|
||||
event.preventDefault();
|
||||
focusWithoutScrolling(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleDialogKeyDown(
|
||||
panel: HTMLElement,
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null,
|
||||
canClose: boolean,
|
||||
onClose?: () => void
|
||||
): boolean {
|
||||
if (event.key === "Tab") return trapDialogFocus(panel, event, activeElement);
|
||||
if (event.key !== "Escape" || !canClose) return false;
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function restoreDialogFocus(element: HTMLElement | null): boolean {
|
||||
if (!element || element.isConnected === false) return false;
|
||||
focusWithoutScrolling(element);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldCloseDialogOnBackdrop(
|
||||
target: EventTarget | null,
|
||||
currentTarget: EventTarget | null,
|
||||
closeOnBackdrop: boolean,
|
||||
canClose: boolean
|
||||
): boolean {
|
||||
return closeOnBackdrop && canClose && target === currentTarget;
|
||||
}
|
||||
158
webui/src/components/dialogStack.ts
Normal file
158
webui/src/components/dialogStack.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
focusDialogOnOpen,
|
||||
handleDialogKeyDown,
|
||||
restoreDialogFocus,
|
||||
type DialogKeyboardEvent
|
||||
} from "./dialogInteractions";
|
||||
|
||||
export type DialogStackId = symbol;
|
||||
|
||||
export type DialogStackRegistration = {
|
||||
id: DialogStackId;
|
||||
activationOrder: number;
|
||||
panel: HTMLElement;
|
||||
restoreFocus: HTMLElement | null;
|
||||
canClose: () => boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type DialogStackEntry = DialogStackRegistration & {
|
||||
restoreTargets: HTMLElement[];
|
||||
};
|
||||
|
||||
const openDialogs: DialogStackEntry[] = [];
|
||||
let activationSequence = 0;
|
||||
let listeningForKeyDown = false;
|
||||
|
||||
function topDialog(): DialogStackEntry | null {
|
||||
return openDialogs[openDialogs.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function currentActiveElement(): Element | null {
|
||||
return typeof document === "undefined" ? null : document.activeElement;
|
||||
}
|
||||
|
||||
function appendUniqueTargets(targets: HTMLElement[], additions: Array<HTMLElement | null>): HTMLElement[] {
|
||||
const next = [...targets];
|
||||
additions.forEach((target) => {
|
||||
if (target && !next.includes(target)) next.push(target);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function exposeAsTopmost(panel: HTMLElement): void {
|
||||
panel.removeAttribute("inert");
|
||||
panel.removeAttribute("aria-hidden");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("data-dialog-stack-state", "topmost");
|
||||
}
|
||||
|
||||
function hideAsUnderlying(panel: HTMLElement): void {
|
||||
panel.setAttribute("inert", "");
|
||||
panel.setAttribute("aria-hidden", "true");
|
||||
panel.removeAttribute("aria-modal");
|
||||
panel.setAttribute("data-dialog-stack-state", "underlying");
|
||||
}
|
||||
|
||||
function syncStackAccessibility(): void {
|
||||
const topIndex = openDialogs.length - 1;
|
||||
openDialogs.forEach((entry, index) => {
|
||||
if (index === topIndex) exposeAsTopmost(entry.panel);
|
||||
else hideAsUnderlying(entry.panel);
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowKeyDown(event: KeyboardEvent): void {
|
||||
handleTopDialogKeyDown(event, currentActiveElement());
|
||||
}
|
||||
|
||||
function syncWindowKeyDownListener(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (openDialogs.length > 0 && !listeningForKeyDown) {
|
||||
window.addEventListener("keydown", handleWindowKeyDown);
|
||||
listeningForKeyDown = true;
|
||||
} else if (openDialogs.length === 0 && listeningForKeyDown) {
|
||||
window.removeEventListener("keydown", handleWindowKeyDown);
|
||||
listeningForKeyDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function focusAfterTopmostCloses(entry: DialogStackEntry): void {
|
||||
const nextTop = topDialog();
|
||||
if (nextTop) {
|
||||
const targetInsideNextDialog = entry.restoreTargets.find(
|
||||
(target) => target.isConnected !== false && nextTop.panel.contains(target)
|
||||
);
|
||||
if (targetInsideNextDialog && restoreDialogFocus(targetInsideNextDialog)) return;
|
||||
focusDialogOnOpen(nextTop.panel, currentActiveElement());
|
||||
return;
|
||||
}
|
||||
|
||||
entry.restoreTargets.some((target) => restoreDialogFocus(target));
|
||||
}
|
||||
|
||||
function unregisterDialog(id: DialogStackId): void {
|
||||
const index = openDialogs.findIndex((entry) => entry.id === id);
|
||||
if (index < 0) return;
|
||||
|
||||
const wasTopmost = index === openDialogs.length - 1;
|
||||
const [entry] = openDialogs.splice(index, 1);
|
||||
exposeAsTopmost(entry.panel);
|
||||
syncStackAccessibility();
|
||||
syncWindowKeyDownListener();
|
||||
|
||||
if (wasTopmost) focusAfterTopmostCloses(entry);
|
||||
}
|
||||
|
||||
export function nextDialogActivationOrder(): number {
|
||||
activationSequence += 1;
|
||||
return activationSequence;
|
||||
}
|
||||
|
||||
export function registerDialog(
|
||||
registration: DialogStackRegistration,
|
||||
activeElement: Element | null
|
||||
): () => void {
|
||||
const existingIndex = openDialogs.findIndex((entry) => entry.id === registration.id);
|
||||
if (existingIndex >= 0) openDialogs.splice(existingIndex, 1);
|
||||
|
||||
const entry: DialogStackEntry = {
|
||||
...registration,
|
||||
restoreTargets: appendUniqueTargets([], [registration.restoreFocus])
|
||||
};
|
||||
openDialogs.push(entry);
|
||||
openDialogs.sort((left, right) => left.activationOrder - right.activationOrder);
|
||||
|
||||
const entryIndex = openDialogs.indexOf(entry);
|
||||
const entryBelow = openDialogs[entryIndex - 1];
|
||||
if (entryBelow) entry.restoreTargets = appendUniqueTargets(entry.restoreTargets, entryBelow.restoreTargets);
|
||||
for (let index = entryIndex + 1; index < openDialogs.length; index += 1) {
|
||||
openDialogs[index].restoreTargets = appendUniqueTargets(openDialogs[index].restoreTargets, entry.restoreTargets);
|
||||
}
|
||||
|
||||
if (topDialog() === entry) focusDialogOnOpen(entry.panel, activeElement);
|
||||
syncStackAccessibility();
|
||||
syncWindowKeyDownListener();
|
||||
|
||||
return () => unregisterDialog(registration.id);
|
||||
}
|
||||
|
||||
export function handleTopDialogKeyDown(
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
const entry = topDialog();
|
||||
if (!entry) return false;
|
||||
return handleDialogKeyDown(entry.panel, event, activeElement, entry.canClose(), entry.onClose);
|
||||
}
|
||||
|
||||
export function dialogIsTopmost(id: DialogStackId): boolean {
|
||||
const entry = topDialog();
|
||||
return !entry || entry.id === id;
|
||||
}
|
||||
|
||||
export function resetDialogStackForTests(): void {
|
||||
openDialogs.splice(0).forEach((entry) => exposeAsTopmost(entry.panel));
|
||||
activationSequence = 0;
|
||||
syncWindowKeyDownListener();
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Button from "../Button";
|
||||
import { CredentialFields } from "../CredentialPanel";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import SegmentedControl from "../SegmentedControl";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
|
||||
@@ -48,7 +47,7 @@ export type MailServerFolderLookupResult = {
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
export type MailServerSettingsSection = "smtp" | "imap";
|
||||
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||
|
||||
export type MailServerSettingsPanelProps = {
|
||||
@@ -72,30 +71,11 @@ export type MailServerSettingsPanelProps = {
|
||||
imapActionDisabled?: boolean;
|
||||
smtpTestLabel?: string;
|
||||
imapTestLabel?: string;
|
||||
folderLookupLabel?: string;
|
||||
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
||||
onTestSmtp?: () => void;
|
||||
onTestImap?: () => void;
|
||||
onLookupFolders?: () => void;
|
||||
smtpTestResult?: MailServerConnectionTestResult | null;
|
||||
imapTestResult?: MailServerConnectionTestResult | null;
|
||||
folderLookupResult?: MailServerFolderLookupResult | null;
|
||||
onUseDetectedFolder?: () => void;
|
||||
useDetectedFolderDisabled?: boolean;
|
||||
mockToggle?: {
|
||||
label?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
append?: {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
disabled?: boolean;
|
||||
folderDisabled?: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onFolderChange: (folder: string) => void;
|
||||
};
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
@@ -202,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
|
||||
return values.some((value) => String(value ?? "").trim() !== "");
|
||||
}
|
||||
|
||||
export function resolveMailServerSettingsActiveSection(
|
||||
activeSection: MailServerSettingsSection,
|
||||
initialSection: MailServerSettingsSection,
|
||||
previousInitialSection: MailServerSettingsSection,
|
||||
visibleSectionIds: readonly MailServerSettingsSection[])
|
||||
: MailServerSettingsSection {
|
||||
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||
const initialSectionVisible = visibleSectionIds.includes(initialSection);
|
||||
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
|
||||
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
|
||||
return activeSection;
|
||||
}
|
||||
|
||||
export default function MailServerSettingsPanel({
|
||||
smtp,
|
||||
imap,
|
||||
@@ -223,18 +216,11 @@ export default function MailServerSettingsPanel({
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
||||
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
||||
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
|
||||
busyAction = null,
|
||||
onTestSmtp,
|
||||
onTestImap,
|
||||
onLookupFolders,
|
||||
smtpTestResult = null,
|
||||
imapTestResult = null,
|
||||
folderLookupResult = null,
|
||||
onUseDetectedFolder,
|
||||
useDetectedFolderDisabled = false,
|
||||
mockToggle,
|
||||
append,
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false,
|
||||
@@ -254,35 +240,31 @@ export default function MailServerSettingsPanel({
|
||||
const imapSecurity = stringValue(imap.security, "tls");
|
||||
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
||||
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
||||
const appendTargetHelp = append ?
|
||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
||||
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
|
||||
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
||||
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
||||
const fallbackSection = sections[0]?.id ?? "smtp";
|
||||
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
|
||||
const sectionKey = sections.map((section) => section.id).join("|");
|
||||
const visibleSectionIds = sections.map((section) => section.id);
|
||||
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
|
||||
const sectionKey = visibleSectionIds.join("|");
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
||||
const previousInitialSectionRef = useRef(initialSection);
|
||||
const showSectionSwitcher = sections.length > 1;
|
||||
const showServerFields = mode !== "credentials";
|
||||
const showCredentialFields = mode !== "server";
|
||||
|
||||
useEffect(() => {
|
||||
if (!sections.some((section) => section.id === activeSection)) {
|
||||
setActiveSection(resolvedInitialSection);
|
||||
return;
|
||||
}
|
||||
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
|
||||
setActiveSection(initialSection);
|
||||
}
|
||||
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
|
||||
const nextSection = resolveMailServerSettingsActiveSection(
|
||||
activeSection,
|
||||
initialSection,
|
||||
previousInitialSectionRef.current,
|
||||
visibleSectionIds
|
||||
);
|
||||
previousInitialSectionRef.current = initialSection;
|
||||
if (nextSection !== activeSection) setActiveSection(nextSection);
|
||||
}, [activeSection, initialSection, sectionKey]);
|
||||
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
@@ -324,10 +306,10 @@ export default function MailServerSettingsPanel({
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
@@ -336,9 +318,7 @@ export default function MailServerSettingsPanel({
|
||||
onChange={patchSmtpCredentials}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
savedPassword={smtpPasswordSaved}
|
||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
|
||||
}
|
||||
</div>
|
||||
{onTestSmtp &&
|
||||
@@ -355,10 +335,10 @@ export default function MailServerSettingsPanel({
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
@@ -367,9 +347,7 @@ export default function MailServerSettingsPanel({
|
||||
onChange={patchImapCredentials}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
savedPassword={imapPasswordSaved}
|
||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||
}
|
||||
</div>
|
||||
{onTestImap &&
|
||||
@@ -381,41 +359,6 @@ export default function MailServerSettingsPanel({
|
||||
</section>
|
||||
}
|
||||
|
||||
{activeSection === "advanced" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
||||
{mockToggle &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange} />
|
||||
|
||||
</div>
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
</div>
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
||||
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
|
||||
</div>
|
||||
</FormField>
|
||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -437,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
|
||||
export function MailServerFolderLookupResultView({
|
||||
result,
|
||||
disabled = false,
|
||||
onUseDetected
|
||||
|
||||
|
||||
|
||||
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
|
||||
onUseDetected,
|
||||
compact = true,
|
||||
floatingFailures = false
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
||||
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
|
||||
}
|
||||
|
||||
const folders = result.folders ?? [];
|
||||
return (
|
||||
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||
<p>{result.message}</p>
|
||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
||||
|
||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import { isValidEmailAddress } from "../../utils/emailAddresses";
|
||||
import Button from "../Button";
|
||||
import FieldLabel from "../help/FieldLabel";
|
||||
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
|
||||
import TableActionGroup from "../table/TableActionGroup";
|
||||
import {
|
||||
dedupePeoplePickerItems,
|
||||
externalPeoplePickerItem,
|
||||
peoplePickerDedupeKey,
|
||||
selectionFromPeoplePickerCandidate,
|
||||
type PeoplePickerItem,
|
||||
type PeoplePickerSearch,
|
||||
type PeoplePickerSearchCandidate,
|
||||
type PeoplePickerSearchGroup
|
||||
} from "./peoplePickerTypes";
|
||||
|
||||
export type PeoplePickerProps = {
|
||||
id?: string;
|
||||
label?: ReactNode;
|
||||
help?: ReactNode;
|
||||
selectedLabel?: ReactNode;
|
||||
value: readonly PeoplePickerItem[];
|
||||
onChange: (value: PeoplePickerItem[]) => void;
|
||||
search: PeoplePickerSearch;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
allowManualExternal?: boolean;
|
||||
manualEmailRequired?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type IndexedCandidate = {
|
||||
candidate: PeoplePickerSearchCandidate;
|
||||
group: PeoplePickerSearchGroup;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
people: "i18n:govoplan-core.people.b37554f6",
|
||||
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
|
||||
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
|
||||
searching: "i18n:govoplan-core.searching.1a6a5ba8",
|
||||
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
|
||||
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
|
||||
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
|
||||
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
|
||||
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
|
||||
source: "i18n:govoplan-core.source.6da13add",
|
||||
addPerson: "i18n:govoplan-core.add_person.3b10561b",
|
||||
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
|
||||
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
|
||||
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
|
||||
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
|
||||
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
|
||||
} as const;
|
||||
|
||||
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||
return typeof node === "string" ? translateText(node) : node;
|
||||
}
|
||||
|
||||
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
|
||||
const flattened: IndexedCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const candidate of group.candidates) {
|
||||
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
|
||||
seen.add(candidate.selection_key);
|
||||
flattened.push({ candidate, group, index: flattened.length });
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export default function PeoplePicker({
|
||||
id,
|
||||
label = I18N.people,
|
||||
help,
|
||||
selectedLabel = I18N.selectedPeople,
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
disabled = false,
|
||||
required = false,
|
||||
allowManualExternal = true,
|
||||
manualEmailRequired = true,
|
||||
minQueryLength = 2,
|
||||
searchLimit = 25,
|
||||
debounceMs = 250,
|
||||
className = ""
|
||||
}: PeoplePickerProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const pickerId = id || `people-picker-${generatedId}`;
|
||||
const labelId = `${pickerId}-label`;
|
||||
const inputId = `${pickerId}-search`;
|
||||
const listboxId = `${pickerId}-results`;
|
||||
const statusId = `${pickerId}-status`;
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const manualNameRef = useRef<HTMLInputElement | null>(null);
|
||||
const manualEmailRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
|
||||
[selectedItems]
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualName, setManualName] = useState("");
|
||||
const [manualEmail, setManualEmail] = useState("");
|
||||
const [manualError, setManualError] = useState("");
|
||||
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
|
||||
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
|
||||
const flattened = useMemo(() => flattenGroups(groups), [groups]);
|
||||
|
||||
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
|
||||
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = query.trim();
|
||||
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
|
||||
setGroups([]);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
setSearchError("");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setSearchError("");
|
||||
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
|
||||
.then((nextGroups) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setGroups(nextGroups);
|
||||
setHasSearched(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
setGroups([]);
|
||||
setHasSearched(true);
|
||||
setSearchError(I18N.searchFailed);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, Math.max(0, Math.floor(debounceMs)));
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
|
||||
setActiveIndex(firstAvailable?.index ?? -1);
|
||||
}, [flattened, selectedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualOpen) manualNameRef.current?.focus();
|
||||
}, [manualOpen]);
|
||||
|
||||
function focusSearch() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function addCandidate(candidate: PeoplePickerSearchCandidate) {
|
||||
if (disabled || candidateUnavailable(candidate)) return;
|
||||
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
|
||||
setQuery("");
|
||||
setGroups([]);
|
||||
setHasSearched(false);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
|
||||
function removeItem(item: PeoplePickerItem) {
|
||||
if (disabled) return;
|
||||
const removedKey = peoplePickerDedupeKey(item);
|
||||
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
|
||||
if (available.length === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
const currentPosition = available.findIndex((item) => item.index === activeIndex);
|
||||
const nextPosition = currentPosition < 0
|
||||
? (delta > 0 ? 0 : available.length - 1)
|
||||
: (currentPosition + delta + available.length) % available.length;
|
||||
setActiveIndex(available[nextPosition].index);
|
||||
}
|
||||
|
||||
function onSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && activeIndex >= 0 && open) {
|
||||
event.preventDefault();
|
||||
const active = flattened.find((item) => item.index === activeIndex);
|
||||
if (active) addCandidate(active.candidate);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPerson(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = manualName.trim();
|
||||
const email = manualEmail.trim().toLowerCase();
|
||||
if (!name) {
|
||||
setManualError(I18N.enterName);
|
||||
manualNameRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
|
||||
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||
manualEmailRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const item = externalPeoplePickerItem(name, email || null);
|
||||
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
|
||||
setManualError(I18N.alreadySelected);
|
||||
return;
|
||||
}
|
||||
onChange(dedupePeoplePickerItems([...selectedItems, item]));
|
||||
setManualName("");
|
||||
setManualEmail("");
|
||||
setManualError("");
|
||||
setManualOpen(false);
|
||||
}
|
||||
|
||||
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const statusMessage = loading
|
||||
? I18N.searching
|
||||
: searchError
|
||||
? searchError
|
||||
: query.trim() && query.trim().length < normalizedMinQueryLength
|
||||
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
|
||||
value0: normalizedMinQueryLength
|
||||
})
|
||||
: hasSearched && flattened.length === 0
|
||||
? I18N.noMatches
|
||||
: "";
|
||||
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
|
||||
const columns: DataGridColumn<PeoplePickerItem>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "i18n:govoplan-core.name.709a2322",
|
||||
minWidth: 180,
|
||||
resizable: true,
|
||||
render: (item) => (
|
||||
<div className="people-picker-selected-person">
|
||||
<strong>{item.display_name}</strong>
|
||||
{item.description && <small>{item.description}</small>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
header: "i18n:govoplan-core.email_address.c94d3175",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
value: (item) => item.email || "—"
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: I18N.source,
|
||||
minWidth: 130,
|
||||
resizable: true,
|
||||
value: (item) => translateText(item.source_label || kindLabel(item.kind))
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||
width: 104,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (item) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={2}
|
||||
actions={[
|
||||
{
|
||||
id: "add",
|
||||
label: I18N.addPerson,
|
||||
icon: <Plus size={16} aria-hidden="true" />,
|
||||
disabled,
|
||||
onClick: focusSearch
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: I18N.removePerson,
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled,
|
||||
onClick: () => removeItem(item)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
|
||||
<div className="people-picker-search-field">
|
||||
<label htmlFor={inputId} id={labelId}>
|
||||
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
|
||||
</label>
|
||||
<div className="people-picker-search-control">
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
id={inputId}
|
||||
type="search"
|
||||
role="combobox"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
required={required && selectedItems.length === 0}
|
||||
placeholder={translateText(I18N.searchPlaceholder)}
|
||||
aria-label={translateText(I18N.searchPeople)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={resultsVisible}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
|
||||
aria-describedby={statusId}
|
||||
aria-busy={loading}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
setSearchError("");
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resultsVisible && (
|
||||
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
|
||||
{groups.map((group) => {
|
||||
const candidates = flattened.filter((item) => item.group.key === group.key);
|
||||
if (candidates.length === 0) return null;
|
||||
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
return (
|
||||
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
|
||||
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
|
||||
{candidates.map(({ candidate, index }) => {
|
||||
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
|
||||
const unavailable = candidateUnavailable(candidate);
|
||||
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
|
||||
return (
|
||||
<button
|
||||
id={`${pickerId}-option-${index}`}
|
||||
key={candidate.selection_key}
|
||||
type="button"
|
||||
role="option"
|
||||
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
|
||||
aria-selected={selected}
|
||||
aria-disabled={unavailable}
|
||||
disabled={unavailable}
|
||||
title={disabledReason ? translateText(disabledReason) : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => !unavailable && setActiveIndex(index)}
|
||||
onFocus={() => !unavailable && setActiveIndex(index)}
|
||||
onClick={() => addCandidate(candidate)}
|
||||
>
|
||||
<span className="people-picker-result-main">
|
||||
<strong>{candidate.display_name}</strong>
|
||||
{candidate.email && <small>{candidate.email}</small>}
|
||||
</span>
|
||||
<span className="people-picker-result-context">
|
||||
{candidate.description && <small>{candidate.description}</small>}
|
||||
<small>{translateText(candidate.source_label || group.label)}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
|
||||
{statusMessage && translateText(statusMessage)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allowManualExternal && (
|
||||
<div className="people-picker-manual">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
aria-expanded={manualOpen}
|
||||
onClick={() => {
|
||||
setManualOpen((current) => !current);
|
||||
setManualError("");
|
||||
}}
|
||||
>
|
||||
<UserPlus size={16} aria-hidden="true" />
|
||||
{translateText(I18N.addExternal)}
|
||||
</Button>
|
||||
{manualOpen && (
|
||||
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
|
||||
<label>
|
||||
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
|
||||
<input
|
||||
ref={manualNameRef}
|
||||
value={manualName}
|
||||
disabled={disabled}
|
||||
required
|
||||
autoComplete="name"
|
||||
onChange={(event) => {
|
||||
setManualName(event.target.value);
|
||||
setManualError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
|
||||
<input
|
||||
ref={manualEmailRef}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
value={manualEmail}
|
||||
disabled={disabled}
|
||||
required={manualEmailRequired}
|
||||
autoComplete="email"
|
||||
onChange={(event) => {
|
||||
setManualEmail(event.target.value);
|
||||
setManualError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="people-picker-manual-actions">
|
||||
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
|
||||
{translateText("i18n:govoplan-core.cancel.77dfd213")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={disabled}>
|
||||
{translateText(I18N.addPerson)}
|
||||
</Button>
|
||||
</div>
|
||||
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
|
||||
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
|
||||
<DataGrid
|
||||
id={`${pickerId}-selection`}
|
||||
rows={selectedItems}
|
||||
columns={columns}
|
||||
getRowKey={peoplePickerDedupeKey}
|
||||
emptyText={I18N.noSelection}
|
||||
emptyActionColumnId="actions"
|
||||
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
|
||||
initialFit="container"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
|
||||
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
|
||||
if (kind === "external") return I18N.externalPerson;
|
||||
return kind;
|
||||
}
|
||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
|
||||
|
||||
export type PeoplePickerItem = {
|
||||
selection_key: string;
|
||||
kind: PeoplePickerItemKind;
|
||||
reference_id?: string | null;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
source_module?: string | null;
|
||||
source_label?: string | null;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
description?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
|
||||
disabled?: boolean;
|
||||
disabled_reason?: string | null;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
candidates: readonly PeoplePickerSearchCandidate[];
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchOptions = {
|
||||
limit: number;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearch = (
|
||||
query: string,
|
||||
options: PeoplePickerSearchOptions
|
||||
) => Promise<readonly PeoplePickerSearchGroup[]>;
|
||||
|
||||
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
|
||||
const email = item.email?.trim().toLowerCase();
|
||||
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
|
||||
const seen = new Set<string>();
|
||||
const result: PeoplePickerItem[] = [];
|
||||
for (const item of items) {
|
||||
const key = peoplePickerDedupeKey(item);
|
||||
if (!item.selection_key.trim() || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
|
||||
const normalizedName = displayName.trim();
|
||||
const normalizedEmail = email?.trim().toLowerCase() || null;
|
||||
const identityPart = normalizedEmail || normalizedName.toLowerCase();
|
||||
return {
|
||||
selection_key: `external:${identityPart}`,
|
||||
kind: "external",
|
||||
reference_id: null,
|
||||
display_name: normalizedName,
|
||||
email: normalizedEmail,
|
||||
source_module: null,
|
||||
source_label: "i18n:govoplan-core.external_person.35115d8e",
|
||||
source_ref: null,
|
||||
source_revision: null,
|
||||
provenance: {},
|
||||
metadata: { manual: true }
|
||||
};
|
||||
}
|
||||
|
||||
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
|
||||
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
|
||||
return selection;
|
||||
}
|
||||
101
webui/src/components/resolveDroppedFiles.ts
Normal file
101
webui/src/components/resolveDroppedFiles.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
|
||||
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
|
||||
type FileSystemDirectoryHandleLike = {
|
||||
kind: "directory";
|
||||
values?: () => AsyncIterable<FileSystemHandleLike>;
|
||||
};
|
||||
type WebKitFileEntryLike = {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
file: (
|
||||
success: (file: File) => void,
|
||||
error?: (error: DOMException) => void
|
||||
) => void;
|
||||
};
|
||||
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
|
||||
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
|
||||
type DropItemWithFileHandles = DataTransferItem & {
|
||||
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
|
||||
webkitGetAsEntry?: () => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a browser file drop exactly once, preferring the standard file list
|
||||
* before progressively trying item and directory-drag compatibility APIs.
|
||||
*/
|
||||
export async function resolveDroppedFiles(
|
||||
dataTransfer: Pick<DataTransfer, "files" | "items">
|
||||
): Promise<File[]> {
|
||||
const directFiles = Array.from(dataTransfer.files);
|
||||
if (directFiles.length > 0) return directFiles;
|
||||
|
||||
const items = Array.from(dataTransfer.items).filter(
|
||||
(item) => item.kind === "file"
|
||||
) as DropItemWithFileHandles[];
|
||||
const itemFiles = items
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
if (itemFiles.length > 0) return itemFiles;
|
||||
|
||||
const handleFiles = await filesFromDataTransferHandles(items);
|
||||
if (handleFiles.length > 0) return handleFiles;
|
||||
|
||||
return filesFromWebKitEntries(items);
|
||||
}
|
||||
|
||||
async function filesFromDataTransferHandles(
|
||||
items: DropItemWithFileHandles[]
|
||||
): Promise<File[]> {
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
let handle: FileSystemHandleLike | null | undefined;
|
||||
try {
|
||||
handle = await item.getAsFileSystemHandle?.();
|
||||
} catch {
|
||||
handle = null;
|
||||
}
|
||||
if (!handle) continue;
|
||||
files.push(...await filesFromFileSystemHandle(handle));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function filesFromFileSystemHandle(
|
||||
handle: FileSystemHandleLike
|
||||
): Promise<File[]> {
|
||||
if (handle.kind === "file") return [await handle.getFile()];
|
||||
const values = handle.values?.();
|
||||
if (!values) return [];
|
||||
const files: File[] = [];
|
||||
for await (const child of values) {
|
||||
files.push(...await filesFromFileSystemHandle(child));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function filesFromWebKitEntries(
|
||||
items: DropItemWithFileHandles[]
|
||||
): Promise<File[]> {
|
||||
const entries = items.flatMap((item) => {
|
||||
const entry = item.webkitGetAsEntry?.() as unknown;
|
||||
return isWebKitEntryLike(entry) ? [entry] : [];
|
||||
});
|
||||
const files = await Promise.all(entries.map(fileFromWebKitEntry));
|
||||
return files.filter((file): file is File => Boolean(file));
|
||||
}
|
||||
|
||||
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as { isFile?: unknown; isDirectory?: unknown };
|
||||
return (
|
||||
typeof entry.isFile === "boolean" &&
|
||||
typeof entry.isDirectory === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
|
||||
if (!entry.isFile) return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||
import StatusBadge from "../StatusBadge";
|
||||
import Button from "../Button";
|
||||
import TableActionGroup from "./TableActionGroup";
|
||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||
|
||||
export type DataGridSortDirection = "asc" | "desc";
|
||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
export type DataGridPagination = {
|
||||
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
|
||||
mode?: "client" | "server";
|
||||
type DataGridPaginationBase = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows?: number;
|
||||
pageSizeOptions?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type DataGridClientPagination = DataGridPaginationBase & {
|
||||
/**
|
||||
* Client mode filters and sorts before slicing. `rows` must therefore contain
|
||||
* the complete logical result set, never an already paginated backend page.
|
||||
*/
|
||||
mode?: "client";
|
||||
totalRows?: never;
|
||||
};
|
||||
|
||||
export type DataGridServerPagination = DataGridPaginationBase & {
|
||||
/**
|
||||
* Server mode treats `rows` as the current backend page. The query callback
|
||||
* must apply every emitted sort/filter to the backend before pagination.
|
||||
*/
|
||||
mode: "server";
|
||||
/** Total rows after the backend has applied the current filters. */
|
||||
totalRows: number;
|
||||
};
|
||||
|
||||
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
|
||||
|
||||
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
||||
|
||||
export type DataGridColumn<T> = {
|
||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
||||
bufferWidth?: number;
|
||||
};
|
||||
|
||||
type DataGridProps<T> = {
|
||||
type DataGridBaseProps<T> = {
|
||||
id: string;
|
||||
rows: T[];
|
||||
columns: DataGridColumn<T>[];
|
||||
@@ -98,14 +116,29 @@ type DataGridProps<T> = {
|
||||
storageKey?: string;
|
||||
initialFilters?: Record<string, string | string[]>;
|
||||
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
||||
pagination?: DataGridPagination;
|
||||
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
|
||||
onQueryChange?: (query: DataGridQueryState) => void;
|
||||
/**
|
||||
* Synchronize a query selected outside the grid, for example by a summary
|
||||
* count shortcut. Server grids should feed `onQueryChange` back into this
|
||||
* value so the header controls and backend query remain in agreement.
|
||||
*/
|
||||
query?: DataGridQueryState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Query ownership is deliberately explicit: client grids receive all rows and
|
||||
* DataGrid queries them locally; server grids receive one page and must handle
|
||||
* the complete query through `onQueryChange`.
|
||||
*/
|
||||
export type DataGridProps<T> = DataGridBaseProps<T> & (
|
||||
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
|
||||
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
|
||||
);
|
||||
|
||||
export type DataGridRowActionsProps = {
|
||||
disabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
/** Set to false only when row ordering is not part of this table's action set. */
|
||||
reorderable?: boolean;
|
||||
onAddBelow: () => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp?: () => void;
|
||||
@@ -177,6 +210,7 @@ export default function DataGrid<T>({
|
||||
storageKey,
|
||||
initialFilters = {},
|
||||
initialSort,
|
||||
query,
|
||||
pagination,
|
||||
onQueryChange
|
||||
}: DataGridProps<T>) {
|
||||
@@ -203,7 +237,15 @@ export default function DataGrid<T>({
|
||||
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
||||
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
||||
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
|
||||
const externalQueryKey = query ? JSON.stringify(query) : "";
|
||||
const normalizedExternalQuery = useMemo(
|
||||
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
|
||||
[externalQueryKey]
|
||||
);
|
||||
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
||||
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
|
||||
normalizedExternalQuery
|
||||
));
|
||||
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
||||
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
||||
@@ -211,14 +253,21 @@ export default function DataGrid<T>({
|
||||
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const serverQueryMode = pagination?.mode === "server";
|
||||
const onQueryChangeRef = useRef(onQueryChange);
|
||||
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
|
||||
const lastLayoutSignatureRef = useRef<string | null>(null);
|
||||
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||
const serverQueryMode = pagination?.mode === "server";
|
||||
|
||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||
|
||||
useEffect(() => {
|
||||
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
|
||||
if (!serverQueryMode) lastServerQueryRef.current = null;
|
||||
}, [pagination, serverQueryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
||||
}, [columns, effectiveResizeBehavior]);
|
||||
@@ -231,6 +280,10 @@ export default function DataGrid<T>({
|
||||
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
||||
}, [normalizedInitialSort]);
|
||||
|
||||
useEffect(() => {
|
||||
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
|
||||
}, [normalizedExternalQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||
@@ -458,10 +511,17 @@ export default function DataGrid<T>({
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverQueryMode) return;
|
||||
onQueryChangeRef.current?.({
|
||||
const query = {
|
||||
sort: state.sort ?? null,
|
||||
filters: { ...(state.filters ?? {}) }
|
||||
});
|
||||
};
|
||||
const previousQuery = lastServerQueryRef.current;
|
||||
lastServerQueryRef.current = query;
|
||||
const serverPagination = serverPaginationRef.current;
|
||||
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
|
||||
serverPagination.onPageChange(1);
|
||||
}
|
||||
onQueryChangeRef.current?.(query);
|
||||
}, [serverQueryMode, state.sort, state.filters]);
|
||||
|
||||
const filterTypes = useMemo(() => {
|
||||
@@ -793,13 +853,28 @@ export default function DataGrid<T>({
|
||||
|
||||
}
|
||||
|
||||
function DataGridPaginationBar({
|
||||
export type DataGridPaginationBarProps = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
pageCount?: number;
|
||||
pageSizeOptions?: number[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
export function DataGridPaginationBar({
|
||||
page,
|
||||
pageSize,
|
||||
totalRows,
|
||||
pageCount,
|
||||
pageSizeOptions,
|
||||
pageCount = Math.max(1, Math.ceil(totalRows / pageSize)),
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
disabled = false,
|
||||
className = "",
|
||||
ariaLabel = "i18n:govoplan-core.table_pagination.3665bd76",
|
||||
onPageChange,
|
||||
onPageSizeChange
|
||||
|
||||
@@ -811,13 +886,13 @@ function DataGridPaginationBar({
|
||||
|
||||
|
||||
|
||||
}: {page: number;pageSize: number;totalRows: number;pageCount: number;pageSizeOptions: number[];disabled?: boolean;onPageChange: (page: number) => void;onPageSizeChange?: (pageSize: number) => void;}) {
|
||||
}: DataGridPaginationBarProps) {
|
||||
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const last = Math.min(totalRows, page * pageSize);
|
||||
const options = [...new Set([...pageSizeOptions, pageSize])].filter((value) => value > 0).sort((a, b) => a - b);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<div className="data-grid-pagination" aria-label={translateText("i18n:govoplan-core.table_pagination.3665bd76")}>
|
||||
<div className={`data-grid-pagination ${className}`.trim()} aria-label={translateText(ariaLabel)}>
|
||||
<div className="data-grid-pagination-summary">{first}–{last} {translateText("i18n:govoplan-core.of")} {totalRows}</div>
|
||||
<label className="data-grid-page-size">
|
||||
<span>{translateText("i18n:govoplan-core.rows_per_page.af2f9c1b")}</span>
|
||||
@@ -843,6 +918,7 @@ function DataGridPaginationBar({
|
||||
export function DataGridRowActions({
|
||||
disabled = false,
|
||||
removeDisabled = false,
|
||||
reorderable = true,
|
||||
onAddBelow,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
@@ -852,81 +928,68 @@ export function DataGridRowActions({
|
||||
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
|
||||
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
|
||||
}: DataGridRowActionsProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedAddLabel = translateText(addLabel);
|
||||
const translatedRemoveLabel = translateText(removeLabel);
|
||||
const translatedMoveUpLabel = translateText(moveUpLabel);
|
||||
const translatedMoveDownLabel = translateText(moveDownLabel);
|
||||
return (
|
||||
<div className="data-grid-row-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="data-grid-row-action is-add"
|
||||
aria-label={translatedAddLabel}
|
||||
title={translatedAddLabel}
|
||||
disabled={disabled}
|
||||
onClick={onAddBelow}>
|
||||
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="data-grid-row-action is-reorder"
|
||||
aria-label={translatedMoveUpLabel}
|
||||
title={translatedMoveUpLabel}
|
||||
disabled={disabled || !onMoveUp}
|
||||
onClick={() => onMoveUp?.()}>
|
||||
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="data-grid-row-action is-reorder"
|
||||
aria-label={translatedMoveDownLabel}
|
||||
title={translatedMoveDownLabel}
|
||||
disabled={disabled || !onMoveDown}
|
||||
onClick={() => onMoveDown?.()}>
|
||||
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="data-grid-row-action is-remove"
|
||||
aria-label={translatedRemoveLabel}
|
||||
title={translatedRemoveLabel}
|
||||
disabled={disabled || removeDisabled}
|
||||
onClick={onRemove}>
|
||||
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>);
|
||||
<TableActionGroup
|
||||
className="data-grid-row-actions"
|
||||
actions={[
|
||||
{
|
||||
id: "add",
|
||||
label: addLabel,
|
||||
icon: <Plus size={16} aria-hidden="true" />,
|
||||
variant: "primary",
|
||||
disabled,
|
||||
onClick: onAddBelow
|
||||
},
|
||||
reorderable && {
|
||||
id: "move-up",
|
||||
label: moveUpLabel,
|
||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||
disabled: disabled || !onMoveUp,
|
||||
onClick: () => onMoveUp?.()
|
||||
},
|
||||
reorderable && {
|
||||
id: "move-down",
|
||||
label: moveDownLabel,
|
||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||
disabled: disabled || !onMoveDown,
|
||||
onClick: () => onMoveDown?.()
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: removeLabel,
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: disabled || removeDisabled,
|
||||
onClick: onRemove
|
||||
}
|
||||
]}
|
||||
/>);
|
||||
|
||||
}
|
||||
|
||||
export function DataGridEmptyAction({
|
||||
disabled = false,
|
||||
reorderable = true,
|
||||
onAdd,
|
||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||
|
||||
|
||||
|
||||
|
||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||
return (
|
||||
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="data-grid-row-action is-add"
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={disabled}
|
||||
onClick={onAdd}>
|
||||
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>);
|
||||
<TableActionGroup
|
||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||
minimumSlots={reorderable ? 4 : 2}
|
||||
actions={[{
|
||||
id: "add",
|
||||
label,
|
||||
icon: <Plus size={16} aria-hidden="true" />,
|
||||
variant: "primary",
|
||||
disabled,
|
||||
onClick: onAdd
|
||||
}]}
|
||||
/>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1197,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
|
||||
return { ...state, sort: initialSort };
|
||||
}
|
||||
|
||||
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
|
||||
if (!query) return state;
|
||||
const current = {
|
||||
sort: state.sort ?? null,
|
||||
filters: state.filters ?? {}
|
||||
};
|
||||
if (dataGridQueriesEqual(current, query)) return state;
|
||||
return {
|
||||
...state,
|
||||
sort: query.sort ? { ...query.sort } : null,
|
||||
filters: { ...query.filters }
|
||||
};
|
||||
}
|
||||
|
||||
function formatListFilter(values: string[]): string {
|
||||
return `list:${JSON.stringify([...new Set(values)])}`;
|
||||
}
|
||||
@@ -1663,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
|
||||
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||
for (const key of keys) {
|
||||
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function stringifyCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
|
||||
100
webui/src/components/table/TableActionGroup.tsx
Normal file
100
webui/src/components/table/TableActionGroup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
export type TableActionDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* @deprecated Use `disabled` for a row-level unavailable state and omit the
|
||||
* action from the array only when it is not part of this table's action set.
|
||||
* A false value is retained as a disabled, visible action for compatibility.
|
||||
*/
|
||||
applicable?: boolean;
|
||||
disabled?: boolean;
|
||||
disabledReason?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
export type TableActionGroupProps = {
|
||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Reserve trailing action slots so controls keep the same column position. */
|
||||
minimumSlots?: number;
|
||||
};
|
||||
|
||||
function isDefinedAction(
|
||||
action: TableActionDefinition | false | null | undefined
|
||||
): action is TableActionDefinition {
|
||||
return Boolean(action);
|
||||
}
|
||||
|
||||
export function runTableAction(
|
||||
event: Pick<MouseEvent<HTMLButtonElement>, "stopPropagation">,
|
||||
onClick: () => void
|
||||
) {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard row-level action surface for tables and DataGrids.
|
||||
*
|
||||
* Callers define the action set for the whole table. An action that cannot be
|
||||
* used for one row stays visible and disabled; an action that is structurally
|
||||
* irrelevant to the table is omitted from the array. Labels remain available
|
||||
* to assistive technology and as native tooltips while the visible controls
|
||||
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
|
||||
* rows, keeping their first action aligned with ordinary rows.
|
||||
*/
|
||||
export default function TableActionGroup({
|
||||
actions,
|
||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||
className = "",
|
||||
minimumSlots = 0
|
||||
}: TableActionGroupProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const definedActions = actions.filter(isDefinedAction);
|
||||
if (definedActions.length === 0) return null;
|
||||
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
|
||||
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`table-action-group${className ? ` ${className}` : ""}`}
|
||||
role="group"
|
||||
aria-label={translateText(label)}
|
||||
>
|
||||
{definedActions.map((action) => {
|
||||
const translatedLabel = translateText(action.label);
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="button"
|
||||
variant={action.variant ?? "secondary"}
|
||||
className="table-action-button"
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={action.disabled || action.applicable === false}
|
||||
disabledReason={action.disabledReason}
|
||||
onClick={(event) => runTableAction(event, action.onClick)}
|
||||
>
|
||||
<span className="table-action-icon" aria-hidden="true">
|
||||
{action.icon}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||
<span
|
||||
key={`reserved-action-slot-${index}`}
|
||||
className="table-action-placeholder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||
"mail_servers:read",
|
||||
"mail_servers:write",
|
||||
"mail_servers:manage_credentials",
|
||||
"mail:profile:write_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:policies:read",
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||
|
||||
}
|
||||
|
||||
30
webui/src/hooks/useOutsideDismiss.ts
Normal file
30
webui/src/hooks/useOutsideDismiss.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function useOutsideDismiss<TElement extends HTMLElement = HTMLDivElement>(
|
||||
active: boolean,
|
||||
onDismiss: () => void,
|
||||
) {
|
||||
const rootRef = useRef<TElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!rootRef.current || rootRef.current.contains(event.target as Node)) return;
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onDismiss();
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [active, onDismiss]);
|
||||
|
||||
return rootRef;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user