refactor(forms-runtime): centralize owner-specific email normalization
Module Package Release / publish-packages (push) Successful in 14s

Release v0.1.22. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
2026-09-08 12:19:39 +02:00
parent cd78800a65
commit 1236b861b8
8 changed files with 63 additions and 34 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/forms-runtime-webui", "name": "@govoplan/forms-runtime-webui",
"version": "0.1.21", "version": "0.1.22",
"private": true, "private": true,
"description": "Definition-aware form submissions and service launch for GovOPlaN.", "description": "Definition-aware form submissions and service launch for GovOPlaN.",
"type": "module", "type": "module",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-forms-runtime" name = "govoplan-forms-runtime"
version = "0.1.21" version = "0.1.22"
description = "Definition-aware form submissions and service launch for GovOPlaN." description = "Definition-aware form submissions and service launch for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -15,6 +15,7 @@ from govoplan_core.core.dsar import (
DsarSubjectRef, DsarSubjectRef,
dsar_capability_name, 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 ( from govoplan_forms_runtime.backend.db.models import (
FormAcknowledgement, FormAcknowledgement,
FormAssistedConfirmation, FormAssistedConfirmation,
@@ -1116,21 +1117,6 @@ def _session(value: object) -> Session:
return value 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: def _normalized_id(value: object) -> str | None:
if value is None: if value is None:
return None return None
+20
View File
@@ -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
@@ -65,7 +65,7 @@ from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
MODULE_ID = "forms_runtime" MODULE_ID = "forms_runtime"
MODULE_NAME = "Forms Runtime" MODULE_NAME = "Forms Runtime"
MODULE_VERSION = "0.1.21" MODULE_VERSION = "0.1.22"
PARTICIPATE_SCOPE = "forms_runtime:submission:participate" PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
ASSIST_SCOPE = "forms_runtime:submission:assist" ASSIST_SCOPE = "forms_runtime:submission:assist"
READ_SCOPE = "forms_runtime:workspace:read" READ_SCOPE = "forms_runtime:workspace:read"
@@ -27,6 +27,7 @@ from govoplan_forms_runtime.backend.db.models import (
FormStatusAccessToken, FormStatusAccessToken,
) )
from govoplan_forms_runtime.backend.domain import FormInstance 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( STATUS_ACCESS_MODES = frozenset(
@@ -616,21 +617,6 @@ def _consume_request_limit(grant: FormStatusAccessGrant, *, now: datetime) -> bo
return True 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: def _email_digest(grant_id: str, email: str) -> str:
return hashlib.sha256(f"{grant_id}\0{email}".encode("utf-8")).hexdigest() return hashlib.sha256(f"{grant_id}\0{email}".encode("utf-8")).hexdigest()
+37
View File
@@ -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()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/forms-runtime-webui", "name": "@govoplan/forms-runtime-webui",
"version": "0.1.21", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",