From 1236b861b8128b9fa1ce30c51a32856554d8c3df Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 12:19:39 +0200 Subject: [PATCH] refactor(forms-runtime): centralize owner-specific email normalization Release v0.1.22. Coordinated integrity review: GovOPlaN/govoplan-core#298. --- package.json | 2 +- pyproject.toml | 2 +- .../backend/dsar_provider.py | 16 +------- .../backend/email_normalization.py | 20 ++++++++++ .../backend/manifest.py | 2 +- .../backend/status_access.py | 16 +------- tests/test_email_normalization.py | 37 +++++++++++++++++++ webui/package.json | 2 +- 8 files changed, 63 insertions(+), 34 deletions(-) create mode 100755 src/govoplan_forms_runtime/backend/email_normalization.py create mode 100755 tests/test_email_normalization.py diff --git a/package.json b/package.json index 5584659..f108b67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/forms-runtime-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "description": "Definition-aware form submissions and service launch for GovOPlaN.", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 723bdf4..14c8508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-forms-runtime" -version = "0.1.21" +version = "0.1.22" description = "Definition-aware form submissions and service launch for GovOPlaN." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_forms_runtime/backend/dsar_provider.py b/src/govoplan_forms_runtime/backend/dsar_provider.py index c34517b..8db2342 100644 --- a/src/govoplan_forms_runtime/backend/dsar_provider.py +++ b/src/govoplan_forms_runtime/backend/dsar_provider.py @@ -15,6 +15,7 @@ from govoplan_core.core.dsar import ( DsarSubjectRef, dsar_capability_name, ) +from govoplan_forms_runtime.backend.email_normalization import normalize_status_email as _normalized_email from govoplan_forms_runtime.backend.db.models import ( FormAcknowledgement, FormAssistedConfirmation, @@ -1116,21 +1117,6 @@ def _session(value: object) -> Session: return value -def _normalized_email(value: object) -> str | None: - candidate = str(value or "").strip().casefold() - if ( - not candidate - or len(candidate) > 320 - or candidate.count("@") != 1 - or any(character.isspace() for character in candidate) - ): - return None - local, domain = candidate.rsplit("@", 1) - if not local or "." not in domain or domain.startswith(".") or domain.endswith("."): - return None - return candidate - - def _normalized_id(value: object) -> str | None: if value is None: return None diff --git a/src/govoplan_forms_runtime/backend/email_normalization.py b/src/govoplan_forms_runtime/backend/email_normalization.py new file mode 100755 index 0000000..285b7ac --- /dev/null +++ b/src/govoplan_forms_runtime/backend/email_normalization.py @@ -0,0 +1,20 @@ +"""Existing Forms Runtime email-selector policy, shared by status access and DSAR. + +This is selector canonicalization, not a transformation of submitted form values +or a general-purpose email-address validator. +""" + + +def normalize_status_email(value: object) -> str | None: + candidate = str(value or "").strip().casefold() + if ( + not candidate + or len(candidate) > 320 + or candidate.count("@") != 1 + or any(character.isspace() for character in candidate) + ): + return None + local, domain = candidate.rsplit("@", 1) + if not local or "." not in domain or domain.startswith(".") or domain.endswith("."): + return None + return candidate diff --git a/src/govoplan_forms_runtime/backend/manifest.py b/src/govoplan_forms_runtime/backend/manifest.py index 57a4f51..ff602e0 100644 --- a/src/govoplan_forms_runtime/backend/manifest.py +++ b/src/govoplan_forms_runtime/backend/manifest.py @@ -65,7 +65,7 @@ from govoplan_forms_runtime.backend.status_access import FormStatusAccessService MODULE_ID = "forms_runtime" MODULE_NAME = "Forms Runtime" -MODULE_VERSION = "0.1.21" +MODULE_VERSION = "0.1.22" PARTICIPATE_SCOPE = "forms_runtime:submission:participate" ASSIST_SCOPE = "forms_runtime:submission:assist" READ_SCOPE = "forms_runtime:workspace:read" diff --git a/src/govoplan_forms_runtime/backend/status_access.py b/src/govoplan_forms_runtime/backend/status_access.py index 0a19d79..490f603 100644 --- a/src/govoplan_forms_runtime/backend/status_access.py +++ b/src/govoplan_forms_runtime/backend/status_access.py @@ -27,6 +27,7 @@ from govoplan_forms_runtime.backend.db.models import ( FormStatusAccessToken, ) from govoplan_forms_runtime.backend.domain import FormInstance +from govoplan_forms_runtime.backend.email_normalization import normalize_status_email as _normalize_email STATUS_ACCESS_MODES = frozenset( @@ -616,21 +617,6 @@ def _consume_request_limit(grant: FormStatusAccessGrant, *, now: datetime) -> bo return True -def _normalize_email(value: object) -> str | None: - candidate = str(value or "").strip().casefold() - if ( - not candidate - or len(candidate) > 320 - or candidate.count("@") != 1 - or any(character.isspace() for character in candidate) - ): - return None - local, domain = candidate.rsplit("@", 1) - if not local or "." not in domain or domain.startswith(".") or domain.endswith("."): - return None - return candidate - - def _email_digest(grant_id: str, email: str) -> str: return hashlib.sha256(f"{grant_id}\0{email}".encode("utf-8")).hexdigest() diff --git a/tests/test_email_normalization.py b/tests/test_email_normalization.py new file mode 100755 index 0000000..61fd988 --- /dev/null +++ b/tests/test_email_normalization.py @@ -0,0 +1,37 @@ +import unittest + +from govoplan_forms_runtime.backend.dsar_provider import _normalized_email +from govoplan_forms_runtime.backend.email_normalization import normalize_status_email +from govoplan_forms_runtime.backend.status_access import _normalize_email + + +class EmailNormalizationTests(unittest.TestCase): + def test_status_access_and_dsar_share_the_same_selector_policy(self) -> None: + self.assertIs(normalize_status_email, _normalize_email) + self.assertIs(normalize_status_email, _normalized_email) + + def test_existing_selector_results_remain_exact_at_policy_boundaries(self) -> None: + boundary = "a" * 308 + "@example.org" + values = ( + (None, None), (False, None), (0, None), ("", None), (" ", None), + (" Subject@Example.ORG ", "subject@example.org"), + ("\u00a0Straße@BÜRO.Example\u00a0", "strasse@büro.example"), + ("a+b@sub.example.org", "a+b@sub.example.org"), + ("a..b@example..org", "a..b@example..org"), # Existing policy, not a new RFC validator. + ("a b@example.org", None), ("a@exa\tmple.org", None), ("a@exa\u00a0mple.org", None), + ("a@@example.org", None), ("@example.org", None), ("a@example", None), + ("a@.example.org", None), ("a@example.org.", None), + (boundary, boundary), ("a" + boundary, None), + ) + for value, expected in values: + with self.subTest(value=value): + self.assertEqual(expected, normalize_status_email(value)) + + def test_selector_canonicalization_does_not_mutate_submitted_values(self) -> None: + values = {"email": " Subject@Example.ORG ", "other": ["001", " ", None]} + self.assertEqual("subject@example.org", normalize_status_email(values["email"])) + self.assertEqual({"email": " Subject@Example.ORG ", "other": ["001", " ", None]}, values) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 97aa75f..dd2f57c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/forms-runtime-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "main": "src/index.ts",