diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b1a6774..71df55e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The types of changes are: * Distinguish whether webhook has been visited and no fields were found, versus never visited [#1339](https://github.com/ethyca/fidesops/pull/1339) * Fix Redis Cache Early Expiration in Tests [#1358](https://github.com/ethyca/fidesops/pull/1358) * Limit values for the offset pagination strategy are now cast to integers before use [#1364](https://github.com/ethyca/fidesops/pull/1364) +* Allow `requires_input` PrivacyRequests to be addressed if a webhook is deleted, disabled, or updated [#1394](https://github.com/ethyca/fidesops/pull/1394) ### Added diff --git a/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py b/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py index b159d7d32..1a6a02c14 100644 --- a/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py @@ -39,6 +39,8 @@ ConnectionException, ) from fidesops.ops.models.connectionconfig import ConnectionConfig, ConnectionType +from fidesops.ops.models.manual_webhook import AccessManualWebhook +from fidesops.ops.models.privacy_request import PrivacyRequest, PrivacyRequestStatus from fidesops.ops.schemas.api import BulkUpdateFailed from fidesops.ops.schemas.connection_configuration import ( connection_secrets_schemas, @@ -58,6 +60,9 @@ ) from fidesops.ops.schemas.shared_schemas import FidesOpsKey from fidesops.ops.service.connectors import get_connector +from fidesops.ops.service.privacy_request.request_runner_service import ( + queue_privacy_request, +) from fidesops.ops.util.api_router import APIRouter from fidesops.ops.util.logger import Pii from fidesops.ops.util.oauth_util import verify_oauth_client @@ -222,6 +227,9 @@ def patch_connections( ) ) + # Check if possibly disabling a manual webhook here causes us to need to queue affected privacy requests + requeue_requires_input_requests(db) + return BulkPutConnectionConfiguration( succeeded=created_or_updated, failed=failed, @@ -238,9 +246,15 @@ def delete_connection( ) -> None: """Removes the connection configuration with matching key.""" connection_config = get_connection_config_or_error(db, connection_key) + connection_type = connection_config.connection_type logger.info("Deleting connection config with key '%s'.", connection_key) connection_config.delete(db) + # Access Manual Webhooks are cascade deleted if their ConnectionConfig is deleted, + # so we queue any privacy requests that are no longer blocked by webhooks + if connection_type == ConnectionType.manual_webhook: + requeue_requires_input_requests(db) + def validate_secrets( request_body: connection_secrets_schemas, connection_config: ConnectionConfig @@ -356,3 +370,28 @@ async def test_connection_config_secrets( connection_config = get_connection_config_or_error(db, connection_key) msg = f"Test completed for ConnectionConfig with key: {connection_key}." return connection_status(connection_config, msg, db) + + +def requeue_requires_input_requests(db: Session) -> None: + """ + Queue privacy requests with request status "requires_input" if they are no longer blocked by + access manual webhooks. + + For use when all access manual webhooks have been either disabled or deleted, leaving privacy requests + lingering in a "requires_input" state. + """ + if not AccessManualWebhook.get_enabled(db): + for pr in PrivacyRequest.filter( + db=db, + conditions=(PrivacyRequest.status == PrivacyRequestStatus.requires_input), + ): + logger.info( + "Queuing privacy request '%s with '%s' status now that manual inputs are no longer required.", + pr.id, + pr.status.value, + ) + pr.status = PrivacyRequestStatus.in_processing + pr.save(db=db) + queue_privacy_request( + privacy_request_id=pr.id, + ) diff --git a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py index 4cd891f45..4029a0498 100644 --- a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py @@ -62,6 +62,7 @@ FunctionalityNotConfigured, IdentityNotFoundException, IdentityVerificationException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PolicyNotFoundException, TraversalError, @@ -1357,6 +1358,11 @@ def view_uploaded_manual_webhook_data( ) -> Optional[ManualWebhookData]: """ View uploaded data for this privacy request for the given access manual webhook + + If no data exists for this webhook, we just return all fields as None. + If we have missing or extra fields saved, we'll just return the overlap between what is saved and what is defined on the webhook. + + If checked=False, data must be reviewed before submission. The privacy request should not be submitted as-is. """ privacy_request: PrivacyRequest = get_privacy_request_or_error( db, privacy_request_id @@ -1368,7 +1374,8 @@ def view_uploaded_manual_webhook_data( if not privacy_request.status == PrivacyRequestStatus.requires_input: raise HTTPException( status_code=HTTP_400_BAD_REQUEST, - detail=f"Invalid access manual webhook upload request: privacy request '{privacy_request.id}' status = {privacy_request.status.value}.", # type: ignore + detail=f"Invalid access manual webhook upload request: privacy request " + f"'{privacy_request.id}' status = {privacy_request.status.value}.", # type: ignore ) try: @@ -1377,20 +1384,20 @@ def view_uploaded_manual_webhook_data( connection_config.key, privacy_request.id, ) - data: Dict[str, Any] = privacy_request.get_manual_webhook_input( + data: Dict[str, Any] = privacy_request.get_manual_webhook_input_strict( access_manual_webhook ) checked = True - except NoCachedManualWebhookEntry as exc: + except ( + PydanticValidationError, + ManualWebhookFieldsUnset, + NoCachedManualWebhookEntry, + ) as exc: logger.info(exc) - data = access_manual_webhook.empty_fields_dict - checked = False - except PydanticValidationError: - raise HTTPException( - status_code=HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Saved fields differ from fields specified on webhook '{access_manual_webhook.connection_config.key}'. " - f"Re-upload manual data using '{PRIVACY_REQUEST_ACCESS_MANUAL_WEBHOOK_INPUT.format(connection_key=connection_config.key, privacy_request_id=privacy_request.id)}'.", + data = privacy_request.get_manual_webhook_input_non_strict( + manual_webhook=access_manual_webhook ) + checked = False return ManualWebhookData(checked=checked, fields=data) @@ -1424,8 +1431,12 @@ async def resume_privacy_request_from_requires_input( ) try: for manual_webhook in access_manual_webhooks: - privacy_request.get_manual_webhook_input(manual_webhook) - except (NoCachedManualWebhookEntry, PydanticValidationError) as exc: + privacy_request.get_manual_webhook_input_strict(manual_webhook) + except ( + NoCachedManualWebhookEntry, + PydanticValidationError, + ManualWebhookFieldsUnset, + ) as exc: raise HTTPException( status_code=HTTP_400_BAD_REQUEST, detail=f"Cannot resume privacy request. {exc}", diff --git a/src/fidesops/ops/common_exceptions.py b/src/fidesops/ops/common_exceptions.py index 0b93ab7ac..749bc596b 100644 --- a/src/fidesops/ops/common_exceptions.py +++ b/src/fidesops/ops/common_exceptions.py @@ -109,6 +109,10 @@ class NoCachedManualWebhookEntry(BaseException): """No manual data exists for this webhook on the given privacy request.""" +class ManualWebhookFieldsUnset(BaseException): + """Manual webhook has fields that are not explicitly set: Likely new field has been added""" + + class PrivacyRequestErasureEmailSendRequired(BaseException): """Erasure requests will need to be fulfilled by email send. Exception is raised to change ExecutionLog details""" diff --git a/src/fidesops/ops/models/manual_webhook.py b/src/fidesops/ops/models/manual_webhook.py index c1d05eadd..1129df547 100644 --- a/src/fidesops/ops/models/manual_webhook.py +++ b/src/fidesops/ops/models/manual_webhook.py @@ -2,7 +2,7 @@ from fideslib.db.base_class import Base from fideslib.schemas.base_class import BaseSchema -from pydantic import create_model +from pydantic import BaseConfig, create_model from sqlalchemy import Column, ForeignKey, String, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.mutable import MutableList @@ -50,6 +50,14 @@ class Config: ) return ManualWebhookValidationModel + @property + def fields_non_strict_schema(self) -> BaseSchema: + """Returns a dynamic Pydantic Schema for webhook fields that can keep the overlap between + fields that are saved and fields that are defined here.""" + schema: BaseSchema = self.fields_schema + schema.__config__ = BaseConfig # Extra is "ignore" on BaseConfig + return schema + @property def empty_fields_dict(self) -> Dict[str, None]: """Return a dictionary that maps defined dsr_package_labels to None diff --git a/src/fidesops/ops/models/privacy_request.py b/src/fidesops/ops/models/privacy_request.py index d3ffb3c0b..8734dcf06 100644 --- a/src/fidesops/ops/models/privacy_request.py +++ b/src/fidesops/ops/models/privacy_request.py @@ -30,6 +30,7 @@ from fidesops.ops.api.v1.scope_registry import PRIVACY_REQUEST_CALLBACK_RESUME from fidesops.ops.common_exceptions import ( IdentityVerificationException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -505,27 +506,50 @@ def cache_manual_webhook_input( parsed_data.dict(), ) - def get_manual_webhook_input( + def get_manual_webhook_input_strict( self, manual_webhook: AccessManualWebhook ) -> Dict[str, Any]: - """Retrieve manually added data that matches fields supplied in the specified manual webhook. + """ + Retrieves manual webhook fields saved to the privacy request in strict mode. + Fails either if extra saved fields are detected (webhook definition had fields removed) or fields were not + explicitly set (webhook definition had fields added). This mode lets us know if webhooks data needs to be re-uploaded. - This is for use by the *manual_webhook* connector which is *NOT* integrated with the garph. + This is for use by the *manual_webhook* connector which is *NOT* integrated with the graph. """ - cache: FidesopsRedis = get_cache() - cached_results: Optional[ - Optional[Dict[str, Any]] - ] = cache.get_encoded_objects_by_prefix( - f"WEBHOOK_MANUAL_INPUT__{self.id}__{manual_webhook.id}" + cached_results: Optional[Dict[str, Any]] = _get_manual_input_from_cache( + privacy_request=self, manual_webhook=manual_webhook ) + if cached_results: - return manual_webhook.fields_schema.parse_obj( - list(cached_results.values())[0] - ).dict() + data: Dict[str, Any] = manual_webhook.fields_schema.parse_obj( + cached_results + ).dict(exclude_unset=True) + if set(data.keys()) != set(manual_webhook.fields_schema.__fields__.keys()): + raise ManualWebhookFieldsUnset( + f"Fields unset for privacy_request_id '{self.id}' for connection config '{manual_webhook.connection_config.key}'" + ) + return data raise NoCachedManualWebhookEntry( f"No data cached for privacy_request_id '{self.id}' for connection config '{manual_webhook.connection_config.key}'" ) + def get_manual_webhook_input_non_strict( + self, manual_webhook: AccessManualWebhook + ) -> Dict[str, Any]: + """Retrieves manual webhook fields saved to the privacy request in non-strict mode. + Returns None for any fields not explicitly set and ignores extra fields. + + This is for use by the *manual_webhook* connector which is *NOT* integrated with the graph. + """ + cached_results: Optional[Dict[str, Any]] = _get_manual_input_from_cache( + privacy_request=self, manual_webhook=manual_webhook + ) + if cached_results: + return manual_webhook.fields_non_strict_schema.parse_obj( + cached_results + ).dict() + return manual_webhook.empty_fields_dict + def cache_manual_input( self, collection: CollectionAddress, manual_rows: Optional[List[Row]] ) -> None: @@ -713,6 +737,22 @@ def error_processing(self, db: Session) -> None: ) +def _get_manual_input_from_cache( + privacy_request: PrivacyRequest, manual_webhook: AccessManualWebhook +) -> Optional[Dict[str, Any]]: + """Get raw manual input uploaded to the privacy request for the given webhook + from the cache without attempting to coerce into a Pydantic schema""" + cache: FidesopsRedis = get_cache() + cached_results: Optional[ + Optional[Dict[str, Any]] + ] = cache.get_encoded_objects_by_prefix( + f"WEBHOOK_MANUAL_INPUT__{privacy_request.id}__{manual_webhook.id}" + ) + if cached_results: + return list(cached_results.values())[0] + return None + + class ProvidedIdentityType(EnumType): """Enum for privacy request identity types""" diff --git a/src/fidesops/ops/service/privacy_request/request_runner_service.py b/src/fidesops/ops/service/privacy_request/request_runner_service.py index 3a4009e40..b78d1a5cf 100644 --- a/src/fidesops/ops/service/privacy_request/request_runner_service.py +++ b/src/fidesops/ops/service/privacy_request/request_runner_service.py @@ -16,6 +16,7 @@ ClientUnsuccessfulException, EmailDispatchException, IdentityNotFoundException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -94,9 +95,13 @@ def get_access_manual_webhook_inputs( try: for manual_webhook in AccessManualWebhook.get_enabled(db): manual_inputs[manual_webhook.connection_config.key] = [ - privacy_request.get_manual_webhook_input(manual_webhook) + privacy_request.get_manual_webhook_input_strict(manual_webhook) ] - except (NoCachedManualWebhookEntry, ValidationError) as exc: + except ( + NoCachedManualWebhookEntry, + ValidationError, + ManualWebhookFieldsUnset, + ) as exc: logger.info(exc) privacy_request.status = PrivacyRequestStatus.requires_input privacy_request.save(db) diff --git a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py index e9340c455..5b8350062 100644 --- a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py @@ -22,7 +22,11 @@ from fidesops.ops.models.connectionconfig import ConnectionConfig, ConnectionType from fidesops.ops.models.manual_webhook import AccessManualWebhook from fidesops.ops.models.policy import CurrentStep -from fidesops.ops.models.privacy_request import CheckpointActionRequired, ManualAction +from fidesops.ops.models.privacy_request import ( + CheckpointActionRequired, + ManualAction, + PrivacyRequestStatus, +) from fidesops.ops.schemas.email.email import EmailActionType from fidesops.ops.tasks import EMAIL_QUEUE_NAME @@ -188,6 +192,51 @@ def test_patch_connections_bulk_create_limit_exceeded( == "ensure this value has at most 50 items" ) + @mock.patch( + "fidesops.ops.api.v1.endpoints.connection_endpoints.queue_privacy_request" + ) + def test_disable_manual_webhook( + self, + mock_queue, + db, + url, + generate_auth_header, + api_client, + privacy_request_requires_input, + integration_manual_webhook_config, + access_manual_webhook, + ): + auth_header = generate_auth_header(scopes=[CONNECTION_CREATE_OR_UPDATE]) + + # Update resources + payload = [ + { + "name": integration_manual_webhook_config.name, + "key": integration_manual_webhook_config.key, + "connection_type": ConnectionType.manual_webhook.value, + "access": "write", + "disabled": True, + } + ] + + response = api_client.patch( + V1_URL_PREFIX + CONNECTIONS, headers=auth_header, json=payload + ) + + assert 200 == response.status_code + + assert ( + mock_queue.called + ), "Disabling this last webhook caused 'requires_input' privacy requests to be queued" + assert ( + mock_queue.call_args.kwargs["privacy_request_id"] + == privacy_request_requires_input.id + ) + db.refresh(privacy_request_requires_input) + assert ( + privacy_request_requires_input.status == PrivacyRequestStatus.in_processing + ) + def test_patch_connections_bulk_update( self, url, api_client: TestClient, db: Session, generate_auth_header, payload ) -> None: @@ -821,14 +870,19 @@ def test_delete_connection_config( is None ) + @mock.patch( + "fidesops.ops.api.v1.endpoints.connection_endpoints.queue_privacy_request" + ) def test_delete_manual_webhook_connection_config( self, + mock_queue, url, api_client: TestClient, db: Session, generate_auth_header, integration_manual_webhook_config, access_manual_webhook, + privacy_request_requires_input, ) -> None: """Assert both the connection config and its webhook are deleted""" assert ( @@ -859,6 +913,17 @@ def test_delete_manual_webhook_connection_config( .first() is None ) + assert ( + mock_queue.called + ), "Deleting this last webhook caused 'requires_input' privacy requests to be queued" + assert ( + mock_queue.call_args.kwargs["privacy_request_id"] + == privacy_request_requires_input.id + ) + db.refresh(privacy_request_requires_input) + assert ( + privacy_request_requires_input.status == PrivacyRequestStatus.in_processing + ) class TestPutConnectionConfigSecrets: diff --git a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py index cebb53ce5..788778caf 100644 --- a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py @@ -3150,7 +3150,7 @@ def test_patch_inputs_for_manual_webhook( assert response.json() is None assert ( - privacy_request_requires_input.get_manual_webhook_input( + privacy_request_requires_input.get_manual_webhook_input_strict( access_manual_webhook ) == payload @@ -3277,7 +3277,7 @@ def test_no_manual_webhook_data_exists( "fields": {"email": None, "last_name": None}, } - def test_cached_data_differs_from_webhook_fields( + def test_cached_data_extra_saved_webhook_field( self, api_client: TestClient, db, @@ -3295,11 +3295,40 @@ def test_cached_data_differs_from_webhook_fields( ] access_manual_webhook.save(db) response = api_client.get(url, headers=auth_header) - assert response.status_code == 422 - assert ( - f"Saved fields differ from fields specified on webhook '{integration_manual_webhook_config.key}'." - in response.json()["detail"] + assert response.status_code == 200 + assert response.json() == { + "checked": False, + "fields": {"id_number": None}, + }, "Response has checked=False, so this data needs to be re-uploaded before we can run the privacy request." + + def test_cached_data_missing_saved_webhook_field( + self, + api_client: TestClient, + db, + url, + generate_auth_header, + access_manual_webhook, + integration_manual_webhook_config, + privacy_request_requires_input, + cached_input, + ): + auth_header = generate_auth_header([PRIVACY_REQUEST_VIEW_DATA]) + + access_manual_webhook.fields.append( + {"pii_field": "id_no", "dsr_package_label": "id_number"} ) + access_manual_webhook.save(db) + response = api_client.get(url, headers=auth_header) + + assert response.status_code == 200 + assert response.json() == { + "checked": False, + "fields": { + "id_number": None, + "email": "customer-1@example.com", + "last_name": "McCustomer", + }, + }, "Response has checked=False. A new field has been defined on the webhook, so we should re-examine to see if that is more data we need to retrieve." def test_get_inputs_for_manual_webhook( self, diff --git a/tests/ops/fixtures/manual_webhook_fixtures.py b/tests/ops/fixtures/manual_webhook_fixtures.py index 3516c4316..d221c3923 100644 --- a/tests/ops/fixtures/manual_webhook_fixtures.py +++ b/tests/ops/fixtures/manual_webhook_fixtures.py @@ -42,7 +42,10 @@ def access_manual_webhook(db, integration_manual_webhook_config) -> ConnectionCo }, ) yield manual_webhook - manual_webhook.delete(db) + try: + manual_webhook.delete(db) + except ObjectDeletedError: + pass @pytest.fixture(scope="function") diff --git a/tests/ops/models/test_privacy_request.py b/tests/ops/models/test_privacy_request.py index 38febb907..9b2102e55 100644 --- a/tests/ops/models/test_privacy_request.py +++ b/tests/ops/models/test_privacy_request.py @@ -9,6 +9,7 @@ from fidesops.ops.common_exceptions import ( ClientUnsuccessfulException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -572,30 +573,34 @@ def test_cache_template_contents(self, privacy_request): class TestCacheManualWebhookInput: def test_cache_manual_webhook_input(self, privacy_request, access_manual_webhook): with pytest.raises(NoCachedManualWebhookEntry): - privacy_request.get_manual_webhook_input(access_manual_webhook) + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={"email": "customer-1@example.com", "last_name": "Customer"}, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": "customer-1@example.com", "last_name": "Customer", } - def test_cache_no_fields(self, privacy_request, access_manual_webhook): + def test_cache_no_fields_supplied(self, privacy_request, access_manual_webhook): privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={}, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": None, "last_name": None, - } + }, "Missing fields persisted as None" - def test_cache_field_missing(self, privacy_request, access_manual_webhook): + def test_cache_some_fields_supplied(self, privacy_request, access_manual_webhook): privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={ @@ -603,10 +608,12 @@ def test_cache_field_missing(self, privacy_request, access_manual_webhook): }, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": "customer-1@example.com", "last_name": None, - } + }, "Missing fields saved as None" def test_cache_extra_fields_not_in_webhook_specs( self, privacy_request, access_manual_webhook @@ -634,6 +641,70 @@ def test_cache_manual_webhook_no_fields_defined( input_data={"email": "customer-1@example.com", "last_name": "Customer"}, ) + def test_fields_added_to_webhook_definition( + self, db, privacy_request, access_manual_webhook + ): + """Test the use case where new fields have been added to the webhook definition + since the webhook data was saved to the privacy request""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"last_name": "Customer", "email": "jane@example.com"}, + ) + + access_manual_webhook.fields.append( + {"pii_field": "Phone", "dsr_package_label": "phone"} + ) + access_manual_webhook.save(db) + + with pytest.raises(ManualWebhookFieldsUnset): + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) + + def test_fields_removed_from_webhook_definition( + self, db, privacy_request, access_manual_webhook + ): + """Test the use case where fields have been removed from the webhook definition + since the webhook data was saved to the privacy request""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"last_name": "Customer", "email": "jane@example.com"}, + ) + + access_manual_webhook.fields = [ + {"pii_field": "last_name", "dsr_package_label": "last_name"} + ] + access_manual_webhook.save(db) + + with pytest.raises(ValidationError): + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) + + def test_non_strict_retrieval_from_cache( + self, db, privacy_request, access_manual_webhook + ): + """Test non-strict retrieval, we ignore extra fields saved and serialize missing fields as None""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"email": "customer-1@example.com", "last_name": "Customer"}, + ) + + access_manual_webhook.fields = [ # email field deleted + {"pii_field": "First Name", "dsr_package_label": "first_name"}, # New Field + { + "pii_field": "Last Name", + "dsr_package_label": "last_name", + }, # Existing Field + {"pii_field": "Phone", "dsr_package_label": "phone"}, # New Field + ] + access_manual_webhook.save(db) + + overlap_input = privacy_request.get_manual_webhook_input_non_strict( + access_manual_webhook + ) + assert overlap_input == { + "first_name": None, + "last_name": "Customer", + "phone": None, + }, "Ignores 'email' field saved to privacy request" + class TestCanRunFromCheckpoint: def test_can_run_from_checkpoint(self):