Add governed public form intake

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent d4bbdd079f
commit 3c292b8c07
25 changed files with 4715 additions and 47 deletions
+166
View File
@@ -26,10 +26,13 @@ from govoplan_forms.backend.service import (
record_form_definition,
)
from govoplan_forms_runtime.backend.db.models import (
FormAcknowledgement,
FormHandoffEffect,
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
FormIntakeProfile,
FormIntakeSession,
)
from govoplan_forms_runtime.backend.service import (
FormRuntimeError,
@@ -37,6 +40,7 @@ from govoplan_forms_runtime.backend.service import (
FormsServiceLauncher,
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
from govoplan_forms_runtime.backend.intake import FormIntakeError, FormIntakeService
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@@ -113,6 +117,9 @@ class FormsRuntimeTests(unittest.TestCase):
FormInstanceRevision.__table__,
FormInstanceEvent.__table__,
FormHandoffEffect.__table__,
FormIntakeProfile.__table__,
FormIntakeSession.__table__,
FormAcknowledgement.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
@@ -683,6 +690,165 @@ class FormsRuntimeTests(unittest.TestCase):
self.assertEqual("reconciled", reconciled.state)
self.assertEqual("handed_off", revised.status)
def test_anonymous_and_invitation_intake_are_hashed_expiring_and_replay_safe(
self,
) -> None:
intake = FormIntakeService(self.registry)
available = intake.list_available_definitions(
self.session,
self.principal,
)
self.assertEqual(
(self.definition.reference,), tuple(item.reference for item in available)
)
anonymous = intake.create_profile(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="anonymous",
custodian_ref="user:account-1",
rate_limit_per_minute=10,
recorded_at=NOW,
)
started = intake.start_anonymous(
self.session,
public_id=anonymous.public_id,
values={"name": "Ada"},
idempotency_key="anonymous-start",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertIsNotNone(started.token)
stored_session = self.session.query(FormIntakeSession).one()
self.assertNotEqual(started.token, stored_session.token_sha256)
current, definition = intake.get_public_instance(
self.session,
token=str(started.token),
now=NOW + timedelta(minutes=2),
)
self.assertEqual("permit-form", definition.reference.object_id)
submitted = intake.submit_public(
self.session,
token=str(started.token),
expected_revision=current.revision,
values=current.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="anonymous-submit",
recorded_at=NOW + timedelta(minutes=3),
)
self.assertEqual("submitted", submitted.status)
replay = intake.start_anonymous(
self.session,
public_id=anonymous.public_id,
values={"name": "Ada"},
idempotency_key="anonymous-start",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertTrue(replay.replayed)
self.assertIsNone(replay.token)
self.assertEqual(started.instance.instance_id, replay.instance.instance_id)
invitation = intake.create_profile(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="invitation",
custodian_ref="user:account-1",
recorded_at=NOW,
)
issued = intake.issue_invitation(
self.session,
self.principal,
profile_id=invitation.profile_id,
idempotency_key="invite-1",
recorded_at=NOW,
)
invited = intake.start_invitation(
self.session,
token=str(issued.token),
values={"name": "Grace"},
recorded_at=NOW + timedelta(minutes=1),
)
self.assertEqual("active", invited.status)
self.assertEqual("invitation", invited.mode)
with self.assertRaisesRegex(FormIntakeError, "unavailable"):
intake.get_public_instance(
self.session,
token="x" * 40,
now=NOW + timedelta(minutes=1),
)
def test_native_acknowledgement_is_bound_to_actor_and_exact_payload(self) -> None:
draft = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="ack-create",
recorded_at=NOW,
)
signature = self.runtime.acknowledge(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
statement_id="submission-correct",
statement_version="1",
values=draft.values,
attachment_refs=(),
accepted_at=NOW + timedelta(minutes=1),
idempotency_key="ack-1",
)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values=draft.values,
attachment_refs=(),
signature_refs=(signature,),
idempotency_key="ack-submit",
recorded_at=NOW + timedelta(minutes=2),
)
self.assertEqual(
"accepted", submitted.metadata["evidence_verification"][0]["state"]
)
second = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="ack-create-2",
recorded_at=NOW,
)
stale_signature = self.runtime.acknowledge(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=1,
statement_id="submission-correct",
statement_version="1",
values=second.values,
attachment_refs=(),
accepted_at=NOW + timedelta(minutes=1),
idempotency_key="ack-2",
)
with self.assertRaisesRegex(FormRuntimeError, "failed final verification"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=1,
values={"name": "Changed after acknowledgement"},
attachment_refs=(),
signature_refs=(stale_signature,),
idempotency_key="ack-stale-submit",
recorded_at=NOW + timedelta(minutes=2),
)
if __name__ == "__main__":
unittest.main()
+13 -2
View File
@@ -14,7 +14,15 @@ class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
{item.path for item in frontend.routes}, # type: ignore[union-attr]
)
self.assertEqual(
{"forms_runtime.navigation", "forms_runtime.workspace", "forms_runtime.instance"},
{"/forms/public/:publicId", "/forms/intake/:token"},
{item.path for item in frontend.public_routes}, # type: ignore[union-attr]
)
self.assertEqual(
{
"forms_runtime.navigation",
"forms_runtime.workspace",
"forms_runtime.instance",
},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
@@ -23,8 +31,11 @@ class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
guide = topics["forms_runtime.submissions"]
reference = topics["forms_runtime.reference.fields-and-consequences"]
self.assertIn("forms_runtime.instance", guide.metadata["help_contexts"])
self.assertIn("forms_runtime.public-intake", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
self.assertIn("forms_runtime.action.submit", reference.metadata["help_contexts"])
self.assertIn(
"forms_runtime.action.submit", reference.metadata["help_contexts"]
)
self.assertIn("submit", reference.metadata["consequence_classes"])
self.assertIn("compensate", reference.metadata["consequence_classes"])
+4 -1
View File
@@ -32,11 +32,14 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
"form_instance_revisions",
"form_instance_events",
"form_handoff_effects",
"form_intake_profiles",
"form_intake_sessions",
"form_acknowledgements",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"a3d5f7b9c1e2",
"b4e6f8a0c2d3",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import InstitutionalReference
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
from govoplan_forms_runtime.backend.db.models import (
FormInstanceIdentity,
FormInstanceRevision,
)
from govoplan_forms_runtime.backend.domain import FormInstance
from govoplan_forms_runtime.backend.record_source import FormsRuntimeRecordSource
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
@dataclass
class Principal:
account_id: str = "account-1"
tenant_id: str = "tenant-1"
scopes: tuple[str, ...] = ("forms_runtime:submission:participate",)
def has(self, scope: str) -> bool:
return scope in self.scopes
class FormsRuntimeRecordSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
FormInstanceIdentity.__table__.create(self.engine)
FormInstanceRevision.__table__.create(self.engine)
self.session = Session(self.engine)
instance = FormInstance(
tenant_id="tenant-1",
instance_id="submission-1",
revision=2,
status="submitted",
definition_ref=InstitutionalReference(
kind="form",
owner_module="forms",
object_id="permit-form",
tenant_id="tenant-1",
version="3",
),
values={"name": "Ada"},
validation_results=(),
receipt_id="receipt-1",
recorded_at=NOW,
change_reason="Submitted.",
created_by="account-1",
changed_by="account-1",
)
self.session.add_all(
(
FormInstanceIdentity(
id="identity-1",
tenant_id="tenant-1",
instance_id="submission-1",
definition_id="permit-form",
definition_revision="3",
created_by="account-1",
),
FormInstanceRevision(
id="revision-2",
tenant_id="tenant-1",
instance_id="submission-1",
identity_id="identity-1",
revision=2,
status="submitted",
recorded_at=NOW,
snapshot=instance.to_dict(),
changed_by="account-1",
),
)
)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def locator(self) -> RecordSourceLocator:
return RecordSourceLocator(
tenant_id="tenant-1",
source_module="forms_runtime",
resource_type="form_submission_revision",
resource_id="submission-1",
source_revision="2",
)
def test_resolves_exact_authorized_immutable_submission(self) -> None:
result = FormsRuntimeRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="file permit request",
)
self.assertEqual("Form submission receipt-1", result.label)
self.assertEqual(64, len(result.content_sha256 or ""))
self.assertEqual("submitted", result.metadata["status"])
self.assertNotIn("values", result.metadata)
def test_current_access_is_rechecked(self) -> None:
with self.assertRaisesRegex(RecordContractError, "cannot read"):
FormsRuntimeRecordSource().resolve(
self.session,
Principal(account_id="account-2"),
locator=self.locator(),
purpose="file permit request",
)
result = FormsRuntimeRecordSource().resolve(
self.session,
Principal(
account_id="account-2",
scopes=("forms_runtime:workspace:read",),
),
locator=self.locator(),
purpose="records administration",
)
self.assertEqual("receipt-1", result.metadata["receipt_id"])
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_forms_runtime.backend.db.models import (
FormInstanceIdentity,
FormInstanceRevision,
)
from govoplan_forms_runtime.backend.search_source import (
FormsRuntimeSearchSource,
PROVIDER_ID,
)
NOW = datetime(2026, 8, 6, 12, 0, tzinfo=UTC)
class FormsRuntimeSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
FormInstanceIdentity.__table__.create(self.engine)
FormInstanceRevision.__table__.create(self.engine)
self.session = Session(self.engine)
identity = FormInstanceIdentity(
id="identity-row-1",
tenant_id="tenant-1",
instance_id="instance-1",
definition_id="permit-form",
definition_revision="4",
created_by="account-1",
)
revision = FormInstanceRevision(
tenant_id="tenant-1",
instance_id="instance-1",
identity_id="identity-row-1",
revision=3,
status="submitted",
recorded_at=NOW,
changed_by="account-1",
snapshot={
"definition_ref": {
"object_id": "permit-form",
"version": "4",
"label": "Permit application",
},
"values": {"protected_field": "PROTECTED-VALUE"},
"receipt_id": "receipt-1",
"service_ref": {"object_id": "permit", "version": "2"},
},
)
self.session.add_all((identity, revision))
self.session.commit()
self.source = FormsRuntimeSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_search_projection_excludes_submitted_values_and_rechecks_access(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type="form_submission",
rebuild_id="rebuild-1",
),
)
document = page.documents[0]
self.assertNotIn("PROTECTED-VALUE", repr(document))
self.assertFalse(document.metadata["protected_values_indexed"])
self.assertEqual("Permit application", document.title)
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="forms_runtime",
resource_type="form_submission",
resource_id="instance-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="3")
self.assertTrue(
self.source.authorize(
self.session,
_principal({"forms_runtime:submission:participate"}),
requests=(request,),
)[reference.key]
)
self.assertTrue(
self.source.authorize(
self.session,
_principal({"forms_runtime:workspace:read"}, account_id="other"),
requests=(request,),
)[reference.key]
)
self.assertFalse(
self.source.authorize(
self.session,
_principal({"forms_runtime:submission:participate"}, account_id="other"),
requests=(request,),
)[reference.key]
)
changes = self.source.index_changes_for_event(
self.session,
event=PlatformEvent(
type="forms_runtime.instance.submitted",
module_id="forms_runtime",
tenant=EventTenantRef(id="tenant-1"),
resource=EventObjectRef(type="form_submission", id="instance-1"),
),
delivery_key="delivery-1",
)
self.assertEqual("upsert", changes[0].kind)
def _principal(scopes: set[str], *, account_id: str = "account-1") -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=f"membership-{account_id}",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id=account_id),
user=SimpleNamespace(id=f"membership-{account_id}"),
)
if __name__ == "__main__":
unittest.main()