From 8939631c5bb2f9301fd4e3e151a50f2d9f2120e4 Mon Sep 17 00:00:00 2001 From: clala Date: Sat, 28 Feb 2026 01:44:44 +0900 Subject: [PATCH 1/7] feat: add Docuseal webhook endpoint for member agreement (#48) Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact. NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed. --- .env.example | 2 + apps/worker/src/five08/backend/api.py | 115 +++++++++- apps/worker/src/five08/worker/actors.py | 2 + apps/worker/src/five08/worker/config.py | 20 +- .../five08/worker/crm/docuseal_processor.py | 103 +++++++++ apps/worker/src/five08/worker/jobs.py | 16 ++ apps/worker/src/five08/worker/models.py | 26 +++ tests/unit/test_backend_api.py | 205 ++++++++++++++++++ tests/unit/test_worker_models.py | 28 ++- 9 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 apps/worker/src/five08/worker/crm/docuseal_processor.py diff --git a/.env.example b/.env.example index c44e11ae..4548c27d 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,8 @@ RESUME_EXTRACTOR_VERSION=v1 CRM_SYNC_ENABLED=true CRM_SYNC_INTERVAL_SECONDS=900 CRM_SYNC_PAGE_SIZE=200 +# Optional: restrict Docuseal agreements to this template id +DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID= # Discord bot (required for bot runtime) DISCORD_BOT_TOKEN=your_bot_token_here diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index 39fbfbae..f5966832 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -58,12 +58,17 @@ apply_resume_profile_job, extract_resume_profile_job, process_contact_skills_job, + process_docuseal_agreement_job, process_webhook_event, sync_people_from_crm_job, sync_person_from_crm_job, ) from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor -from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload +from five08.worker.models import ( + AuditEventPayload, + DocusealWebhookPayload, + EspoCRMWebhookPayload, +) logger = logging.getLogger(__name__) @@ -680,6 +685,109 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse: ) +async def docuseal_webhook_handler(request: Request) -> JSONResponse: + """Process a Docuseal form.completed webhook and enqueue agreement job.""" + if not _is_authorized(request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + + try: + payload_data = await request.json() + except Exception: + return JSONResponse({"error": "invalid_json"}, status_code=400) + + if not isinstance(payload_data, dict): + return JSONResponse({"error": "payload_must_be_object"}, status_code=400) + + try: + payload = DocusealWebhookPayload.model_validate(payload_data) + except (ValidationError, TypeError) as exc: + return JSONResponse( + {"error": "invalid_payload", "detail": str(exc)}, + status_code=400, + ) + + if payload.event_type != "form.completed": + return JSONResponse( + { + "status": "ignored", + "reason": f"unhandled event_type: {payload.event_type}", + }, + status_code=200, + ) + + submitter = payload.data + + template_filter_id = settings.docuseal_member_agreement_template_id + if template_filter_id is not None: + template_id = submitter.template.id if submitter.template else None + if template_id != template_filter_id: + logger.info( + "Ignoring Docuseal agreement webhook for unmatched template_id=%s" + " expected=%s submission_id=%s", + template_id, + template_filter_id, + submitter.id, + ) + return JSONResponse( + { + "status": "ignored", + "reason": "template_mismatch", + "submission_id": submitter.id, + }, + status_code=200, + ) + + email = (submitter.email or "").strip() + + completed_at = submitter.completed_at or payload.timestamp + if isinstance(completed_at, str): + completed_at = completed_at.strip() + if not isinstance(completed_at, str) or not completed_at: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + try: + datetime.fromisoformat(completed_at.replace("Z", "+00:00")) + except ValueError: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + if not email: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + queue = request.app.state.queue + try: + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=queue, + fn=process_docuseal_agreement_job, + args=(email, completed_at, submitter.id), + settings=settings, + idempotency_key=f"docuseal-agreement:{submitter.id}", + ) + except Exception: + logger.exception( + "Failed enqueueing Docuseal agreement job email=%s submission_id=%s", + email, + submitter.id, + ) + return JSONResponse({"error": "enqueue_failed"}, status_code=503) + + logger.info( + "Enqueued Docuseal agreement job job_id=%s email=%s", + job.id, + email, + ) + return JSONResponse( + { + "status": "queued", + "source": "docuseal", + "job_id": job.id, + "email": email, + "submission_id": submitter.id, + }, + status_code=202, + ) + + async def audit_event_handler(request: Request) -> JSONResponse: """Persist one human audit event.""" if not _is_authorized(request): @@ -1228,6 +1336,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI: espocrm_people_sync_webhook_handler, methods=["POST"], ) + app.add_api_route( + "/webhooks/docuseal", + docuseal_webhook_handler, + methods=["POST"], + ) app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"]) app.add_api_route( diff --git a/apps/worker/src/five08/worker/actors.py b/apps/worker/src/five08/worker/actors.py index 70bfd476..5846560f 100644 --- a/apps/worker/src/five08/worker/actors.py +++ b/apps/worker/src/five08/worker/actors.py @@ -25,6 +25,7 @@ apply_resume_profile_job, extract_resume_profile_job, process_contact_skills_job, + process_docuseal_agreement_job, process_webhook_event, sync_people_from_crm_job, sync_person_from_crm_job, @@ -48,6 +49,7 @@ apply_resume_profile_job.__name__: apply_resume_profile_job, sync_people_from_crm_job.__name__: sync_people_from_crm_job, sync_person_from_crm_job.__name__: sync_person_from_crm_job, + process_docuseal_agreement_job.__name__: process_docuseal_agreement_job, } diff --git a/apps/worker/src/five08/worker/config.py b/apps/worker/src/five08/worker/config.py index 430e7e73..867e3cdf 100644 --- a/apps/worker/src/five08/worker/config.py +++ b/apps/worker/src/five08/worker/config.py @@ -2,7 +2,7 @@ from urllib.parse import urlparse -from pydantic import model_validator +from pydantic import field_validator, model_validator from five08.settings import SharedSettings @@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings): openai_model: str = "gpt-4o-mini" resume_ai_model: str = "gpt-4o-mini" resume_extractor_version: str = "v1" + docuseal_member_agreement_template_id: int | None = None max_file_size_mb: int = 10 allowed_file_types: str = "pdf,doc,docx,txt" @@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings": self.auth_cookie_samesite = normalized return self + @field_validator("docuseal_member_agreement_template_id", mode="before") + @classmethod + def _normalize_docuseal_member_agreement_template_id( + cls, + value: object, + ) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + return int(normalized) + raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer") + @property def allowed_file_extensions(self) -> set[str]: """Allowed resume file extensions.""" diff --git a/apps/worker/src/five08/worker/crm/docuseal_processor.py b/apps/worker/src/five08/worker/crm/docuseal_processor.py new file mode 100644 index 00000000..3ce963bc --- /dev/null +++ b/apps/worker/src/five08/worker/crm/docuseal_processor.py @@ -0,0 +1,103 @@ +"""Docuseal member agreement processing workflow.""" + +import logging +import hashlib +from typing import Any + +from five08.clients.espo import EspoAPI, EspoAPIError +from five08.worker.config import settings + +logger = logging.getLogger(__name__) + + +class DocusealAgreementProcessor: + """Look up a CRM contact by email and mark their member agreement as signed.""" + + @staticmethod + def _masked_email(email: str) -> str: + return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] + + def __init__(self) -> None: + api_url = settings.espo_base_url.rstrip("/") + "/api/v1" + self.api = EspoAPI(api_url, settings.espo_api_key) + + def process_agreement( + self, + email: str, + completed_at: str, + submission_id: int, + ) -> dict[str, Any]: + """Search for the signer by email and update cMemberAgreementSignedAt.""" + masked_email = self._masked_email(email) + + try: + result = self.api.request( + "GET", + "Contact", + { + "where": [ + { + "type": "equals", + "attribute": "emailAddress", + "value": email, + } + ], + "maxSize": 1, + "select": "id,name,emailAddress", + }, + ) + except EspoAPIError as exc: + logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc) + return { + "success": False, + "masked_email": masked_email, + "error": f"CRM search failed: {exc}", + } + + contacts = result.get("list", []) + if not contacts: + logger.warning( + "No CRM contact found for masked_email=%s submission_id=%s", + masked_email, + submission_id, + ) + return { + "success": False, + "masked_email": masked_email, + "error": "contact_not_found", + } + + contact = contacts[0] + contact_id = contact["id"] + + try: + self.api.request( + "PUT", + f"Contact/{contact_id}", + { + "cMemberAgreementSignedAt": completed_at, + "cSignedMemberAgreement": True, + }, + ) + except EspoAPIError as exc: + logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc) + return { + "success": False, + "masked_email": masked_email, + "submission_id": submission_id, + "contact_id": contact_id, + "error": f"CRM update failed: {exc}", + } + + logger.info( + "Marked member agreement signed contact_id=%s masked_email=%s", + contact_id, + masked_email, + ) + return { + "success": True, + "masked_email": masked_email, + "contact_id": contact_id, + "submission_id": submission_id, + "completed_at": completed_at, + } diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 65c296f1..e404a304 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from typing import Any +from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor from five08.worker.crm.people_sync import PeopleSyncProcessor from five08.worker.crm.processor import ContactSkillsProcessor from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor @@ -68,6 +69,21 @@ def apply_resume_profile_job( return result.model_dump() +def process_docuseal_agreement_job( + email: str, + completed_at: str, + submission_id: int, +) -> dict[str, Any]: + """Mark a CRM contact as having signed the member agreement via Docuseal.""" + logger.info( + "Processing Docuseal agreement job email=%s submission_id=%s", + email, + submission_id, + ) + processor = DocusealAgreementProcessor() + return processor.process_agreement(email, completed_at, submission_id) + + def sync_people_from_crm_job() -> dict[str, Any]: """Sync a full contacts page-set from CRM into the local people cache.""" logger.info("Processing CRM people full-sync job") diff --git a/apps/worker/src/five08/worker/models.py b/apps/worker/src/five08/worker/models.py index e5c4e84d..037dc9ad 100644 --- a/apps/worker/src/five08/worker/models.py +++ b/apps/worker/src/five08/worker/models.py @@ -118,6 +118,32 @@ class ResumeApplyResult(BaseModel): error: str | None = None +class DocusealSubmitter(BaseModel): + """Single submitter entry from a Docuseal webhook payload.""" + + class Template(BaseModel): + """Template metadata attached to a Docuseal submitter.""" + + id: int | None = None + + id: int + email: str + status: str + submission_id: int | None = None + completed_at: str | None = None + name: str | None = None + external_id: str | None = None + template: Template | None = None + + +class DocusealWebhookPayload(BaseModel): + """Docuseal form.completed webhook payload.""" + + event_type: str + timestamp: str + data: DocusealSubmitter + + class AuditEventPayload(BaseModel): """Inbound payload for creating a human audit event.""" diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index fbc99432..f4a8eb40 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -576,3 +576,208 @@ def test_auth_logout_writes_logout_audit(client: TestClient) -> None: assert audit_payload.action == "auth.logout" assert audit_payload.result == api.AuditResult.SUCCESS assert audit_payload.actor_subject == "admin@508.dev" + + +# -- Docuseal webhook tests -------------------------------------------------- + +_DOCUSEAL_PAYLOAD = { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "submission_id": 4200, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "name": "Jane Doe", + "template": {"id": 68}, + }, +} + + +def test_docuseal_webhook_rejects_unauthorized(client: TestClient) -> None: + """Docuseal webhook should reject requests without valid auth.""" + response = client.post("/webhooks/docuseal", json=_DOCUSEAL_PAYLOAD) + assert response.status_code == 401 + assert response.json()["error"] == "unauthorized" + + +def test_docuseal_webhook_enqueues_agreement_job( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Valid form.completed payload should enqueue agreement job.""" + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-1") + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-1" + assert payload["email"] == "member@508.dev" + assert payload["submission_id"] == 42 + + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["idempotency_key"] == "docuseal-agreement:42" + + +def test_docuseal_webhook_rejects_invalid_payload( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Malformed payload should return 400.""" + response = client.post( + "/webhooks/docuseal", + json={"bad": "data"}, + headers=auth_headers, + ) + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +@pytest.mark.parametrize("email", ["", " "]) +def test_docuseal_webhook_rejects_blank_email( + client: TestClient, + auth_headers: dict[str, str], + email: str, +) -> None: + """Blank submitter email should be rejected.""" + payload = { + **_DOCUSEAL_PAYLOAD, + "data": {**_DOCUSEAL_PAYLOAD["data"], "email": email}, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +@pytest.mark.parametrize("timestamp", ["", " "]) +def test_docuseal_webhook_rejects_blank_timestamp( + client: TestClient, + auth_headers: dict[str, str], + timestamp: str, +) -> None: + """Blank submitter completion time should be rejected.""" + payload = { + **_DOCUSEAL_PAYLOAD, + "timestamp": timestamp, + "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": ""}, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +def test_docuseal_webhook_ignores_unmatched_template( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Webhooks for non-target templates should be ignored when template filter is set.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 100, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + **_DOCUSEAL_PAYLOAD["data"], + "template": {"id": 101}, + }, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + assert response.json()["reason"] == "template_mismatch" + + +def test_docuseal_webhook_processes_matching_template( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Matching template webhooks should still enqueue agreement jobs.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-2") + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-2" + assert payload["email"] == "member@508.dev" + assert payload["submission_id"] == 42 + + +def test_docuseal_webhook_ignores_non_completed_event( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Non form.completed events should be acknowledged but ignored.""" + payload = { + "event_type": "form.viewed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "pending", + }, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + +def test_docuseal_webhook_returns_503_on_enqueue_failure( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Enqueue failure should return 503.""" + with patch( + "five08.backend.api.enqueue_job", + side_effect=RuntimeError("queue down"), + ): + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + assert response.status_code == 503 + assert response.json()["error"] == "enqueue_failed" diff --git a/tests/unit/test_worker_models.py b/tests/unit/test_worker_models.py index 46c3c057..beb70e83 100644 --- a/tests/unit/test_worker_models.py +++ b/tests/unit/test_worker_models.py @@ -1,6 +1,10 @@ """Unit tests for worker models.""" -from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload +from five08.worker.models import ( + AuditEventPayload, + DocusealWebhookPayload, + EspoCRMWebhookPayload, +) def test_espocrm_webhook_payload_from_list() -> None: @@ -23,3 +27,25 @@ def test_audit_event_payload_defaults_metadata() -> None: actor_subject="12345", ) assert payload.metadata == {} + + +def test_docuseal_webhook_payload_parses_completed_event() -> None: + """Docuseal payload should parse form.completed event with submitter data.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "name": "Jane Doe", + }, + } + ) + assert payload.event_type == "form.completed" + assert payload.data.id == 42 + assert payload.data.email == "member@508.dev" + assert payload.data.completed_at == "2026-02-25T12:00:00Z" + assert payload.data.name == "Jane Doe" From c722156ace6e7ba53712d8b7be27f7c934348358 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 28 Feb 2026 08:26:07 +0800 Subject: [PATCH 2/7] test: add docuseal webhook coverage --- tests/unit/test_backend_api.py | 29 ++++++++++ tests/unit/test_docuseal_processor.py | 81 +++++++++++++++++++++++++++ tests/unit/test_worker_config.py | 22 ++++++++ tests/unit/test_worker_models.py | 39 +++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 tests/unit/test_docuseal_processor.py diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index f4a8eb40..6893d86e 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -741,6 +741,35 @@ def test_docuseal_webhook_processes_matching_template( assert payload["submission_id"] == 42 +def test_docuseal_webhook_processes_without_template_filter( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no template filter is configured, template-less payloads still enqueue.""" + monkeypatch.setattr(api.settings, "docuseal_member_agreement_template_id", None) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + **_DOCUSEAL_PAYLOAD["data"], + "template": None, + }, + } + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-3") + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-3" + + def test_docuseal_webhook_ignores_non_completed_event( client: TestClient, auth_headers: dict[str, str], diff --git a/tests/unit/test_docuseal_processor.py b/tests/unit/test_docuseal_processor.py new file mode 100644 index 00000000..ff3ca9e3 --- /dev/null +++ b/tests/unit/test_docuseal_processor.py @@ -0,0 +1,81 @@ +"""Unit tests for Docuseal processor logic.""" + +import hashlib +from unittest.mock import Mock, patch + +from five08.clients.espo import EspoAPIError +from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor + + +def _expected_masked_email(email: str) -> str: + return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] + + +def test_docuseal_processor_marks_member_agreement_and_flag() -> None: + """Processor should update agreement date and bool fields on matching contact.""" + mock_api = Mock() + mock_api.request.side_effect = [ + {"list": [{"id": "contact-1"}]}, + {"updated": True}, + ] + expected_email = "member@508.dev" + expected_masked = _expected_masked_email(expected_email) + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email=expected_email, + completed_at="2026-02-25T12:00:00Z", + submission_id=416, + ) + + assert mock_api.request.call_count == 2 + assert mock_api.request.call_args_list[1].args[1] == "Contact/contact-1" + assert mock_api.request.call_args_list[1].args[2] == { + "cMemberAgreementSignedAt": "2026-02-25T12:00:00Z", + "cSignedMemberAgreement": True, + } + assert result["success"] is True + assert result["masked_email"] == expected_masked + assert result["contact_id"] == "contact-1" + assert result["submission_id"] == 416 + assert "email" not in result + + +def test_docuseal_processor_returns_contact_not_found_when_missing_contact() -> None: + """Processor should return a contact-not-found error without raw email.""" + mock_api = Mock() + mock_api.request.return_value = {"list": []} + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="missing@508.dev", + completed_at="2026-02-25T12:00:00Z", + submission_id=123, + ) + + assert result["success"] is False + assert result["error"] == "contact_not_found" + assert result["masked_email"] == _expected_masked_email("missing@508.dev") + assert result["masked_email"] != "missing@508.dev" + assert mock_api.request.call_count == 1 + + +def test_docuseal_processor_returns_error_on_search_failure() -> None: + """Processor should return failure payload when CRM search request fails.""" + mock_api = Mock() + mock_api.request.side_effect = EspoAPIError("CRM unavailable") + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="broken@508.dev", + completed_at="2026-02-25T12:00:00Z", + submission_id=55, + ) + + assert result["success"] is False + assert result["error"] == "CRM search failed: CRM unavailable" + assert result["masked_email"] == _expected_masked_email("broken@508.dev") + assert result["masked_email"] != "broken@508.dev" diff --git a/tests/unit/test_worker_config.py b/tests/unit/test_worker_config.py index 60085e1a..9b7c8769 100644 --- a/tests/unit/test_worker_config.py +++ b/tests/unit/test_worker_config.py @@ -29,3 +29,25 @@ def test_email_intake_validation_passes_with_required_fields() -> None: ) assert settings.email_resume_intake_enabled is True + + +def test_docuseal_template_id_normalizes_blank_string_to_none() -> None: + """Docuseal template filter should treat empty string as unset.""" + settings = WorkerSettings( + espo_base_url="https://crm.test.com", + espo_api_key="test-key", + docuseal_member_agreement_template_id="", + ) + + assert settings.docuseal_member_agreement_template_id is None + + +def test_docuseal_template_id_accepts_numeric_string() -> None: + """Docuseal template filter should coerce numeric strings to int.""" + settings = WorkerSettings( + espo_base_url="https://crm.test.com", + espo_api_key="test-key", + docuseal_member_agreement_template_id="68", + ) + + assert settings.docuseal_member_agreement_template_id == 68 diff --git a/tests/unit/test_worker_models.py b/tests/unit/test_worker_models.py index beb70e83..3ca1c6de 100644 --- a/tests/unit/test_worker_models.py +++ b/tests/unit/test_worker_models.py @@ -49,3 +49,42 @@ def test_docuseal_webhook_payload_parses_completed_event() -> None: assert payload.data.email == "member@508.dev" assert payload.data.completed_at == "2026-02-25T12:00:00Z" assert payload.data.name == "Jane Doe" + + +def test_docuseal_webhook_payload_parses_template_metadata() -> None: + """Docuseal payload should parse template metadata and submission_id.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "submission_id": 123, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "template": {"id": 68}, + }, + } + ) + + assert payload.data.submission_id == 123 + assert payload.data.template is not None + assert payload.data.template.id == 68 + + +def test_docuseal_webhook_payload_accepts_payload_without_template() -> None: + """Legacy Docuseal payloads without template should still parse.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + }, + } + ) + + assert payload.data.template is None From 16d9b2a9da277ee812bde0121a67d0be9e4523ee Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 14:15:07 +0800 Subject: [PATCH 3/7] fix: sanitize and align docuseal webhook identifiers --- apps/worker/src/five08/backend/api.py | 33 ++++++++++++------ apps/worker/src/five08/worker/jobs.py | 10 ++++-- tests/unit/test_backend_api.py | 50 ++++++++++++++++++++++++--- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index f5966832..bfc95636 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -4,6 +4,7 @@ import asyncio import contextlib +import hashlib import logging import secrets import time @@ -73,6 +74,11 @@ logger = logging.getLogger(__name__) +def _masked_email(email: str) -> str: + """Return a deterministic masked value for logging and responses.""" + return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] + + class ResumeExtractRequest(BaseModel): """Request schema for queued resume extraction.""" @@ -716,6 +722,9 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: ) submitter = payload.data + submission_id = ( + submitter.submission_id if submitter.submission_id is not None else submitter.id + ) template_filter_id = settings.docuseal_member_agreement_template_id if template_filter_id is not None: @@ -726,13 +735,13 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: " expected=%s submission_id=%s", template_id, template_filter_id, - submitter.id, + submission_id, ) return JSONResponse( { "status": "ignored", "reason": "template_mismatch", - "submission_id": submitter.id, + "submission_id": submission_id, }, status_code=200, ) @@ -753,36 +762,38 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: if not email: return JSONResponse({"error": "invalid_payload"}, status_code=400) + masked_email = _masked_email(email) + queue = request.app.state.queue try: job: EnqueuedJob = await asyncio.to_thread( enqueue_job, queue=queue, fn=process_docuseal_agreement_job, - args=(email, completed_at, submitter.id), + args=(email, completed_at, submission_id), settings=settings, - idempotency_key=f"docuseal-agreement:{submitter.id}", + idempotency_key=f"docuseal-agreement:{submission_id}", ) except Exception: logger.exception( - "Failed enqueueing Docuseal agreement job email=%s submission_id=%s", - email, - submitter.id, + "Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s", + masked_email, + submission_id, ) return JSONResponse({"error": "enqueue_failed"}, status_code=503) logger.info( - "Enqueued Docuseal agreement job job_id=%s email=%s", + "Enqueued Docuseal agreement job job_id=%s masked_email=%s", job.id, - email, + masked_email, ) return JSONResponse( { "status": "queued", "source": "docuseal", "job_id": job.id, - "email": email, - "submission_id": submitter.id, + "masked_email": masked_email, + "submission_id": submission_id, }, status_code=202, ) diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index e404a304..abfb6c8e 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -1,6 +1,7 @@ """Domain job functions executed by worker actors.""" import logging +import hashlib from datetime import datetime, timezone from typing import Any @@ -12,6 +13,11 @@ logger = logging.getLogger(__name__) +def _masked_email(email: str) -> str: + """Return a deterministic masked value for email in logs.""" + return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] + + def process_contact_skills_job(contact_id: str) -> dict[str, Any]: """Process one EspoCRM contact and update their skills.""" logger.info("Processing queued contact skills job contact_id=%s", contact_id) @@ -76,8 +82,8 @@ def process_docuseal_agreement_job( ) -> dict[str, Any]: """Mark a CRM contact as having signed the member agreement via Docuseal.""" logger.info( - "Processing Docuseal agreement job email=%s submission_id=%s", - email, + "Processing Docuseal agreement job masked_email=%s submission_id=%s", + _masked_email(email), submission_id, ) processor = DocusealAgreementProcessor() diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index 6893d86e..32364859 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -1,6 +1,7 @@ """Unit tests for backend dashboard/ingest API.""" import pytest +import hashlib from fastapi.testclient import TestClient from unittest.mock import AsyncMock, Mock, patch @@ -595,6 +596,10 @@ def test_auth_logout_writes_logout_audit(client: TestClient) -> None: } +def _expected_masked_email(email: str) -> str: + return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] + + def test_docuseal_webhook_rejects_unauthorized(client: TestClient) -> None: """Docuseal webhook should reject requests without valid auth.""" response = client.post("/webhooks/docuseal", json=_DOCUSEAL_PAYLOAD) @@ -620,11 +625,11 @@ def test_docuseal_webhook_enqueues_agreement_job( assert payload["status"] == "queued" assert payload["source"] == "docuseal" assert payload["job_id"] == "job-ds-1" - assert payload["email"] == "member@508.dev" - assert payload["submission_id"] == 42 + assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["submission_id"] == 4200 call_kwargs = mock_enqueue.call_args.kwargs - assert call_kwargs["idempotency_key"] == "docuseal-agreement:42" + assert call_kwargs["idempotency_key"] == "docuseal-agreement:4200" def test_docuseal_webhook_rejects_invalid_payload( @@ -737,8 +742,9 @@ def test_docuseal_webhook_processes_matching_template( assert payload["status"] == "queued" assert payload["source"] == "docuseal" assert payload["job_id"] == "job-ds-2" - assert payload["email"] == "member@508.dev" - assert payload["submission_id"] == 42 + assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["submission_id"] == 4200 + assert mock_enqueue.call_args.kwargs["idempotency_key"] == "docuseal-agreement:4200" def test_docuseal_webhook_processes_without_template_filter( @@ -770,6 +776,40 @@ def test_docuseal_webhook_processes_without_template_filter( assert payload["job_id"] == "job-ds-3" +def test_docuseal_webhook_uses_submitter_id_when_submission_id_missing( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Webhooks without submission_id should fallback to submitter id for idempotency.""" + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + }, + } + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-4") + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-4" + assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["submission_id"] == 42 + + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["idempotency_key"] == "docuseal-agreement:42" + + def test_docuseal_webhook_ignores_non_completed_event( client: TestClient, auth_headers: dict[str, str], From 99660252a14729cd4e38a97a0788c30ca39b9910 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 14:22:53 +0800 Subject: [PATCH 4/7] feat: ignore docuseal webhooks when template filter unset --- apps/worker/src/five08/backend/api.py | 45 ++++++++++------- tests/unit/test_backend_api.py | 71 +++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index bfc95636..e2188f00 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -727,24 +727,33 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: ) template_filter_id = settings.docuseal_member_agreement_template_id - if template_filter_id is not None: - template_id = submitter.template.id if submitter.template else None - if template_id != template_filter_id: - logger.info( - "Ignoring Docuseal agreement webhook for unmatched template_id=%s" - " expected=%s submission_id=%s", - template_id, - template_filter_id, - submission_id, - ) - return JSONResponse( - { - "status": "ignored", - "reason": "template_mismatch", - "submission_id": submission_id, - }, - status_code=200, - ) + if template_filter_id is None: + logger.info("Ignoring Docuseal agreement webhook: template filter is unset") + return JSONResponse( + { + "status": "ignored", + "reason": "template_filter_not_configured", + }, + status_code=200, + ) + + template_id = submitter.template.id if submitter.template else None + if template_id != template_filter_id: + logger.info( + "Ignoring Docuseal agreement webhook for unmatched template_id=%s" + " expected=%s submission_id=%s", + template_id, + template_filter_id, + submission_id, + ) + return JSONResponse( + { + "status": "ignored", + "reason": "template_mismatch", + "submission_id": submission_id, + }, + status_code=200, + ) email = (submitter.email or "").strip() diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index 32364859..7768af8f 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -1,7 +1,7 @@ """Unit tests for backend dashboard/ingest API.""" -import pytest import hashlib +import pytest from fastapi.testclient import TestClient from unittest.mock import AsyncMock, Mock, patch @@ -610,8 +610,14 @@ def test_docuseal_webhook_rejects_unauthorized(client: TestClient) -> None: def test_docuseal_webhook_enqueues_agreement_job( client: TestClient, auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Valid form.completed payload should enqueue agreement job.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) with patch("five08.backend.api.enqueue_job") as mock_enqueue: mock_enqueue.return_value = Mock(id="job-ds-1") response = client.post( @@ -632,6 +638,37 @@ def test_docuseal_webhook_enqueues_agreement_job( assert call_kwargs["idempotency_key"] == "docuseal-agreement:4200" +def test_docuseal_webhook_ignored_when_template_filter_unset( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Docuseal webhook should be ignored when template filter is unset.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + None, + ) + with ( + patch("five08.backend.api.enqueue_job") as mock_enqueue, + patch("five08.backend.api.logger.info") as mock_info, + ): + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload["status"] == "ignored" + assert payload["reason"] == "template_filter_not_configured" + mock_enqueue.assert_not_called() + assert mock_info.call_args.args[0].startswith( + "Ignoring Docuseal agreement webhook: template filter is unset" + ) + + def test_docuseal_webhook_rejects_invalid_payload( client: TestClient, auth_headers: dict[str, str], @@ -747,13 +784,12 @@ def test_docuseal_webhook_processes_matching_template( assert mock_enqueue.call_args.kwargs["idempotency_key"] == "docuseal-agreement:4200" -def test_docuseal_webhook_processes_without_template_filter( +def test_docuseal_webhook_ignores_when_template_id_missing( client: TestClient, auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch, ) -> None: - """When no template filter is configured, template-less payloads still enqueue.""" - monkeypatch.setattr(api.settings, "docuseal_member_agreement_template_id", None) + """Template-less payloads should be ignored when filter is configured.""" payload = { **_DOCUSEAL_PAYLOAD, "data": { @@ -761,8 +797,12 @@ def test_docuseal_webhook_processes_without_template_filter( "template": None, }, } + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) with patch("five08.backend.api.enqueue_job") as mock_enqueue: - mock_enqueue.return_value = Mock(id="job-ds-3") response = client.post( "/webhooks/docuseal", json=payload, @@ -770,17 +810,23 @@ def test_docuseal_webhook_processes_without_template_filter( ) payload = response.json() - assert response.status_code == 202 - assert payload["status"] == "queued" - assert payload["source"] == "docuseal" - assert payload["job_id"] == "job-ds-3" + assert response.status_code == 200 + assert payload["status"] == "ignored" + assert payload["reason"] == "template_mismatch" + mock_enqueue.assert_not_called() def test_docuseal_webhook_uses_submitter_id_when_submission_id_missing( client: TestClient, auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Webhooks without submission_id should fallback to submitter id for idempotency.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) payload = { **_DOCUSEAL_PAYLOAD, "data": { @@ -788,6 +834,7 @@ def test_docuseal_webhook_uses_submitter_id_when_submission_id_missing( "email": "member@508.dev", "status": "completed", "completed_at": "2026-02-25T12:00:00Z", + "template": {"id": 68}, }, } with patch("five08.backend.api.enqueue_job") as mock_enqueue: @@ -836,8 +883,14 @@ def test_docuseal_webhook_ignores_non_completed_event( def test_docuseal_webhook_returns_503_on_enqueue_failure( client: TestClient, auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Enqueue failure should return 503.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) with patch( "five08.backend.api.enqueue_job", side_effect=RuntimeError("queue down"), From 0dc58c78da26d95f001248531dde5ac68e1ed6ce Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 14:23:15 +0800 Subject: [PATCH 5/7] docs: clarify DOCUSEAL template id optional behavior --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4548c27d..7691de05 100644 --- a/.env.example +++ b/.env.example @@ -88,7 +88,8 @@ RESUME_EXTRACTOR_VERSION=v1 CRM_SYNC_ENABLED=true CRM_SYNC_INTERVAL_SECONDS=900 CRM_SYNC_PAGE_SIZE=200 -# Optional: restrict Docuseal agreements to this template id +# Optional: restrict Docuseal agreements to this template id. +# If unset, Docuseal agreement processing is ignored. DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID= # Discord bot (required for bot runtime) From a41457e422f20290455ca37c532bbdca446f5963 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 14:39:29 +0800 Subject: [PATCH 6/7] Improve Docuseal webhook handling and masking --- apps/worker/src/five08/backend/api.py | 16 ++++++------- .../five08/worker/crm/docuseal_processor.py | 8 ++----- apps/worker/src/five08/worker/jobs.py | 9 ++----- apps/worker/src/five08/worker/masking.py | 15 ++++++++++++ tests/unit/test_backend_api.py | 24 ++++++++++++------- tests/unit/test_docuseal_processor.py | 12 ++++------ 6 files changed, 47 insertions(+), 37 deletions(-) create mode 100644 apps/worker/src/five08/worker/masking.py diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index e2188f00..8acc721e 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -4,7 +4,6 @@ import asyncio import contextlib -import hashlib import logging import secrets import time @@ -55,6 +54,7 @@ from five08.worker.config import settings from five08.worker.db_migrations import run_job_migrations from five08.worker.dispatcher import build_queue_client +from five08.worker.masking import mask_email from five08.worker.jobs import ( apply_resume_profile_job, extract_resume_profile_job, @@ -74,11 +74,6 @@ logger = logging.getLogger(__name__) -def _masked_email(email: str) -> str: - """Return a deterministic masked value for logging and responses.""" - return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] - - class ResumeExtractRequest(BaseModel): """Request schema for queued resume extraction.""" @@ -108,8 +103,13 @@ def _is_authorized(request: Request) -> bool: logger.error("Rejecting request: API_SHARED_SECRET is not configured") return False + # TODO: security-hardening: move webhook auth to per-webhook generated secrets + # sourced from an admin dashboard with copyable callback URLs. provided_secret = request.headers.get("X-API-Secret", "") - return secrets.compare_digest(provided_secret, settings.api_shared_secret) + if secrets.compare_digest(provided_secret, settings.api_shared_secret): + return True + + return False def _extract_idempotency_key(value: object) -> str | None: @@ -771,7 +771,7 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: if not email: return JSONResponse({"error": "invalid_payload"}, status_code=400) - masked_email = _masked_email(email) + masked_email = mask_email(email) queue = request.app.state.queue try: diff --git a/apps/worker/src/five08/worker/crm/docuseal_processor.py b/apps/worker/src/five08/worker/crm/docuseal_processor.py index 3ce963bc..863951d2 100644 --- a/apps/worker/src/five08/worker/crm/docuseal_processor.py +++ b/apps/worker/src/five08/worker/crm/docuseal_processor.py @@ -1,11 +1,11 @@ """Docuseal member agreement processing workflow.""" import logging -import hashlib from typing import Any from five08.clients.espo import EspoAPI, EspoAPIError from five08.worker.config import settings +from five08.worker.masking import mask_email logger = logging.getLogger(__name__) @@ -13,10 +13,6 @@ class DocusealAgreementProcessor: """Look up a CRM contact by email and mark their member agreement as signed.""" - @staticmethod - def _masked_email(email: str) -> str: - return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] - def __init__(self) -> None: api_url = settings.espo_base_url.rstrip("/") + "/api/v1" self.api = EspoAPI(api_url, settings.espo_api_key) @@ -28,7 +24,7 @@ def process_agreement( submission_id: int, ) -> dict[str, Any]: """Search for the signer by email and update cMemberAgreementSignedAt.""" - masked_email = self._masked_email(email) + masked_email = mask_email(email) try: result = self.api.request( diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index abfb6c8e..5d4adbbb 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -1,7 +1,6 @@ """Domain job functions executed by worker actors.""" import logging -import hashlib from datetime import datetime, timezone from typing import Any @@ -9,15 +8,11 @@ from five08.worker.crm.people_sync import PeopleSyncProcessor from five08.worker.crm.processor import ContactSkillsProcessor from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor +from five08.worker.masking import mask_email logger = logging.getLogger(__name__) -def _masked_email(email: str) -> str: - """Return a deterministic masked value for email in logs.""" - return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] - - def process_contact_skills_job(contact_id: str) -> dict[str, Any]: """Process one EspoCRM contact and update their skills.""" logger.info("Processing queued contact skills job contact_id=%s", contact_id) @@ -83,7 +78,7 @@ def process_docuseal_agreement_job( """Mark a CRM contact as having signed the member agreement via Docuseal.""" logger.info( "Processing Docuseal agreement job masked_email=%s submission_id=%s", - _masked_email(email), + mask_email(email), submission_id, ) processor = DocusealAgreementProcessor() diff --git a/apps/worker/src/five08/worker/masking.py b/apps/worker/src/five08/worker/masking.py new file mode 100644 index 00000000..bdbcc3e6 --- /dev/null +++ b/apps/worker/src/five08/worker/masking.py @@ -0,0 +1,15 @@ +"""PII masking helpers used across worker modules.""" + + +def mask_email(email: str) -> str: + """Return a deterministic redacted email representation for logs and responses.""" + local, at, domain = email.partition("@") + if not at: + return "***" + + masked_local = (local[:1] if local else "*") + "***" + + if not domain: + return f"{masked_local}@****..." + + return f"{masked_local}@{domain[:1]}****..." diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index 7768af8f..67b3cec8 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -1,11 +1,11 @@ """Unit tests for backend dashboard/ingest API.""" -import hashlib import pytest from fastapi.testclient import TestClient from unittest.mock import AsyncMock, Mock, patch from five08.backend import api +from five08.worker.masking import mask_email class _HealthyRedis: @@ -596,10 +596,6 @@ def test_auth_logout_writes_logout_audit(client: TestClient) -> None: } -def _expected_masked_email(email: str) -> str: - return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] - - def test_docuseal_webhook_rejects_unauthorized(client: TestClient) -> None: """Docuseal webhook should reject requests without valid auth.""" response = client.post("/webhooks/docuseal", json=_DOCUSEAL_PAYLOAD) @@ -631,7 +627,7 @@ def test_docuseal_webhook_enqueues_agreement_job( assert payload["status"] == "queued" assert payload["source"] == "docuseal" assert payload["job_id"] == "job-ds-1" - assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["masked_email"] == mask_email("member@508.dev") assert payload["submission_id"] == 4200 call_kwargs = mock_enqueue.call_args.kwargs @@ -687,9 +683,15 @@ def test_docuseal_webhook_rejects_invalid_payload( def test_docuseal_webhook_rejects_blank_email( client: TestClient, auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, email: str, ) -> None: """Blank submitter email should be rejected.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) payload = { **_DOCUSEAL_PAYLOAD, "data": {**_DOCUSEAL_PAYLOAD["data"], "email": email}, @@ -708,9 +710,15 @@ def test_docuseal_webhook_rejects_blank_email( def test_docuseal_webhook_rejects_blank_timestamp( client: TestClient, auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, timestamp: str, ) -> None: """Blank submitter completion time should be rejected.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) payload = { **_DOCUSEAL_PAYLOAD, "timestamp": timestamp, @@ -779,7 +787,7 @@ def test_docuseal_webhook_processes_matching_template( assert payload["status"] == "queued" assert payload["source"] == "docuseal" assert payload["job_id"] == "job-ds-2" - assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["masked_email"] == mask_email("member@508.dev") assert payload["submission_id"] == 4200 assert mock_enqueue.call_args.kwargs["idempotency_key"] == "docuseal-agreement:4200" @@ -850,7 +858,7 @@ def test_docuseal_webhook_uses_submitter_id_when_submission_id_missing( assert payload["status"] == "queued" assert payload["source"] == "docuseal" assert payload["job_id"] == "job-ds-4" - assert payload["masked_email"] == _expected_masked_email("member@508.dev") + assert payload["masked_email"] == mask_email("member@508.dev") assert payload["submission_id"] == 42 call_kwargs = mock_enqueue.call_args.kwargs diff --git a/tests/unit/test_docuseal_processor.py b/tests/unit/test_docuseal_processor.py index ff3ca9e3..80ae33fd 100644 --- a/tests/unit/test_docuseal_processor.py +++ b/tests/unit/test_docuseal_processor.py @@ -1,14 +1,10 @@ """Unit tests for Docuseal processor logic.""" -import hashlib from unittest.mock import Mock, patch from five08.clients.espo import EspoAPIError from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor - - -def _expected_masked_email(email: str) -> str: - return hashlib.sha256(email.encode("utf-8")).hexdigest()[:12] +from five08.worker.masking import mask_email def test_docuseal_processor_marks_member_agreement_and_flag() -> None: @@ -19,7 +15,7 @@ def test_docuseal_processor_marks_member_agreement_and_flag() -> None: {"updated": True}, ] expected_email = "member@508.dev" - expected_masked = _expected_masked_email(expected_email) + expected_masked = mask_email(expected_email) with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): processor = DocusealAgreementProcessor() @@ -57,7 +53,7 @@ def test_docuseal_processor_returns_contact_not_found_when_missing_contact() -> assert result["success"] is False assert result["error"] == "contact_not_found" - assert result["masked_email"] == _expected_masked_email("missing@508.dev") + assert result["masked_email"] == mask_email("missing@508.dev") assert result["masked_email"] != "missing@508.dev" assert mock_api.request.call_count == 1 @@ -77,5 +73,5 @@ def test_docuseal_processor_returns_error_on_search_failure() -> None: assert result["success"] is False assert result["error"] == "CRM search failed: CRM unavailable" - assert result["masked_email"] == _expected_masked_email("broken@508.dev") + assert result["masked_email"] == mask_email("broken@508.dev") assert result["masked_email"] != "broken@508.dev" From 269b277f8d600158a2fda586441d9bba849e71e9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 14:52:53 +0800 Subject: [PATCH 7/7] chore: stop writing redundant cSignedMemberAgreement flag --- apps/worker/src/five08/worker/crm/docuseal_processor.py | 1 - tests/unit/test_docuseal_processor.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/worker/src/five08/worker/crm/docuseal_processor.py b/apps/worker/src/five08/worker/crm/docuseal_processor.py index 863951d2..54823d1d 100644 --- a/apps/worker/src/five08/worker/crm/docuseal_processor.py +++ b/apps/worker/src/five08/worker/crm/docuseal_processor.py @@ -72,7 +72,6 @@ def process_agreement( f"Contact/{contact_id}", { "cMemberAgreementSignedAt": completed_at, - "cSignedMemberAgreement": True, }, ) except EspoAPIError as exc: diff --git a/tests/unit/test_docuseal_processor.py b/tests/unit/test_docuseal_processor.py index 80ae33fd..1fca8ccc 100644 --- a/tests/unit/test_docuseal_processor.py +++ b/tests/unit/test_docuseal_processor.py @@ -7,8 +7,8 @@ from five08.worker.masking import mask_email -def test_docuseal_processor_marks_member_agreement_and_flag() -> None: - """Processor should update agreement date and bool fields on matching contact.""" +def test_docuseal_processor_marks_member_agreement_signed_timestamp() -> None: + """Processor should update the member agreement signed-at timestamp.""" mock_api = Mock() mock_api.request.side_effect = [ {"list": [{"id": "contact-1"}]}, @@ -29,7 +29,6 @@ def test_docuseal_processor_marks_member_agreement_and_flag() -> None: assert mock_api.request.call_args_list[1].args[1] == "Contact/contact-1" assert mock_api.request.call_args_list[1].args[2] == { "cMemberAgreementSignedAt": "2026-02-25T12:00:00Z", - "cSignedMemberAgreement": True, } assert result["success"] is True assert result["masked_email"] == expected_masked