8 Commits
24 changed files with 844 additions and 53 deletions
+2
View File
@@ -138,11 +138,13 @@ dist
# Local WebUI test/build scratch directories # Local WebUI test/build scratch directories
.component-test-build/ .component-test-build/
.file-drop-test-build/
.module-test-build/ .module-test-build/
.policy-test-build/ .policy-test-build/
.template-preview-test-build/ .template-preview-test-build/
.import-test-build/ .import-test-build/
webui/.component-test-build/ webui/.component-test-build/
webui/.file-drop-test-build/
webui/.module-test-build/ webui/.module-test-build/
webui/.policy-test-build/ webui/.policy-test-build/
webui/.template-preview-test-build/ webui/.template-preview-test-build/
+1 -1
View File
@@ -201,7 +201,7 @@ class AuthGroupsResponse(BaseModel):
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
expires_at: datetime expires_at: datetime
user: UserInfo user: UserInfo
# Backwards-compatible alias for the active tenant. # Backwards-compatible alias for the active tenant.
+2 -2
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
"""Core auth dependency facade. """Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package. 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. capability registry.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
+12 -13
View File
@@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from collections.abc import Mapping from typing import Literal
from typing import Any, Literal
from govoplan_core.security.permissions import scopes_grant from govoplan_core.security.permissions import scopes_grant
from govoplan_core.security.redaction import contains_plain_secret from govoplan_core.security.redaction import contains_plain_secret
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
storage: str storage: str
ui_managed: bool ui_managed: bool
risk: ConfigurationRisk risk: ConfigurationRisk
secret_handling: SecretHandling = "none" secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
required_scopes: tuple[str, ...] = () required_scopes: tuple[str, ...] = ()
dry_run_required: bool = False dry_run_required: bool = False
validation_required: bool = True validation_required: bool = True
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
maintenance_required: bool = False maintenance_required: bool = False
maintenance_satisfied: bool = False maintenance_satisfied: bool = False
rollback_history_required: bool = False rollback_history_required: bool = False
secret_handling: SecretHandling = "none" secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
audit_event: str | None = None audit_event: str | None = None
policy_explanation: str | None = None policy_explanation: str | None = None
blockers: tuple[str, ...] = () blockers: tuple[str, ...] = ()
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings", storage="module_settings",
ui_managed=True, ui_managed=True,
risk="high", risk="high",
secret_handling="reference_only", secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("mail_servers:manage_credentials",), required_scopes=("mail_servers:manage_credentials",),
validation_required=True, validation_required=True,
audit_event="mail_server_profile.credential_updated", audit_event="mail_server_profile.credential_updated",
@@ -256,7 +255,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings", storage="module_settings",
ui_managed=True, ui_managed=True,
risk="high", risk="high",
secret_handling="reference_only", secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("files:file:admin",), required_scopes=("files:file:admin",),
dry_run_required=True, dry_run_required=True,
policy_explanation_required=True, policy_explanation_required=True,
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="destructive", risk="destructive",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True, maintenance_required=True,
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.", notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
), ),
@@ -285,7 +284,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="destructive", risk="destructive",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True, maintenance_required=True,
two_person_approval_required=True, two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.", notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
@@ -298,7 +297,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="high", 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.", notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
), ),
ConfigurationFieldSafety( ConfigurationFieldSafety(
@@ -309,7 +308,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="high", risk="high",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Configuration package trust roots are deployment-managed.", notes="Configuration package trust roots are deployment-managed.",
), ),
) )
@@ -414,9 +413,9 @@ def _configuration_change_safety_state(
approval_satisfied = not approval_required or approval_count >= 2 approval_satisfied = not approval_required or approval_count >= 2
if approval_required and not approval_satisfied: if approval_required and not approval_satisfied:
blockers.append("two_person_approval_required") 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") 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") blockers.append("env_only_secret")
if field.rollback_history_required: if field.rollback_history_required:
warnings.append("rollback_history_required") warnings.append("rollback_history_required")
@@ -466,7 +465,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
parts.append("requires two-person approval") parts.append("requires two-person approval")
if field.maintenance_required: if field.maintenance_required:
parts.append("requires maintenance mode") 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") parts.append(f"uses {field.secret_handling} secret handling")
return "; ".join(parts) + "." return "; ".join(parts) + "."
+41 -7
View File
@@ -15,7 +15,8 @@ import re
import shlex import shlex
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import stat
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
import sys import sys
import tomllib import tomllib
from typing import Any, Literal from typing import Any, Literal
@@ -579,7 +580,7 @@ def _prepare_module_install_run(
) -> _ModuleInstallRunState: ) -> _ModuleInstallRunState:
run_id = _run_id() run_id = _run_id()
run_dir = effective_runtime_dir / "runs" / 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( commands = structured_install_commands(
plan, plan,
webui_root=webui_root, webui_root=webui_root,
@@ -2445,8 +2446,8 @@ def _migration_provider_modules(
target_metadata: Mapping[str, Mapping[str, object]], target_metadata: Mapping[str, Mapping[str, object]],
) -> dict[str, tuple[tuple[str, str], ...]]: ) -> dict[str, tuple[tuple[str, str], ...]]:
providers: dict[str, list[tuple[str, str]]] = defaultdict(list) providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module_id, metadata in target_metadata.items(): for module_id, module_metadata in target_metadata.items():
for provided in metadata.get("provides_interfaces", ()): for provided in module_metadata.get("provides_interfaces", ()):
if not isinstance(provided, Mapping): if not isinstance(provided, Mapping):
continue continue
name = provided.get("name") if isinstance(provided.get("name"), str) else None 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: def _write_installer_secret(path: Path, value: str | None) -> str | None:
if not value: if not value:
return None 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: try:
path.chmod(0o600) descriptor = os.open(path, flags, 0o600)
except OSError as exc: 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 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: def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
secret_name = raw.get("database_url_secret") secret_name = raw.get("database_url_secret")
if isinstance(secret_name, str) and secret_name: 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) run_id = _result_run_id(request)
if run_id: if run_id:
return ". Run: ".join((sentence, 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: def installer_notification_priority(status: str) -> int:
-1
View File
@@ -6,7 +6,6 @@ from datetime import UTC, datetime
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
+1 -1
View File
@@ -65,7 +65,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None, api_key_secret: str | None = None,
tenant_slug: str = "default", tenant_slug: str = "default",
user_email: str = "admin@example.local", user_email: str = "admin@example.local",
user_password: str = "dev-admin", user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
) -> BootstrapResult: ) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none() tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None: if tenant is None:
+6 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import json import json
import logging import logging
@@ -215,7 +215,10 @@ def _registered_module_registry(
server_config = get_server_config() server_config = get_server_config()
active_database_url = database_url or settings.database_url active_database_url = database_url or settings.database_url
if active_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) 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) 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) candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -634,7 +637,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int: def _row_count(connection, table_name: str) -> int:
quoted = _quoted_table_name(connection, table_name) 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()) return int(connection.execute(statement).scalar_one())
+13 -2
View File
@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
from pathlib import Path from pathlib import Path
from typing import Any 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.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.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest 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: def _env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"} 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"Config: {state.config_path or DEFAULT_CONFIG}")
print(f"Runtime root: {state.runtime_root}") print(f"Runtime root: {state.runtime_root}")
if state.database_url: 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"): if state.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS") pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url: 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: 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" 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})") print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
+1 -1
View File
@@ -41,7 +41,7 @@ def fetch_http(
method: str = "GET", method: str = "GET",
headers: Mapping[str, str] | None = None, headers: Mapping[str, str] | None = None,
) -> HttpFetchResponse: ) -> HttpFetchResponse:
request = urllib.request.Request( request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
validate_http_url(url, label=label), validate_http_url(url, label=label),
headers=dict(headers or {}), headers=dict(headers or {}),
method=method, method=method,
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any
def sensitive_key_tokens(key: object) -> set[str]: def sensitive_key_tokens(key: object) -> set[str]:
value = str(key).strip() value = str(key).strip()
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
normalized_key = str(key).strip().casefold().replace("-", "_") normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}: if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue 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 return True
if isinstance(item, Mapping) and contains_plain_secret(item): if isinstance(item, Mapping) and contains_plain_secret(item):
return True return True
+1 -1
View File
@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
pass pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@runtime_checkable @runtime_checkable
+45 -8
View File
@@ -3743,7 +3743,8 @@ class ApiSmokeTests(unittest.TestCase):
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None: 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"]
campaign_json = { campaign_json = {
"version": "1.0", "version": "1.0",
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"}, "campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
@@ -3751,10 +3752,25 @@ class ApiSmokeTests(unittest.TestCase):
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True}, "recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Warning test", "text": "Body"}, "template": {"subject": "Warning test", "text": "Body"},
"attachments": {"base_path": ".", "base_paths": [], "global": [{ "attachments": {
"id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf", "base_path": "review-files",
"required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"} "base_paths": [{
}], "zip": {"enabled": False, "archives": []}}, "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"}]}]}, "entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"}, "validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True}, "delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
@@ -3779,7 +3795,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"]) self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None: 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"]
campaign_json = { campaign_json = {
"version": "1.0", "version": "1.0",
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"}, "campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
@@ -3788,10 +3805,30 @@ class ApiSmokeTests(unittest.TestCase):
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True}, "recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Hello", "text": "Active recipient only"}, "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": [ "entries": {"inline": [
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]}, {"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"}, "validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
"delivery": {"imap_append_sent": {"enabled": False}}, "delivery": {"imap_append_sent": {"enabled": False}},
+14
View File
@@ -7,8 +7,22 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from govoplan_core.devserver import redacted_database_url
class DevserverSmokeTests(unittest.TestCase): 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: def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[1]
src_root = repo_root / "src" src_root = repo_root / "src"
+45 -1
View File
@@ -8,6 +8,7 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import stat
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -2039,6 +2040,25 @@ finally:
self.assertEqual(["check", "verify"], [record["task_id"] for record in records]) self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
self.assertEqual(["warning", "warning"], [record["status"] 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: def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
available = { available = {
"files": ModuleManifest( "files": ModuleManifest(
@@ -2562,9 +2582,33 @@ finally:
self.assertEqual("external", database_backup["metadata"]["snapshot"]) self.assertEqual("external", database_backup["metadata"]["snapshot"])
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"]) 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("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")) 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: def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
+1
View File
@@ -23,6 +23,7 @@
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"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: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: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: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: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:module-permutations": "node scripts/test-module-permutations.mjs", "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"
@@ -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.");
+3 -1
View File
@@ -12,6 +12,7 @@ const packageByModule = {
files: "@govoplan/files-webui", files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui", idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui", mail: "@govoplan/mail-webui",
notifications: "@govoplan/notifications-webui",
organizations: "@govoplan/organizations-webui", organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui", ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui", policy: "@govoplan/policy-webui",
@@ -29,6 +30,7 @@ const cases = [
{ name: "calendar-only", modules: ["calendar"] }, { name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] }, { name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] }, { name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] }, { name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-only", modules: ["campaigns"] },
@@ -37,7 +39,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { 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", "calendar", "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; const npmExec = process.env.npm_execpath;
+56 -8
View File
@@ -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 { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
import {
dialogIsTopmost,
nextDialogActivationOrder,
registerDialog,
type DialogStackId
} from "./dialogStack";
export type DialogProps = { export type DialogProps = {
open: boolean; open: boolean;
@@ -46,6 +53,20 @@ export default function Dialog({
}: DialogProps) { }: DialogProps) {
const titleId = useId(); const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled; 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 { translateText } = usePlatformLanguage();
const renderedTitle = translateReactNode(title, translateText); const renderedTitle = translateReactNode(title, translateText);
const renderedChildren = translateReactNode(children, translateText); const renderedChildren = translateReactNode(children, translateText);
@@ -53,15 +74,36 @@ export default function Dialog({
const translatedCloseLabel = translateText(closeLabel); const translatedCloseLabel = translateText(closeLabel);
useEffect(() => { useEffect(() => {
if (!open || !canClose) return undefined; if (open || typeof document === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent) => { const rememberFocusedElement = () => {
if (event.key === "Escape") onClose?.(); const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (panelRef.current?.contains(activeElement)) return;
restoreFocusRef.current = activeElement;
}; };
window.addEventListener("keydown", handleKeyDown); rememberFocusedElement();
return () => window.removeEventListener("keydown", handleKeyDown); document.addEventListener("focusin", rememberFocusedElement);
}, [canClose, onClose, open]); 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; if (!open) return null;
@@ -70,13 +112,19 @@ export default function Dialog({
className={joinClasses("dialog-backdrop", backdropClassName)} className={joinClasses("dialog-backdrop", backdropClassName)}
role="presentation" role="presentation"
onMouseDown={(event) => { onMouseDown={(event) => {
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.(); if (
dialogIsTopmost(stackIdRef.current)
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
) onClose?.();
}} }}
> >
<section <section
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", className)} className={joinClasses("dialog-panel", className)}
role={role} role={role}
aria-modal="true" aria-modal="true"
data-dialog-stack-state="topmost"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={ariaDescribedBy} aria-describedby={ariaDescribedBy}
> >
+106
View 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
View 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();
}
+306
View File
@@ -0,0 +1,306 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
import { renderToStaticMarkup } from "react-dom/server";
import Dialog from "../src/components/Dialog";
import {
focusDialogOnOpen,
handleDialogKeyDown,
restoreDialogFocus,
shouldCloseDialogOnBackdrop,
trapDialogFocus
} from "../src/components/dialogInteractions";
import {
handleTopDialogKeyDown,
nextDialogActivationOrder,
registerDialog,
resetDialogStackForTests
} from "../src/components/dialogStack";
type FocusableFixture = HTMLElement & { focusCount: number; attributes: Map<string, string> };
function focusableFixture({ connected = true, hidden = false }: { connected?: boolean; hidden?: boolean } = {}): FocusableFixture {
const attributes = new Map<string, string>();
if (hidden) attributes.set("hidden", "");
const fixture = {
attributes,
focusCount: 0,
tabIndex: 0,
isConnected: connected,
getAttribute: (name: string) => attributes.get(name) ?? null,
hasAttribute: (name: string) => attributes.has(name),
setAttribute: (name: string, value: string) => attributes.set(name, value),
removeAttribute: (name: string) => attributes.delete(name),
closest: () => attributes.has("hidden") || attributes.has("inert") || attributes.get("aria-hidden") === "true" ? fixture : null,
focus() {
fixture.focusCount += 1;
}
};
return fixture as unknown as FocusableFixture;
}
function panelFixture(elements: FocusableFixture[], autofocus?: FocusableFixture) {
const panel = focusableFixture();
panel.tabIndex = -1;
Object.assign(panel, {
contains: (element: Element | null) => element === panel || elements.includes(element as FocusableFixture),
querySelectorAll: () => elements,
querySelector: (selector: string) => selector === "[autofocus]" ? (autofocus ?? null) : null
});
return panel;
}
function keyboardEvent(key: string, shiftKey = false) {
const fixture = {
key,
shiftKey,
prevented: 0,
preventDefault() {
fixture.prevented += 1;
}
};
return fixture;
}
const markup = renderToStaticMarkup(
<Dialog open title="Dialog title" onClose={() => undefined}>
<button type="button">First action</button>
</Dialog>
);
assert(markup.includes('role="dialog"'), "dialog semantics are preserved");
assert(markup.includes('aria-modal="true"'), "modal semantics are preserved");
assert(markup.includes('data-dialog-stack-state="topmost"'), "dialog exposes its initial stack state");
assert(markup.includes('tabindex="-1"'), "dialog panel can receive fallback focus");
const first = focusableFixture();
const requested = focusableFixture();
const entryPanel = panelFixture([first, requested], requested);
assertEqual(focusDialogOnOpen(entryPanel, null), requested, "autofocus target receives initial focus");
assertEqual(requested.focusCount, 1, "autofocus target is focused once");
const defaultEntryPanel = panelFixture([first]);
assertEqual(focusDialogOnOpen(defaultEntryPanel, null), first, "first available control receives initial focus");
assertEqual(first.focusCount, 1, "first available control is focused once");
const retained = focusableFixture();
const retainedPanel = panelFixture([retained]);
assertEqual(focusDialogOnOpen(retainedPanel, retained), retained, "existing focus inside the dialog is retained");
assertEqual(retained.focusCount, 0, "retained focus is not moved redundantly");
const emptyPanel = panelFixture([]);
assertEqual(focusDialogOnOpen(emptyPanel, null), emptyPanel, "panel receives focus when no control is available");
assertEqual(emptyPanel.focusCount, 1, "fallback panel is focused");
const boundaryFirst = focusableFixture();
const boundaryMiddle = focusableFixture();
const boundaryLast = focusableFixture();
const boundaryPanel = panelFixture([boundaryFirst, boundaryMiddle, boundaryLast]);
const forwardWrap = keyboardEvent("Tab");
assert(trapDialogFocus(boundaryPanel, forwardWrap, boundaryLast), "forward Tab wraps at the last control");
assertEqual(forwardWrap.prevented, 1, "forward wrap prevents focus from leaving the dialog");
assertEqual(boundaryFirst.focusCount, 1, "forward wrap focuses the first control");
const backwardWrap = keyboardEvent("Tab", true);
assert(trapDialogFocus(boundaryPanel, backwardWrap, boundaryFirst), "Shift+Tab wraps at the first control");
assertEqual(backwardWrap.prevented, 1, "backward wrap prevents focus from leaving the dialog");
assertEqual(boundaryLast.focusCount, 1, "backward wrap focuses the last control");
const interiorTab = keyboardEvent("Tab");
assert(!trapDialogFocus(boundaryPanel, interiorTab, boundaryMiddle), "interior Tab keeps native focus order");
assertEqual(interiorTab.prevented, 0, "interior Tab is not prevented");
const outsideTab = keyboardEvent("Tab");
assert(trapDialogFocus(boundaryPanel, outsideTab, focusableFixture()), "focus cannot enter the modal from outside its boundary");
assertEqual(boundaryFirst.focusCount, 2, "outside focus is redirected to the first control");
let closeCount = 0;
const escape = keyboardEvent("Escape");
assert(handleDialogKeyDown(boundaryPanel, escape, boundaryFirst, true, () => { closeCount += 1; }), "Escape closes a closable dialog");
assertEqual(closeCount, 1, "Escape invokes onClose once");
assertEqual(escape.prevented, 0, "Escape preserves the existing default-event behavior");
const blockedEscape = keyboardEvent("Escape");
assert(!handleDialogKeyDown(boundaryPanel, blockedEscape, boundaryFirst, false, () => { closeCount += 1; }), "Escape is ignored while closing is disabled");
assertEqual(closeCount, 1, "disabled Escape does not invoke onClose");
const opener = focusableFixture();
assert(restoreDialogFocus(opener), "connected opener focus is restored");
assertEqual(opener.focusCount, 1, "opener receives restored focus once");
assert(!restoreDialogFocus(focusableFixture({ connected: false })), "removed opener is not focused");
const backdrop = {} as EventTarget;
const child = {} as EventTarget;
assert(shouldCloseDialogOnBackdrop(backdrop, backdrop, true, true), "direct backdrop press closes a closable dialog");
assert(!shouldCloseDialogOnBackdrop(child, backdrop, true, true), "presses inside the panel do not close the dialog");
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, false, true), "backdrop closing can be disabled");
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, true, false), "backdrop press respects closeDisabled");
resetDialogStackForTests();
const outerOpener = focusableFixture();
const parentOpener = focusableFixture();
const parentLast = focusableFixture();
const parentPanel = panelFixture([parentOpener, parentLast]);
const childFirst = focusableFixture();
const childLast = focusableFixture();
const childPanel = panelFixture([childFirst, childLast]);
const parentId = Symbol("parent-dialog");
const childId = Symbol("child-dialog");
const parentOrder = nextDialogActivationOrder();
const childOrder = nextDialogActivationOrder();
let parentCloseCount = 0;
let childCloseCount = 0;
let childCanClose = false;
// React may commit a nested child's effect before its parent effect. Activation
// order is allocated during render so the child must remain topmost either way.
const unregisterChild = registerDialog({
id: childId,
activationOrder: childOrder,
panel: childPanel,
restoreFocus: parentOpener,
canClose: () => childCanClose,
onClose: () => { childCloseCount += 1; }
}, parentOpener);
const unregisterParent = registerDialog({
id: parentId,
activationOrder: parentOrder,
panel: parentPanel,
restoreFocus: outerOpener,
canClose: () => true,
onClose: () => { parentCloseCount += 1; }
}, childFirst);
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "underlying", "parent dialog is marked as underlying");
assert(parentPanel.hasAttribute("inert"), "parent dialog is inert while a child is open");
assertEqual(parentPanel.getAttribute("aria-hidden"), "true", "parent dialog is hidden from assistive technology");
assertEqual(parentPanel.getAttribute("aria-modal"), null, "underlying dialog no longer claims modal semantics");
assertEqual(childPanel.getAttribute("data-dialog-stack-state"), "topmost", "child dialog is marked as topmost");
assertEqual(childPanel.getAttribute("aria-modal"), "true", "child dialog owns modal semantics");
const stackedTab = keyboardEvent("Tab");
const parentFocusBeforeTab = parentOpener.focusCount;
assert(handleTopDialogKeyDown(stackedTab, childLast), "topmost dialog traps Tab");
assertEqual(stackedTab.prevented, 1, "stacked Tab wrapping is prevented once");
assertEqual(parentOpener.focusCount, parentFocusBeforeTab, "underlying dialog does not process Tab");
const disabledStackedEscape = keyboardEvent("Escape");
assert(!handleTopDialogKeyDown(disabledStackedEscape, childFirst), "disabled child ignores Escape without falling through");
assertEqual(childCloseCount, 0, "disabled child remains open");
assertEqual(parentCloseCount, 0, "disabled child Escape does not close the parent");
childCanClose = true;
const stackedEscape = keyboardEvent("Escape");
assert(handleTopDialogKeyDown(stackedEscape, childFirst), "topmost dialog handles Escape");
assertEqual(childCloseCount, 1, "one Escape closes the child once");
assertEqual(parentCloseCount, 0, "one Escape does not close the parent");
const parentFocusBeforeChildClose = parentOpener.focusCount;
unregisterChild();
assertEqual(parentOpener.focusCount, parentFocusBeforeChildClose + 1, "closing the child restores focus inside the parent");
assert(!parentPanel.hasAttribute("inert"), "parent dialog becomes interactive after the child closes");
assertEqual(parentPanel.getAttribute("aria-hidden"), null, "parent dialog returns to the accessibility tree");
assertEqual(parentPanel.getAttribute("aria-modal"), "true", "parent dialog regains modal semantics");
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "topmost", "parent dialog becomes topmost");
const parentEscape = keyboardEvent("Escape");
assert(handleTopDialogKeyDown(parentEscape, parentOpener), "parent handles Escape after child cleanup");
assertEqual(parentCloseCount, 1, "parent closes only on its own Escape handling");
unregisterParent();
assertEqual(outerOpener.focusCount, 1, "closing the final dialog restores the outer opener");
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), outerOpener), "keyboard handling stops when the stack is empty");
resetDialogStackForTests();
const forcedOuterOpener = focusableFixture();
const removedParentOpener = focusableFixture();
const removedParentPanel = panelFixture([removedParentOpener]);
const forcedChildControl = focusableFixture();
const forcedChildPanel = panelFixture([forcedChildControl]);
const unregisterForcedParent = registerDialog({
id: Symbol("forced-parent-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: removedParentPanel,
restoreFocus: forcedOuterOpener,
canClose: () => true
}, forcedOuterOpener);
const unregisterForcedChild = registerDialog({
id: Symbol("forced-child-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: forcedChildPanel,
restoreFocus: removedParentOpener,
canClose: () => true
}, removedParentOpener);
unregisterForcedParent();
Object.assign(removedParentOpener, { isConnected: false });
unregisterForcedChild();
assertEqual(forcedOuterOpener.focusCount, 1, "removing a dialog tree restores the nearest connected outer opener");
resetDialogStackForTests();
const strictModeOpener = focusableFixture();
const strictModeControl = focusableFixture();
const strictModePanel = panelFixture([strictModeControl]);
const strictModeRegistration = {
id: Symbol("strict-mode-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: strictModePanel,
restoreFocus: strictModeOpener,
canClose: () => true
};
const unregisterStrictModeFirstPass = registerDialog(strictModeRegistration, strictModeOpener);
unregisterStrictModeFirstPass();
const unregisterStrictModeReplay = registerDialog(strictModeRegistration, strictModeOpener);
assertEqual(strictModeControl.focusCount, 2, "StrictMode effect replay returns focus to the dialog");
assertEqual(strictModeOpener.focusCount, 1, "StrictMode cleanup restores the opener before replay");
unregisterStrictModeReplay();
assertEqual(strictModeOpener.focusCount, 2, "StrictMode replay cleanup restores focus without leaving a stack entry");
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), strictModeOpener), "StrictMode replay leaves no stale topmost dialog");
resetDialogStackForTests();
let keydownListenerAdds = 0;
let keydownListenerRemovals = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
addEventListener(type: string) {
if (type === "keydown") keydownListenerAdds += 1;
},
removeEventListener(type: string) {
if (type === "keydown") keydownListenerRemovals += 1;
},
getComputedStyle() {
return { display: "block", visibility: "visible" };
}
}
});
try {
const listenerOuter = focusableFixture();
const listenerParentPanel = panelFixture([focusableFixture()]);
const listenerChildPanel = panelFixture([focusableFixture()]);
const unregisterListenerParent = registerDialog({
id: Symbol("listener-parent"),
activationOrder: nextDialogActivationOrder(),
panel: listenerParentPanel,
restoreFocus: listenerOuter,
canClose: () => true
}, listenerOuter);
const unregisterListenerChild = registerDialog({
id: Symbol("listener-child"),
activationOrder: nextDialogActivationOrder(),
panel: listenerChildPanel,
restoreFocus: listenerParentPanel,
canClose: () => true
}, listenerParentPanel);
assertEqual(keydownListenerAdds, 1, "one window keyboard listener serves the whole dialog stack");
unregisterListenerChild();
assertEqual(keydownListenerRemovals, 0, "keyboard listener remains while a parent dialog is open");
unregisterListenerParent();
assertEqual(keydownListenerRemovals, 1, "keyboard listener is removed after the final dialog closes");
} finally {
resetDialogStackForTests();
Reflect.deleteProperty(globalThis, "window");
}
+4
View File
@@ -19,6 +19,7 @@
}, },
"include": [ "include": [
"tests/data-grid-actions.test.tsx", "tests/data-grid-actions.test.tsx",
"tests/dialog-focus.test.tsx",
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
"src/components/CredentialPanel.tsx", "src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx", "src/components/email/EmailAddressInput.tsx",
@@ -27,6 +28,9 @@
"src/components/mail/MailServerSettingsPanel.tsx", "src/components/mail/MailServerSettingsPanel.tsx",
"src/components/Button.tsx", "src/components/Button.tsx",
"src/components/DismissibleAlert.tsx", "src/components/DismissibleAlert.tsx",
"src/components/Dialog.tsx",
"src/components/dialogInteractions.ts",
"src/components/dialogStack.ts",
"src/components/FormField.tsx", "src/components/FormField.tsx",
"src/components/ToggleSwitch.tsx" "src/components/ToggleSwitch.tsx"
] ]