diff --git a/.github/ISSUE_TEMPLATE/prepare-release.md b/.github/ISSUE_TEMPLATE/prepare-release.md index 4de09599db..b20f1a68d4 100644 --- a/.github/ISSUE_TEMPLATE/prepare-release.md +++ b/.github/ISSUE_TEMPLATE/prepare-release.md @@ -32,9 +32,9 @@ assignees: sergei-maertens - Payment plugins - [ ] Ogone legacy: `openforms.payments.contrib.ogone.tests.test_client` - Prefill - - [ ] Endpoints: `openforms.prefill.contrib.objects_api.tests.test_endpoints` - - [ ] Config: `openforms.prefill.contrib.objects_api.tests.test_config` - - [ ] Prefill: `openforms.prefill.contrib.objects_api.tests.test_prefill` + - [ ] Objects API: `openforms.prefill.contrib.objects_api` + - [ ] Suwinet: `openforms.prefill.contrib.suwinet` (testenv access has been retracted and won't + be reinstated) - Registration plugins: - [ ] Objects API: `openforms.registrations.contrib.objects_api` - [ ] ZGW APIs: `openforms.registrations.contrib.zgw_apis` diff --git a/docker/objects-apis/README.md b/docker/objects-apis/README.md index 388ada6fe6..b838abba7d 100644 --- a/docker/objects-apis/README.md +++ b/docker/objects-apis/README.md @@ -38,6 +38,7 @@ docker compose -f docker-compose.objects-apis.yml run objecttypes-web \ --indent=4 \ --output /app/fixtures/objecttypes_api_fixtures.json \ core.objecttype \ + core.objectversion \ token ``` diff --git a/docker/objects-apis/fixtures/objecttypes_api_fixtures.json b/docker/objects-apis/fixtures/objecttypes_api_fixtures.json index f4cd1edf02..a4c8ebd262 100644 --- a/docker/objects-apis/fixtures/objecttypes_api_fixtures.json +++ b/docker/objects-apis/fixtures/objecttypes_api_fixtures.json @@ -18,7 +18,7 @@ "documentation_url": "", "labels": {}, "created_at": "2023-10-24", - "modified_at": "2024-02-08", + "modified_at": "2024-11-25", "allow_geometry": true } }, @@ -210,8 +210,8 @@ "object_type": 1, "version": 3, "created_at": "2024-02-08", - "modified_at": "2024-02-08", - "published_at": "2024-02-08", + "modified_at": "2024-11-25", + "published_at": "2024-11-25", "json_schema": { "$id": "https://example.com/person.schema.json", "type": "object", @@ -262,7 +262,7 @@ } } }, - "status": "draft" + "status": "published" } }, { @@ -389,6 +389,71 @@ "status": "draft" } }, +{ + "model": "core.objectversion", + "pk": 9, + "fields": { + "object_type": 1, + "version": 4, + "created_at": "2024-11-25", + "modified_at": "2024-11-25", + "published_at": "2024-11-25", + "json_schema": { + "$id": "https://example.com/person.schema.json", + "type": "object", + "title": "Person", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "age": { + "type": "integer", + "minimum": 18 + }, + "bsn": { + "type": "string" + }, + "name": { + "type": "object", + "properties": { + "last.name": { + "type": "string" + } + } + }, + "nested": { + "type": "object", + "properties": { + "unrelated": { + "type": "string" + }, + "submission_payment_amount": { + "type": "number", + "multipleOf": 0.01 + } + } + }, + "submission_date": { + "type": "string", + "format": "date-time" + }, + "submission_csv_url": { + "type": "string", + "format": "uri" + }, + "submission_pdf_url": { + "type": "string", + "format": "uri" + }, + "submission_payment_completed": { + "type": "boolean" + }, + "submission_payment_public_ids": { + "type": "array" + } + } + }, + "status": "draft" + } +}, { "model": "token.tokenauth", "pk": 1, diff --git a/pyright.pyproject.toml b/pyright.pyproject.toml index 7fa2502384..55892ac49c 100644 --- a/pyright.pyproject.toml +++ b/pyright.pyproject.toml @@ -30,6 +30,7 @@ include = [ "src/openforms/contrib/zgw/service.py", "src/openforms/contrib/objects_api/", # Registrations + "src/openforms/registrations/tasks.py", "src/openforms/registrations/contrib/email/config.py", "src/openforms/registrations/contrib/email/plugin.py", "src/openforms/registrations/contrib/stuf_zds/options.py", diff --git a/src/openforms/authentication/views.py b/src/openforms/authentication/views.py index bd298e8acb..d1cb20bbd9 100644 --- a/src/openforms/authentication/views.py +++ b/src/openforms/authentication/views.py @@ -338,7 +338,6 @@ def _handle_return(self, request: Request, slug: str, plugin_id: str): if hasattr(request, "session") and FORM_AUTH_SESSION_KEY in request.session: authentication_success.send(sender=self.__class__, request=request) - return response def _handle_co_sign(self, form: Form, plugin: BasePlugin) -> None: diff --git a/src/openforms/conf/base.py b/src/openforms/conf/base.py index 3d181e8c9d..ac4d69afe7 100644 --- a/src/openforms/conf/base.py +++ b/src/openforms/conf/base.py @@ -1203,15 +1203,6 @@ "value": config("ZGW_APIS_INCLUDE_DRAFTS", default=False), }, ], - "REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION": [ - { - "condition": "boolean", - "value": config( - "REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION", - default=False, - ), - }, - ], } # diff --git a/src/openforms/conf/dev.py b/src/openforms/conf/dev.py index 749c023627..3f4127c530 100644 --- a/src/openforms/conf/dev.py +++ b/src/openforms/conf/dev.py @@ -30,11 +30,6 @@ os.environ.setdefault("SENDFILE_BACKEND", "django_sendfile.backends.development") -# Feature flags for development -os.environ.setdefault( - "REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION", - "1", -) from .base import * # noqa isort:skip diff --git a/src/openforms/contrib/objects_api/ownership_validation.py b/src/openforms/contrib/objects_api/ownership_validation.py new file mode 100644 index 0000000000..ce7269b618 --- /dev/null +++ b/src/openforms/contrib/objects_api/ownership_validation.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging + +from django.core.exceptions import PermissionDenied + +from glom import Path, PathAccessError, glom +from requests.exceptions import RequestException + +from openforms.contrib.objects_api.clients import ObjectsClient +from openforms.logging import logevent +from openforms.prefill.base import BasePlugin as BasePrefillPlugin +from openforms.registrations.base import BasePlugin as BaseRegistrationPlugin +from openforms.submissions.models import Submission + +logger = logging.getLogger(__name__) + + +def validate_object_ownership( + submission: Submission, + client: ObjectsClient, + object_attribute: list[str], + plugin: BasePrefillPlugin | BaseRegistrationPlugin, +) -> None: + """ + Function to check whether the user associated with a Submission is the owner + of an Object in the Objects API, by comparing the authentication attribute. + + This validation should only be done if the Submission has an `initial_data_reference` + """ + assert submission.initial_data_reference + + if not submission.is_authenticated: + logger.warning( + "Cannot perform object ownership validation for reference %s with unauthenticated user", + submission.initial_data_reference, + ) + logevent.object_ownership_check_anonymous_user(submission, plugin=plugin) + raise PermissionDenied("Cannot pass data reference as anonymous user") + + auth_info = submission.auth_info + + object = None + try: + object = client.get_object(submission.initial_data_reference) + except RequestException as e: + logger.exception( + "Something went wrong while trying to retrieve " + "object for initial_data_reference" + ) + raise PermissionDenied from e + + if not object_attribute: + logger.exception( + "No path for auth value configured: %s, cannot perform ownership check", + object_attribute, + ) + raise PermissionDenied( + "Could not verify if user is owner of the referenced object" + ) + + try: + auth_value = glom(object["record"]["data"], Path(*object_attribute)) + except PathAccessError as e: + logger.exception( + "Could not retrieve auth value for path %s, it could be incorrectly configured", + object_attribute, + ) + raise PermissionDenied( + "Could not verify if user is owner of the referenced object" + ) from e + + if auth_value != auth_info.value: + logger.warning( + "Submission with initial_data_reference did not pass ownership check for reference %s", + submission.initial_data_reference, + ) + logevent.object_ownership_check_failure(submission, plugin=plugin) + raise PermissionDenied("User is not the owner of the referenced object") + + logevent.object_ownership_check_success(submission, plugin=plugin) diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_backend_without_options_does_not_raise_error.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_backend_without_options_does_not_raise_error.yaml new file mode 100644 index 0000000000..955b212c8a --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_backend_without_options_does_not_raise_error.yaml @@ -0,0 +1,50 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf","uuid":"0122126f-4a7f-49d4-b131-b83786e15acf","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:21:59 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_no_backends_configured_does_not_raise_error.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_no_backends_configured_does_not_raise_error.yaml new file mode 100644 index 0000000000..955b212c8a --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_no_backends_configured_does_not_raise_error.yaml @@ -0,0 +1,50 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf","uuid":"0122126f-4a7f-49d4-b131-b83786e15acf","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:21:59 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_ownership_check_fails_if_auth_attribute_path_is_badly_configured.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_ownership_check_fails_if_auth_attribute_path_is_badly_configured.yaml new file mode 100644 index 0000000000..6dccd4b5ea --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_ownership_check_fails_if_auth_attribute_path_is_badly_configured.yaml @@ -0,0 +1,202 @@ +interactions: +- request: + body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + "record": {"typeVersion": 1, "data": {"nested": {"bsn": "111222333"}, "foo": + "bar"}, "startAt": "2024-11-29"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '206' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.2 + method: POST + uri: http://localhost:8002/api/v2/objects + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c","uuid":"c614f674-04dc-435f-a801-99277148b69c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"nested":{"bsn":"111222333"},"foo":"bar"},"geometry":null,"startAt":"2024-11-29","endAt":null,"registrationAt":"2024-11-29","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, POST, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Fri, 29 Nov 2024 09:50:58 GMT + Location: + - http://localhost:8002/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c","uuid":"c614f674-04dc-435f-a801-99277148b69c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"foo":"bar","nested":{"bsn":"111222333"}},"geometry":null,"startAt":"2024-11-29","endAt":null,"registrationAt":"2024-11-29","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Fri, 29 Nov 2024 09:50:58 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c","uuid":"c614f674-04dc-435f-a801-99277148b69c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"foo":"bar","nested":{"bsn":"111222333"}},"geometry":null,"startAt":"2024-11-29","endAt":null,"registrationAt":"2024-11-29","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Fri, 29 Nov 2024 09:50:58 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/c614f674-04dc-435f-a801-99277148b69c","uuid":"c614f674-04dc-435f-a801-99277148b69c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"foo":"bar","nested":{"bsn":"111222333"}},"geometry":null,"startAt":"2024-11-29","endAt":null,"registrationAt":"2024-11-29","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Fri, 29 Nov 2024 09:50:58 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object.yaml new file mode 100644 index 0000000000..955b212c8a --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object.yaml @@ -0,0 +1,50 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf","uuid":"0122126f-4a7f-49d4-b131-b83786e15acf","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:21:59 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object_nested_auth_attribute.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object_nested_auth_attribute.yaml new file mode 100644 index 0000000000..45e5b03a94 --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_not_owner_of_object_nested_auth_attribute.yaml @@ -0,0 +1,106 @@ +interactions: +- request: + body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + "record": {"typeVersion": 1, "data": {"nested": {"bsn": "111222333"}, "foo": + "bar"}, "startAt": "2024-11-26"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '206' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.2 + method: POST + uri: http://localhost:8002/api/v2/objects + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/ebae7217-7a91-43bb-bfce-82c353e46289","uuid":"ebae7217-7a91-43bb-bfce-82c353e46289","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"nested":{"bsn":"111222333"},"foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, POST, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:21:59 GMT + Location: + - http://localhost:8002/api/v2/objects/ebae7217-7a91-43bb-bfce-82c353e46289 + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/ebae7217-7a91-43bb-bfce-82c353e46289 + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/ebae7217-7a91-43bb-bfce-82c353e46289","uuid":"ebae7217-7a91-43bb-bfce-82c353e46289","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"foo":"bar","nested":{"bsn":"111222333"}},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '433' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:22:00 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_owner_of_object.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_owner_of_object.yaml new file mode 100644 index 0000000000..20c1a566aa --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/ObjectsAPIInitialDataOwnershipValidatorTests.test_user_is_owner_of_object.yaml @@ -0,0 +1,50 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf","uuid":"0122126f-4a7f-49d4-b131-b83786e15acf","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:22:00 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/setUpTestData.yaml b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/setUpTestData.yaml new file mode 100644 index 0000000000..381d3cafcd --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIInitialDataOwnershipValidatorTests/setUpTestData.yaml @@ -0,0 +1,58 @@ +interactions: +- request: + body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + "record": {"typeVersion": 1, "data": {"bsn": "111222333", "foo": "bar"}, "startAt": + "2024-11-26"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '194' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.2 + method: POST + uri: http://localhost:8002/api/v2/objects + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf","uuid":"0122126f-4a7f-49d4-b131-b83786e15acf","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-11-26","endAt":null,"registrationAt":"2024-11-26","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, POST, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Tue, 26 Nov 2024 13:21:59 GMT + Location: + - http://localhost:8002/api/v2/objects/0122126f-4a7f-49d4-b131-b83786e15acf + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 201 + message: Created +version: 1 diff --git a/src/openforms/contrib/objects_api/tests/test_ownership_validation.py b/src/openforms/contrib/objects_api/tests/test_ownership_validation.py new file mode 100644 index 0000000000..ba9ac0c643 --- /dev/null +++ b/src/openforms/contrib/objects_api/tests/test_ownership_validation.py @@ -0,0 +1,208 @@ +from pathlib import Path +from unittest.mock import patch + +from django.core.exceptions import PermissionDenied +from django.test import TestCase, override_settings, tag + +from requests.exceptions import HTTPError, RequestException + +from openforms.authentication.service import AuthAttribute +from openforms.contrib.objects_api.clients import get_objects_client +from openforms.contrib.objects_api.helpers import prepare_data_for_registration +from openforms.contrib.objects_api.tests.factories import ObjectsAPIGroupConfigFactory +from openforms.logging.models import TimelineLogProxy +from openforms.registrations.contrib.objects_api.plugin import ObjectsAPIRegistration +from openforms.submissions.tests.factories import SubmissionFactory +from openforms.utils.tests.vcr import OFVCRMixin, with_setup_test_data_vcr + +from ..ownership_validation import validate_object_ownership + +TEST_FILES = (Path(__file__).parent / "files").resolve() + + +PLUGIN = ObjectsAPIRegistration("test") + + +@override_settings( + CORS_ALLOW_ALL_ORIGINS=False, + ALLOWED_HOSTS=["*"], + CORS_ALLOWED_ORIGINS=["http://testserver.com"], +) +class ObjectsAPIInitialDataOwnershipValidatorTests(OFVCRMixin, TestCase): + VCR_TEST_FILES = TEST_FILES + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.objects_api_group_used = ObjectsAPIGroupConfigFactory.create( + for_test_docker_compose=True + ) + + with with_setup_test_data_vcr(cls.VCR_TEST_FILES, cls.__qualname__): + with get_objects_client(cls.objects_api_group_used) as client: + object = client.create_object( + record_data=prepare_data_for_registration( + data={"bsn": "111222333", "foo": "bar"}, + objecttype_version=1, + ), + objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + ) + cls.object_ref = object["uuid"] + + @tag("gh-4398") + def test_user_is_owner_of_object(self): + submission = SubmissionFactory.create( + auth_info__value="111222333", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference=self.object_ref, + ) + + with get_objects_client(self.objects_api_group_used) as client: + try: + validate_object_ownership(submission, client, ["bsn"], PLUGIN) + except PermissionDenied as exc: + raise self.failureException( + "BSN in submission is owner of data" + ) from exc + + @tag("gh-4398") + def test_permission_denied_if_user_is_not_logged_in(self): + submission = SubmissionFactory.create(initial_data_reference=self.object_ref) + assert not submission.is_authenticated + + with ( + get_objects_client(self.objects_api_group_used) as client, + self.assertRaisesMessage( + PermissionDenied, "Cannot pass data reference as anonymous user" + ), + ): + validate_object_ownership(submission, client, ["bsn"], PLUGIN) + + logs = TimelineLogProxy.objects.for_object(submission) + self.assertEqual( + logs.filter_event("object_ownership_check_anonymous_user").count(), 1 + ) + + @tag("gh-4398") + def test_user_is_not_owner_of_object(self): + submission = SubmissionFactory.create( + auth_info__value="123456782", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference=self.object_ref, + ) + + with ( + get_objects_client(self.objects_api_group_used) as client, + self.assertRaisesMessage( + PermissionDenied, "User is not the owner of the referenced object" + ), + ): + validate_object_ownership(submission, client, ["bsn"], PLUGIN) + + logs = TimelineLogProxy.objects.for_object(submission) + self.assertEqual(logs.filter_event("object_ownership_check_failure").count(), 1) + + @tag("gh-4398") + def test_user_is_not_owner_of_object_nested_auth_attribute(self): + with get_objects_client(self.objects_api_group_used) as client: + object = client.create_object( + record_data=prepare_data_for_registration( + data={"nested": {"bsn": "111222333"}, "foo": "bar"}, + objecttype_version=1, + ), + objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + ) + object_ref = object["uuid"] + + submission = SubmissionFactory.create( + auth_info__value="123456782", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference=object_ref, + ) + + with ( + get_objects_client(self.objects_api_group_used) as client, + self.assertRaisesMessage( + PermissionDenied, "User is not the owner of the referenced object" + ), + ): + validate_object_ownership(submission, client, ["nested", "bsn"], PLUGIN) + + @tag("gh-4398") + def test_ownership_check_fails_if_auth_attribute_path_is_badly_configured(self): + with get_objects_client(self.objects_api_group_used) as client: + object = client.create_object( + record_data=prepare_data_for_registration( + data={"nested": {"bsn": "111222333"}, "foo": "bar"}, + objecttype_version=1, + ), + objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + ) + object_ref = object["uuid"] + + submission = SubmissionFactory.create( + auth_info__value="123456782", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference=object_ref, + ) + + with get_objects_client(self.objects_api_group_used) as client: + with ( + self.subTest("empty path"), + self.assertRaisesMessage( + PermissionDenied, + "Could not verify if user is owner of the referenced object", + ), + ): + validate_object_ownership(submission, client, [], PLUGIN) + + with ( + self.subTest("non existent path"), + self.assertRaisesMessage( + PermissionDenied, + "Could not verify if user is owner of the referenced object", + ), + ): + validate_object_ownership( + submission, client, ["this", "does", "not", "exist"], PLUGIN + ) + + @tag("gh-4398") + @patch( + "openforms.contrib.objects_api.clients.objects.ObjectsClient.get_object", + side_effect=RequestException, + ) + def test_request_exception_when_doing_permission_check(self, mock_get_object): + """ + If the object could not be fetched due to request errors, the ownership check + should fail + """ + submission = SubmissionFactory.create( + auth_info__value="111222333", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference="irrelevant", + ) + with get_objects_client(self.objects_api_group_used) as client: + with self.assertRaises(PermissionDenied): + validate_object_ownership(submission, client, ["bsn"], PLUGIN) + + @tag("gh-4398") + @patch( + "openforms.contrib.objects_api.clients.objects.ObjectsClient.get_object", + side_effect=HTTPError("404"), + ) + def test_object_not_found_when_doing_permission_check(self, mock_get_object): + """ + If the object could not be fetched due to request errors, the ownership check + should fail + """ + submission = SubmissionFactory.create( + auth_info__value="111222333", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference="irrelevant", + ) + + with get_objects_client(self.objects_api_group_used) as client: + with self.assertRaises(PermissionDenied): + validate_object_ownership(submission, client, ["bsn"], PLUGIN) diff --git a/src/openforms/forms/admin/mixins.py b/src/openforms/forms/admin/mixins.py index 694fdb68e6..84000b2003 100644 --- a/src/openforms/forms/admin/mixins.py +++ b/src/openforms/forms/admin/mixins.py @@ -34,10 +34,6 @@ def render_change_form( "ZGW_APIS_INCLUDE_DRAFTS": flag_enabled( "ZGW_APIS_INCLUDE_DRAFTS", request=request ), - "REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION": flag_enabled( - "REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION", - request=request, - ), }, "confidentiality_levels": [ {"label": label, "value": value} diff --git a/src/openforms/forms/tests/variables/test_viewset.py b/src/openforms/forms/tests/variables/test_viewset.py index 8796f70483..279b89e8c9 100644 --- a/src/openforms/forms/tests/variables/test_viewset.py +++ b/src/openforms/forms/tests/variables/test_viewset.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _ from factory.django import FileField -from rest_framework import status +from rest_framework import serializers, status from rest_framework.reverse import reverse from rest_framework.test import APITestCase from zgw_consumers.constants import APITypes, AuthTypes @@ -23,6 +23,8 @@ FormStepFactory, FormVariableFactory, ) +from openforms.prefill.contrib.demo.plugin import DemoPrefill +from openforms.prefill.tests.utils import get_test_register, patch_prefill_registry from openforms.variables.constants import ( DataMappingTypes, FormVariableDataTypes, @@ -903,6 +905,23 @@ def test_validators_accepts_only_numeric_keys(self): self.assertEqual(status.HTTP_200_OK, response.status_code) def test_bulk_create_and_update_with_prefill_constraints(self): + # Isolate the prefill registry/plugins for this test - we care about the pattern, + # not about particular plugin implementation details + new_register = get_test_register() + + class OptionsSerializer(serializers.Serializer): + foo = serializers.CharField(required=True, allow_blank=False) + + class OptionsPrefill(DemoPrefill): + options = OptionsSerializer + + new_register("demo-options")(OptionsPrefill) + + # set up registry patching for the test + cm = patch_prefill_registry(new_register) + cm.__enter__() + self.addCleanup(lambda: cm.__exit__(None, None, None)) + user = StaffUserFactory.create(user_permissions=["change_form"]) self.client.force_authenticate(user) @@ -1033,7 +1052,7 @@ def test_bulk_create_and_update_with_prefill_constraints(self): "service_fetch_configuration": None, "data_type": FormVariableDataTypes.string, "source": FormVariableSources.user_defined, - "prefill_plugin": "objects_api", + "prefill_plugin": "demo-options", "prefill_attribute": "", "prefill_options": {"foo": "bar"}, } diff --git a/src/openforms/frontend/frontend.py b/src/openforms/frontend/frontend.py index 896bf8ddc2..010b29da72 100644 --- a/src/openforms/frontend/frontend.py +++ b/src/openforms/frontend/frontend.py @@ -26,6 +26,9 @@ def get_frontend_redirect_url( f = submission.cleaned_form_url f.query.remove("_of_action") f.query.remove("_of_action_params") + # Remove any data references, since this should already be stored on the + # Submission + f.query.remove("initial_data_reference") _query = {"_of_action": action} if action else {} if action_params: _query["_of_action_params"] = json.dumps(action_params) diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index 19f6fc0f02..790c4a6d3e 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -233,6 +233,12 @@ "value": "Confirm" } ], + "1Hb/pK": [ + { + "type": 0, + "value": "This is used to perform validation to verify that the authenticated user is the owner of the object." + } + ], "1HfdUc": [ { "type": 0, @@ -1607,6 +1613,12 @@ "value": "Street name" } ], + "DFQ0Pq": [ + { + "type": 0, + "value": "Update existing objects" + } + ], "DGpAyT": [ { "type": 0, @@ -5361,6 +5373,12 @@ "value": "Partner 1" } ], + "lu7yMK": [ + { + "type": 0, + "value": "Path to auth attribute (e.g. BSN/KVK) in objects" + } + ], "m20av3": [ { "type": 0, @@ -6409,6 +6427,12 @@ "value": "Variable" } ], + "xBb5YI": [ + { + "type": 0, + "value": "Select an object type and version before you can pick a source path." + } + ], "xI6md8": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index 0c6c0e3eca..e354b6c13e 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -233,6 +233,12 @@ "value": "Bevestigen" } ], + "1Hb/pK": [ + { + "type": 0, + "value": "This is used to perform validation to verify that the authenticated user is the owner of the object." + } + ], "1HfdUc": [ { "type": 0, @@ -1628,6 +1634,12 @@ "value": "Straatnaam" } ], + "DFQ0Pq": [ + { + "type": 0, + "value": "Update existing objects" + } + ], "DGpAyT": [ { "type": 0, @@ -5383,6 +5395,12 @@ "value": "Partner 1" } ], + "lu7yMK": [ + { + "type": 0, + "value": "Path to auth attribute (e.g. BSN/KVK) in objects" + } + ], "m20av3": [ { "type": 0, @@ -6431,6 +6449,12 @@ "value": "Variabele" } ], + "xBb5YI": [ + { + "type": 0, + "value": "Select an object type and version before you can pick a source path." + } + ], "xI6md8": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/Context.js b/src/openforms/js/components/admin/form_design/Context.js index 8789fa2085..c87ccc2d7d 100644 --- a/src/openforms/js/components/admin/form_design/Context.js +++ b/src/openforms/js/components/admin/form_design/Context.js @@ -5,7 +5,6 @@ TinyMceContext.displayName = 'TinyMceContext'; const FeatureFlagsContext = React.createContext({ ZGW_APIS_INCLUDE_DRAFTS: false, - REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION: false, }); FeatureFlagsContext.displayName = 'FeatureFlagsContext'; diff --git a/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js b/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js index 54035a01e9..726b9cc2fa 100644 --- a/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js +++ b/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js @@ -6,6 +6,7 @@ import { mockCataloguesGet as mockObjectsApiCataloguesGet, mockObjecttypeVersionsGet, mockObjecttypesGet, + mockTargetPathsPost, } from 'components/admin/form_design/registrations/objectsapi/mocks'; import { mockCaseTypesGet, @@ -512,6 +513,15 @@ export default { ]), mockObjectsApiCataloguesGet(), mockDocumentTypesGet(), + mockTargetPathsPost({ + string: [ + { + targetPath: ['path', 'to.the', 'target'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + }), ], zgwMocks: [ mockZGWApisCataloguesGet(), @@ -740,6 +750,29 @@ export const ObjectsAPI = { await rsSelect(catalogueSelect, 'Catalogus 2'); }); + await step( + 'Path to auth attribute is required if updating existing objects is enabled', + async () => { + const otherSettingsTitle = modal.getByRole('heading', { + name: 'Update existing objects (Tonen)', + }); + expect(otherSettingsTitle).toBeVisible(); + await userEvent.click(within(otherSettingsTitle).getByRole('link', {name: '(Tonen)'})); + + const authAttributePath = modal.getByText( + 'Path to auth attribute (e.g. BSN/KVK) in objects' + ); + + expect(authAttributePath.parentElement.parentElement).toHaveClass('field--disabled'); + + const updateExistingObject = modal.getByLabelText('Bestaand object bijwerken'); + await userEvent.click(updateExistingObject); + + // Checking `updateExistingObject` should make `authAttributePath` no longer disabled + expect(authAttributePath.parentElement.parentElement).not.toHaveClass('field--disabled'); + } + ); + await step('Submit the form', async () => { await userEvent.click(modal.getByRole('button', {name: 'Opslaan'})); expect(args.onChange).toHaveBeenCalled(); diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/AddressNlObjectsApiVariableConfigurationEditor.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/AddressNlObjectsApiVariableConfigurationEditor.js index 47c8b75a30..f6b5f624c4 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/AddressNlObjectsApiVariableConfigurationEditor.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/AddressNlObjectsApiVariableConfigurationEditor.js @@ -1,6 +1,5 @@ -import {FieldArray, useFormikContext} from 'formik'; +import {useFormikContext} from 'formik'; import isEqual from 'lodash/isEqual'; -import PropTypes from 'prop-types'; import React, {useContext} from 'react'; import {FormattedMessage} from 'react-intl'; import {useAsync, useToggle} from 'react-use'; @@ -11,11 +10,11 @@ import Field from 'components/admin/forms/Field'; import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; import {Checkbox} from 'components/admin/forms/Inputs'; -import Select, {LOADING_OPTION} from 'components/admin/forms/Select'; +import {TargetPathSelect} from 'components/admin/forms/objects_api'; import ErrorMessage from 'components/errors/ErrorMessage'; import {post} from 'utils/fetch'; -import {TargetPathDisplay} from './ObjectsApiVariableConfigurationEditor'; +import {MappedVariableTargetPathSelect} from './GenericObjectsApiVariableConfigurationEditor'; const ADDRESSNL_NESTED_PROPERTIES = { postcode: {type: 'string'}, @@ -59,16 +58,17 @@ export const AddressNlEditor = ({ objecttypeVersion, }) => { const {csrftoken} = useContext(APIContext); - const {values, setFieldValue} = useFormikContext(); + const {setValues} = useFormikContext(); + + const hasSpecificOptions = Object.values(mappedVariable?.options ?? {}).some( + targetPath => targetPath && targetPath.length + ); + const [specificTargetPaths, toggleSpecificTargetPaths] = useToggle(hasSpecificOptions); const [jsonSchemaVisible, toggleJsonSchemaVisible] = useToggle(false); - const {specificTargetPaths} = values; - const isSpecificTargetPaths = - specificTargetPaths || - (mappedVariable.options && Object.keys(mappedVariable.options).length > 0); const deriveAddress = components[variable?.key]['deriveAddress']; - // // Load all the possible target paths (obect,string and number types) in parallel and only once + // Load all the possible target paths (obect,string and number types) in parallel and only once const { loading, value: targetPaths, @@ -91,20 +91,6 @@ export const AddressNlEditor = ({ const [objectTypeTargetPaths = [], stringTypeTargetPaths = [], numberTypeTargetPaths = []] = targetPaths || []; - const choicesTypes = { - object: objectTypeTargetPaths, - string: stringTypeTargetPaths, - number: numberTypeTargetPaths, - }; - - const getChoices = type => - loading || error - ? LOADING_OPTION - : choicesTypes[type].map(t => [ - JSON.stringify(t.targetPath), - , - ]); - const getTargetPath = pathSegment => objectTypeTargetPaths.find(t => isEqual(t.targetPath, pathSegment)); @@ -119,18 +105,31 @@ export const AddressNlEditor = ({ ); const onSpecificTargetPathsChange = event => { - setFieldValue('specificTargetPaths', event.target.checked); + const makeSpecific = event.target.checked; + toggleSpecificTargetPaths(makeSpecific); - if (event.target.checked) { - setFieldValue(`${namePrefix}.targetPath`, undefined); - } else { - setFieldValue(`${namePrefix}.options.postcode`, undefined); - setFieldValue(`${namePrefix}.options.houseLetter`, undefined); - setFieldValue(`${namePrefix}.options.houseNumber`, undefined); - setFieldValue(`${namePrefix}.options.houseNumberAddition`, undefined); - setFieldValue(`${namePrefix}.options.city`, undefined); - setFieldValue(`${namePrefix}.options.streetName`, undefined); - } + setValues(prevValues => { + const newVariablesMapping = [...prevValues.variablesMapping]; + const newMappedVariable = { + ...(newVariablesMapping[index] ?? mappedVariable), + // clear targetPath if we're switching to specific subfields + targetPath: makeSpecific ? undefined : mappedVariable.targetPath, + // prepare the options structure if we're switching to specific subfields, + // otherwise remove it entirely + options: makeSpecific + ? { + postcode: undefined, + houseLetter: undefined, + houseNumber: undefined, + houseNumberAddition: undefined, + city: undefined, + streetName: undefined, + } + : undefined, + }; + newVariablesMapping[index] = newMappedVariable; + return {...prevValues, variablesMapping: newVariablesMapping}; + }); }; return ( @@ -151,7 +150,7 @@ export const AddressNlEditor = ({ defaultMessage="Whether to map the specific subfield of addressNl component" /> } - checked={isSpecificTargetPaths} + checked={specificTargetPaths} onChange={onSpecificTargetPathsChange} /> @@ -165,18 +164,19 @@ export const AddressNlEditor = ({ description="'JSON Schema object target' label" /> } - disabled={isSpecificTargetPaths} + disabled={specificTargetPaths} > - - {isSpecificTargetPaths && ( + {specificTargetPaths && (
} required + noManageChildProps > @@ -208,13 +207,12 @@ export const AddressNlEditor = ({ /> } required + noManageChildProps > @@ -227,13 +225,12 @@ export const AddressNlEditor = ({ description="'Objects registration variable mapping, addressNL component: 'options.houseLetter schema target' label" /> } + noManageChildProps > @@ -246,13 +243,12 @@ export const AddressNlEditor = ({ description="Objects registration variable mapping, addressNL component: 'options.houseNumberAddition schema target' label" /> } + noManageChildProps > @@ -266,14 +262,13 @@ export const AddressNlEditor = ({ /> } disabled={!deriveAddress} + noManageChildProps > @@ -287,20 +282,19 @@ export const AddressNlEditor = ({ /> } disabled={!deriveAddress} + noManageChildProps >
)} - {!isSpecificTargetPaths && ( + {!specificTargetPaths && (
e.preventDefault() || toggleJsonSchemaVisible()}> ); }; - -const TargetPathSelect = ({id, name, index, choices, mappedVariable, disabled}) => { - // To avoid having an incomplete variable mapping added in the `variablesMapping` array, - // It is added only when an actual target path is selected. This way, having the empty - // option selected means the variable is unmapped (hence the `arrayHelpers.remove` call below). - const { - values: {variablesMapping}, - getFieldProps, - setFieldValue, - } = useFormikContext(); - const props = getFieldProps(name); - const isNew = variablesMapping.length === index; - - return ( - ( - { - if (event.target.value === '') { - arrayHelpers.remove(index); - } else { - if (isNew) { - arrayHelpers.push({...mappedVariable, targetPath: JSON.parse(event.target.value)}); - } else { - setFieldValue(name, JSON.parse(event.target.value)); - } - } - }} - /> - )} - /> - ); -}; - -TargetPathSelect.propTypes = { - name: PropTypes.string.isRequired, - index: PropTypes.number.isRequired, - choices: PropTypes.array.isRequired, - mappedVariable: PropTypes.object.isRequired, -}; diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/LegacyConfigFields.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/LegacyConfigFields.js index 8d277e1c4f..6f37658e0e 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/LegacyConfigFields.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/LegacyConfigFields.js @@ -1,4 +1,4 @@ -import {useField} from 'formik'; +import {useField, useFormikContext} from 'formik'; import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; @@ -7,6 +7,7 @@ import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; import {TextArea, TextInput} from 'components/admin/forms/Inputs'; import { + AuthAttributePath, ObjectTypeSelect, ObjectTypeVersionSelect, ObjectsAPIGroup, @@ -28,90 +29,121 @@ const onApiGroupChange = prevValues => ({ ...prevValues, objecttype: '', objecttypeVersion: undefined, + authAttributePath: undefined, }); -const LegacyConfigFields = ({apiGroupChoices}) => ( - <> -
- +const LegacyConfigFields = ({apiGroupChoices}) => { + const { + values: { + objectsApiGroup = null, + objecttype = '', + objecttypeVersion = null, + updateExistingObject = false, + }, + } = useFormikContext(); + + return ( + <> +
+ + + } + > + + } + helpText={ + + } + /> + + } + /> + +
+ +
+ } + collapsible + initialCollapsed={false} + > + + + +
+ } > - - } - helpText={ - - } - /> - - } - /> + -
-
- } - collapsible - initialCollapsed={false} - > - - - -
- - - } - > - - - - - -
+ +
+ } + collapsible + fieldNames={['updateExistingObject', 'authAttributePath']} + > + + - } - collapsible - fieldNames={['organisatieRsin']} - > - - - -
- -); +
+ +
+ } + collapsible + fieldNames={['organisatieRsin']} + > + + +
+ + ); +}; LegacyConfigFields.propTypes = { apiGroupChoices: PropTypes.arrayOf( diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsForm.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsForm.js index e68c0508a6..c10baf9f36 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsForm.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsForm.js @@ -56,6 +56,7 @@ ObjectsApiOptionsForm.propTypes = { objecttype: PropTypes.string, objecttypeVersion: PropTypes.number, updateExistingObject: PropTypes.bool, + authAttributePath: PropTypes.arrayOf(PropTypes.string), productaanvraagType: PropTypes.string, informatieobjecttypeSubmissionReport: PropTypes.string, uploadSubmissionCsv: PropTypes.bool, diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsFormFields.stories.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsFormFields.stories.js index ddee0bdd9d..0298a8a78d 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsFormFields.stories.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiOptionsFormFields.stories.js @@ -14,6 +14,7 @@ import { mockObjecttypeVersionsGet, mockObjecttypesError, mockObjecttypesGet, + mockTargetPathsPost, } from './mocks'; const NAME = 'form.registrationBackends.0.options'; @@ -38,9 +39,6 @@ export default { formData: {}, }, parameters: { - featureFlags: { - REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION: true, - }, msw: { handlers: [ mockObjecttypesGet([ @@ -65,6 +63,15 @@ export default { ]), mockCataloguesGet(), mockDocumentTypesGet(), + mockTargetPathsPost({ + string: [ + { + targetPath: ['path', 'to.the', 'target'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + }), ], }, }, @@ -312,6 +319,7 @@ export const V1ValidationErrors = { [`${NAME}.informatieobjecttypeSubmissionReport`, 'Computer says no'], [`${NAME}.informatieobjecttypeSubmissionCsv`, 'Computer says no'], [`${NAME}.informatieobjecttypeAttachment`, 'Computer says no'], + [`${NAME}.authAttributePath`, 'Field is required'], [`${NAME}.organisatieRsin`, 'Computer says no'], ], }, @@ -329,6 +337,7 @@ export const V2ValidationErrors = { [`${NAME}.informatieobjecttypeSubmissionReport`, 'Computer says no'], [`${NAME}.informatieobjecttypeSubmissionCsv`, 'Computer says no'], [`${NAME}.informatieobjecttypeAttachment`, 'Computer says no'], + [`${NAME}.authAttributePath`, 'Field is required'], [`${NAME}.organisatieRsin`, 'Computer says no'], ], }, diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiVariableConfigurationEditor.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiVariableConfigurationEditor.js index 7d99c87432..65fd013801 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiVariableConfigurationEditor.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/ObjectsApiVariableConfigurationEditor.js @@ -18,24 +18,6 @@ const VARIABLE_CONFIGURATION_OPTIONS = { addressNL: AddressNlEditor, }; -const TargetPathDisplay = ({target}) => { - const path = target.targetPath.length ? target.targetPath.join(' > ') : '/ (root)'; - return ( - - ); -}; - -TargetPathDisplay.propTypes = { - target: PropTypes.shape({ - targetPath: PropTypes.arrayOf(PropTypes.string).isRequired, - isRequired: PropTypes.bool.isRequired, - }).isRequired, -}; - /** * Returns the Objects API Configuration editor modal for a specific variable and a specific * component type. This only applies to V2 Options @@ -54,7 +36,7 @@ TargetPathDisplay.propTypes = { * @returns {JSX.Element} - The configuration form for the Objects API */ const ObjectsApiVariableConfigurationEditor = ({variable}) => { - const {values: backendOptions, getFieldProps, setFieldValue} = useFormikContext(); + const {values: backendOptions, getFieldProps} = useFormikContext(); const {components} = useContext(FormContext); /** @type {ObjectsAPIRegistrationBackendOptions} */ @@ -134,4 +116,4 @@ ObjectsApiVariableConfigurationEditor.propTypes = { }).isRequired, }; -export {ObjectsApiVariableConfigurationEditor, TargetPathDisplay}; +export {ObjectsApiVariableConfigurationEditor}; diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/V2ConfigFields.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/V2ConfigFields.js index a65c8a0717..a3f2e2dac1 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/V2ConfigFields.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/V2ConfigFields.js @@ -5,6 +5,7 @@ import {FormattedMessage} from 'react-intl'; import useConfirm from 'components/admin/form_design/useConfirm'; import Fieldset from 'components/admin/forms/Fieldset'; import { + AuthAttributePath, ObjectTypeSelect, ObjectTypeVersionSelect, ObjectsAPIGroup, @@ -26,12 +27,19 @@ const onApiGroupChange = prevValues => ({ ...prevValues, objecttype: '', objecttypeVersion: undefined, + authAttributePath: undefined, variablesMapping: [], }); const V2ConfigFields = ({apiGroupChoices}) => { const { - values: {variablesMapping = []}, + values: { + objectsApiGroup = null, + objecttype = '', + objecttypeVersion = null, + variablesMapping = [], + updateExistingObject = false, + }, setFieldValue, } = useFormikContext(); @@ -123,6 +131,26 @@ const V2ConfigFields = ({apiGroupChoices}) => { +
+ } + collapsible + fieldNames={['updateExistingObject', 'authAttributePath']} + > + + +
+
{ fieldNames={['organisatieRsin']} > -
diff --git a/src/openforms/js/components/admin/form_design/registrations/objectsapi/fields/UpdateExistingObject.js b/src/openforms/js/components/admin/form_design/registrations/objectsapi/fields/UpdateExistingObject.js index 3f5dc0ba3c..3b26ccf77c 100644 --- a/src/openforms/js/components/admin/form_design/registrations/objectsapi/fields/UpdateExistingObject.js +++ b/src/openforms/js/components/admin/form_design/registrations/objectsapi/fields/UpdateExistingObject.js @@ -1,19 +1,11 @@ import {useField} from 'formik'; -import {useContext} from 'react'; import {FormattedMessage} from 'react-intl'; -import {FeatureFlagsContext} from 'components/admin/form_design/Context'; import FormRow from 'components/admin/forms/FormRow'; import {Checkbox} from 'components/admin/forms/Inputs'; const UpdateExistingObject = () => { const [fieldProps] = useField({name: 'updateExistingObject', type: 'checkbox'}); - const {REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION = false} = - useContext(FeatureFlagsContext); - - if (!REGISTRATION_OBJECTS_API_ENABLE_EXISTING_OBJECT_INTEGRATION) { - return null; - } return ( diff --git a/src/openforms/js/components/admin/form_design/story-decorators.js b/src/openforms/js/components/admin/form_design/story-decorators.js index 208ea5bb43..fad8d5f766 100644 --- a/src/openforms/js/components/admin/form_design/story-decorators.js +++ b/src/openforms/js/components/admin/form_design/story-decorators.js @@ -12,9 +12,6 @@ export const FeatureFlagsDecorator = (Story, {parameters}) => ( diff --git a/src/openforms/js/components/admin/form_design/variables/VariablesEditor.stories.js b/src/openforms/js/components/admin/form_design/variables/VariablesEditor.stories.js index ed1c30a76d..16387356ca 100644 --- a/src/openforms/js/components/admin/form_design/variables/VariablesEditor.stories.js +++ b/src/openforms/js/components/admin/form_design/variables/VariablesEditor.stories.js @@ -1,4 +1,5 @@ -import {expect, fn, screen, userEvent, waitFor, within} from '@storybook/test'; +import {expect, fn, userEvent, waitFor, within} from '@storybook/test'; +import selectEvent from 'react-select-event'; import { mockObjectsAPIPrefillPropertiesGet, @@ -6,10 +7,14 @@ import { } from 'components/admin/form_design/mocks'; import {BACKEND_OPTIONS_FORMS} from 'components/admin/form_design/registrations'; import {mockTargetPathsPost} from 'components/admin/form_design/registrations/objectsapi/mocks'; +import { + mockObjecttypeVersionsGet, + mockObjecttypesGet, +} from 'components/admin/form_design/registrations/objectsapi/mocks'; +import {FormDecorator} from 'components/admin/form_design/story-decorators'; +import {serializeValue} from 'components/admin/forms/VariableMapping'; +import {findReactSelectMenu, rsSelect} from 'utils/storybookTestHelpers'; -import {serializeValue} from '../../forms/VariableMapping'; -import {mockObjecttypeVersionsGet, mockObjecttypesGet} from '../registrations/objectsapi/mocks'; -import {FormDecorator, withReactSelectDecorator} from '../story-decorators'; import VariablesEditor from './VariablesEditor'; BACKEND_OPTIONS_FORMS.testPlugin = { @@ -188,6 +193,8 @@ export default { ], }, }), + ], + objectsAPIPrefill: [ mockObjecttypesGet([ { url: 'https://objecttypen.nl/api/v1/objecttypes/2c77babf-a967-4057-9969-0200320d23f1', @@ -209,6 +216,24 @@ export default { {version: 2, status: 'draft'}, ]), ], + objectTypeTargetPaths: [ + mockTargetPathsPost({ + string: [ + { + targetPath: ['path', 'to.the', 'target'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + object: [ + { + targetPath: ['other', 'path'], + isRequired: false, + jsonSchema: {type: 'object', properties: {a: {type: 'string'}}, required: ['a']}, + }, + ], + }), + ], }, }, }, @@ -313,31 +338,6 @@ export const WithObjectsAPIRegistrationBackends = { ], }, ], - onFieldChange: data => { - console.log(data); - }, - }, - parameters: { - msw: { - handlers: [ - mockTargetPathsPost({ - string: [ - { - targetPath: ['path', 'to.the', 'target'], - isRequired: true, - jsonSchema: {type: 'string'}, - }, - ], - object: [ - { - targetPath: ['other', 'path'], - isRequired: false, - jsonSchema: {type: 'object', properties: {a: {type: 'string'}}, required: ['a']}, - }, - ], - }), - ], - }, }, play: async ({canvasElement}) => { const canvas = within(canvasElement); @@ -385,79 +385,87 @@ export const FilesMappingAndObjectAPIRegistration = { pluginVerboseName: 'Objects API registration', }, ], - onFieldChange: data => { - console.log(data); - }, }, parameters: { msw: { - handlers: [ - mockTargetPathsPost({ - string: [ - { - targetPath: ['path', 'to.the', 'target'], - isRequired: true, - jsonSchema: {type: 'string'}, - }, - { - targetPath: ['path', 'to', 'uri'], - isRequired: true, - jsonSchema: { - type: 'string', - format: 'uri', + handlers: { + objectTypeTargetPaths: [ + mockTargetPathsPost({ + string: [ + { + targetPath: ['path', 'to.the', 'target'], + isRequired: true, + jsonSchema: {type: 'string'}, }, - }, - ], - object: [ - { - targetPath: ['other', 'path'], - isRequired: false, - jsonSchema: {type: 'object', properties: {a: {type: 'string'}}, required: ['a']}, - }, - ], - array: [ - { - targetPath: ['path', 'to', 'array'], - isRequired: true, - jsonSchema: {type: 'array'}, - }, - ], - }), - ], + { + targetPath: ['path', 'to', 'uri'], + isRequired: true, + jsonSchema: { + type: 'string', + format: 'uri', + }, + }, + ], + object: [ + { + targetPath: ['other', 'path'], + isRequired: false, + jsonSchema: {type: 'object', properties: {a: {type: 'string'}}, required: ['a']}, + }, + ], + array: [ + { + targetPath: ['path', 'to', 'array'], + isRequired: true, + jsonSchema: {type: 'array'}, + }, + ], + }), + ], + }, }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, step}) => { const canvas = within(canvasElement); const editIcons = canvas.getAllByTitle('Registratie-instellingen bewerken'); + expect(editIcons).toHaveLength(3); + + await step('Single file component', async () => { + // The second icon is for the single file upload component variable + await userEvent.click(editIcons[1]); + + const targetSchemaDropdown = await canvas.findByRole('combobox', {name: 'Bestemmingspad'}); + await expect(targetSchemaDropdown).toBeVisible(); + selectEvent.openMenu(targetSchemaDropdown); + + // Only the targets of type string should appear + const targetSelectMenu = within(await findReactSelectMenu(canvas)); + expect( + await targetSelectMenu.findByRole('option', {name: 'path > to.the > target (verplicht)'}) + ).toBeVisible(); + await expect( + await targetSelectMenu.findByRole('option', {name: 'path > to > uri (verplicht)'}) + ).toBeVisible(); + + const saveButton = canvas.getByRole('button', {name: 'Opslaan'}); + userEvent.click(saveButton); + }); - // The second icon is for the single file upload component variable - userEvent.click(editIcons[1]); - - const targetSchemaDropdown = await screen.findByRole('combobox'); - - await expect(targetSchemaDropdown).toBeInTheDocument(); - - // Only the targets of type string should appear - await expect( - await screen.findByRole('option', {name: 'path > to.the > target (verplicht)'}) - ).toBeVisible(); - await expect( - await screen.findByRole('option', {name: 'path > to > uri (verplicht)'}) - ).toBeVisible(); - - const saveButton = screen.getByRole('button', {name: 'Opslaan'}); - userEvent.click(saveButton); - - // The third icon is for the multiple file upload component variable - userEvent.click(editIcons[2]); + await step('Multi file component', async () => { + // The third icon is for the multiple file upload component variable + await userEvent.click(editIcons[2]); - const dropdown = await screen.findByRole('combobox'); + const targetSchemaDropdown = await canvas.findByRole('combobox', {name: 'Bestemmingspad'}); + await expect(targetSchemaDropdown).toBeVisible(); + selectEvent.openMenu(targetSchemaDropdown); - await expect(dropdown).toBeInTheDocument(); - await expect( - await screen.findByRole('option', {name: 'path > to > array (verplicht)'}) - ).toBeVisible(); + // Only the targets of type array should appear + const targetSelectMenu = within(await findReactSelectMenu(canvas)); + expect( + await targetSelectMenu.findByRole('option', {name: 'path > to > array (verplicht)'}) + ).toBeVisible(); + }); }, }; @@ -542,9 +550,6 @@ export const WithObjectsAPIAndTestRegistrationBackends = { ], }, ], - onFieldChange: data => { - console.log(data); - }, }, parameters: { msw: { @@ -582,13 +587,35 @@ export const ConfigurePrefill = { const editIcon = canvas.getByTitle('Prefill instellen'); await userEvent.click(editIcon); - const pluginDropdown = await screen.findByLabelText('Plugin'); + const pluginDropdown = await canvas.findByLabelText('Plugin'); expect(pluginDropdown).toBeVisible(); expect(await within(pluginDropdown).findByRole('option', {name: 'StUF-BG'})).toBeVisible(); }, }; export const ConfigurePrefillObjectsAPI = { + parameters: { + msw: { + handlers: { + objectTypeTargetPaths: mockTargetPathsPost({ + string: [ + { + targetPath: ['bsn'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + number: [ + { + targetPath: ['path', 'to', 'bsn'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + }), + }, + }, + }, play: async ({canvasElement, step}) => { const canvas = within(canvasElement); @@ -605,18 +632,18 @@ export const ConfigurePrefillObjectsAPI = { await step('Configure Objects API prefill', async () => { const modal = within(await canvas.findByRole('dialog')); - const pluginDropdown = await screen.findByLabelText('Plugin'); + const pluginDropdown = await canvas.findByLabelText('Plugin'); expect(pluginDropdown).toBeVisible(); await userEvent.selectOptions(pluginDropdown, 'Objects API'); // check mappings - const variableSelect = await screen.findByLabelText('Formuliervariabele'); + const variableSelect = await canvas.findByLabelText('Formuliervariabele'); expect(variableSelect).toBeVisible(); expect(modal.getByText('Form.io component')).toBeVisible(); // Wait until the API call to retrieve the prefillAttributes is done await waitFor(async () => { - const prefillPropertySelect = await screen.findByLabelText( + const prefillPropertySelect = await canvas.findByLabelText( 'Selecteer een attribuut uit het objecttype' ); expect(prefillPropertySelect).toBeVisible(); @@ -627,7 +654,30 @@ export const ConfigurePrefillObjectsAPI = { }; export const ConfigurePrefillObjectsAPIWithCopyButton = { - // decorators: [FormDecorator, withReactSelectDecorator], + parameters: { + msw: { + handlers: { + objectTypeTargetPaths: [ + mockTargetPathsPost({ + number: [ + { + targetPath: ['bsn'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + string: [ + { + targetPath: ['path', 'to', 'bsn'], + isRequired: true, + jsonSchema: {type: 'string'}, + }, + ], + }), + ], + }, + }, + }, args: { registrationBackends: [ { @@ -639,6 +689,7 @@ export const ConfigurePrefillObjectsAPIWithCopyButton = { objectsApiGroup: 1, objecttype: '2c77babf-a967-4057-9969-0200320d23f1', objecttypeVersion: 2, + authAttributePath: ['path', 'to', 'bsn'], variablesMapping: [ { variableKey: 'formioComponent', @@ -681,12 +732,12 @@ export const ConfigurePrefillObjectsAPIWithCopyButton = { // open modal for configuration const editIcon = canvas.getByTitle('Prefill instellen'); await userEvent.click(editIcon); - expect(await screen.findByRole('dialog')).toBeVisible(); + expect(await canvas.findByRole('dialog')).toBeVisible(); }); await step('Configure Objects API prefill with copy button', async () => { - const modal = within(await screen.findByRole('dialog')); - const pluginDropdown = await screen.findByLabelText('Plugin'); + const modal = within(await canvas.findByRole('dialog')); + const pluginDropdown = await canvas.findByLabelText('Plugin'); expect(pluginDropdown).toBeVisible(); await userEvent.selectOptions(pluginDropdown, 'Objects API'); @@ -696,45 +747,42 @@ export const ConfigurePrefillObjectsAPIWithCopyButton = { expect(toggleCopyDropdown).toBeVisible(); await userEvent.click(toggleCopyDropdown); + const copyButton = await canvas.findByRole('button', {name: 'Overnemen'}); + expect(copyButton).toBeDisabled(); const copyDropdown = await modal.findByLabelText('Registratie-instellingen overnemen'); expect(copyDropdown).toBeVisible(); - await userEvent.click(copyDropdown); - - // Cannot do selectOption with react-select - const options = await canvas.findAllByText('Example Objects API reg.'); - const option = options[1]; - await userEvent.click(option); + await rsSelect(copyDropdown, 'Example Objects API reg.'); - const copyButton = await canvas.findByRole('button', {name: 'Overnemen'}); expect(copyButton).toBeVisible(); + expect(copyButton).not.toBeDisabled(); await userEvent.click(copyButton); // Click the confirmation button - const button = canvas.getByRole('button', { - name: 'Accepteren', - }); - expect(button).toBeVisible(); - await userEvent.click(button); + const confirmationButton = await canvas.findByRole('button', {name: 'Accepteren'}); + expect(confirmationButton).toBeVisible(); + await userEvent.click(confirmationButton); - const modalForm = await screen.findByTestId('modal-form'); + const modalForm = await canvas.findByTestId('modal-form'); expect(modalForm).toBeVisible(); const propertyDropdowns = await modal.findAllByLabelText( 'Selecteer een attribuut uit het objecttype' ); // Wait until the API call to retrieve the prefillAttributes is done + await modal.findByText('path > to > bsn', undefined, {timeout: 2000}); + await waitFor( - async () => { + () => { expect(modalForm).toHaveFormValues({ 'options.objectsApiGroup': '1', 'options.objecttypeUuid': '2c77babf-a967-4057-9969-0200320d23f1', 'options.objecttypeVersion': '2', + 'options.authAttributePath': JSON.stringify(['path', 'to', 'bsn']), + 'options.variablesMapping.0.targetPath': serializeValue(['height']), + 'options.variablesMapping.1.targetPath': serializeValue(['species']), }); - - expect(propertyDropdowns[0]).toHaveValue(serializeValue(['height'])); - expect(propertyDropdowns[1]).toHaveValue(serializeValue(['species'])); }, - {timeout: 2000} + {timeout: 5000} ); }); }, @@ -795,6 +843,72 @@ export const WithValidationErrors = { }, }; +export const ConfigurePrefillObjectsAPIWithValidationErrors = { + args: { + variables: [ + { + form: 'http://localhost:8000/api/v2/forms/36612390', + formDefinition: undefined, + name: 'User defined', + key: 'userDefined', + source: 'user_defined', + prefillPlugin: 'objects_api', + prefillAttribute: '', + prefillIdentifierRole: '', + dataType: 'string', + dataFormat: undefined, + isSensitiveData: false, + serviceFetchConfiguration: undefined, + initialValue: [], + options: { + objectsApiGroup: 1, + objecttype: '2c77babf-a967-4057-9969-0200320d23f1', + objecttypeVersion: 2, + authAttributePath: ['path', 'to', 'bsn'], + variablesMapping: [ + { + variableKey: 'formioComponent', + targetPath: ['height'], + }, + { + variableKey: 'userDefined', + targetPath: ['species'], + }, + ], + }, + errors: { + prefillPlugin: 'Computer says no.', + prefillOptions: { + objectsApiGroup: 'Computer says no.', + objecttypeUuid: 'Computer says no.', + objecttypeVersion: 'Computer says no.', + authAttributePath: 'This list may not be empty.', + }, + }, + }, + ], + }, + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Open configuration modal', async () => { + const userDefinedVarsTab = await canvas.findByRole('tab', {name: 'Gebruikersvariabelen'}); + expect(userDefinedVarsTab).toBeVisible(); + await userEvent.click(userDefinedVarsTab); + + // open modal for configuration + const editIcon = canvas.getByTitle('Prefill instellen'); + await userEvent.click(editIcon); + expect(await canvas.findByRole('dialog')).toBeVisible(); + }); + + await step('Verify that error is shown', async () => { + const error = canvas.getByText('This list may not be empty.'); + expect(error).toBeVisible(); + }); + }, +}; + export const AddressNLMappingSpecificTargetsNoDeriveAddress = { args: { variables: [ @@ -876,49 +990,49 @@ export const AddressNLMappingSpecificTargetsNoDeriveAddress = { expect(modalForm).toHaveFormValues({}); const modal = within(modalForm); - await step('Object target paths', async () => { - const targetPathDropdown = modal.getByRole('combobox'); - expect(targetPathDropdown).toBeVisible(); + const targetPathDropdown = modal.getByRole('combobox', {name: 'JSON Schema van doelobject'}); + expect(targetPathDropdown).toBeVisible(); - await modal.findByRole('option', {name: 'other > path'}); + await step('Map entire object', async () => { + selectEvent.openMenu(targetPathDropdown); - // Now retrieve all options after '[other,path]' option has been loaded - const updatedOptions = within(targetPathDropdown).getAllByRole('option'); + const targetSelectMenu = within(await findReactSelectMenu(canvas)); + await targetSelectMenu.findByRole('option', {name: 'other > path'}); + expect(targetSelectMenu.getAllByRole('option')).toHaveLength(1); - expect(updatedOptions).toHaveLength(2); - expect(updatedOptions[0]).toHaveTextContent('-----'); - expect(updatedOptions[1]).toHaveTextContent('other > path'); - - await userEvent.selectOptions(targetPathDropdown, '["other","path"]'); + await rsSelect(targetPathDropdown, 'other > path'); }); - await step('String target paths', async () => { - const targetPathDropdown = modal.getByRole('combobox'); - await userEvent.selectOptions(targetPathDropdown, ''); + await step('Map specific subfields', async () => { + await selectEvent.clearAll(targetPathDropdown); - const specificTargetsCheckbox = await canvas.findByRole('checkbox', { + const specificTargetsCheckbox = canvas.getByRole('checkbox', { name: 'Koppel individuele velden', }); - userEvent.click(specificTargetsCheckbox); + await userEvent.click(specificTargetsCheckbox); const postcodeSelect = await canvas.findByLabelText('Bestemmingspad postcode'); + expect(postcodeSelect).toBeVisible(); + const houseNumberSelect = await canvas.findByLabelText('Bestemmingspad huisnummer'); + expect(houseNumberSelect).toBeVisible(); + const houseLetterSelect = await canvas.findByLabelText('Bestemmingspad huisletter'); + expect(houseLetterSelect).toBeVisible(); + const houseNumberAdditionSelect = await canvas.findByLabelText( 'Bestemmingspad huisnummertoevoeging' ); - const citySelect = await canvas.findByLabelText('Bestemmingspad stad/gemeente'); - const streetNameSelect = await canvas.findByLabelText('Bestemmingspad straatnaam'); - - expect(postcodeSelect).toBeVisible(); - expect(houseNumberSelect).toBeVisible(); - expect(houseLetterSelect).toBeVisible(); expect(houseNumberAdditionSelect).toBeVisible(); - expect(citySelect).toBeVisible(); - expect(streetNameSelect).toBeVisible(); + const citySelect = await canvas.findByLabelText('Bestemmingspad stad/gemeente'); expect(citySelect).toBeDisabled(); + + const streetNameSelect = await canvas.findByLabelText('Bestemmingspad straatnaam'); expect(streetNameSelect).toBeDisabled(); + + await rsSelect(postcodeSelect, 'path > to.the > target (verplicht)'); + await rsSelect(houseNumberSelect, 'number > target (verplicht)'); }); }, }; @@ -1004,31 +1118,34 @@ export const AddressNLMappingSpecificTargetsDeriveAddress = { const modalForm = await canvas.findByTestId('modal-form'); const modal = within(modalForm); - const targetPathDropdown = modal.getByRole('combobox'); - await userEvent.selectOptions(targetPathDropdown, ''); + const targetPathDropdown = modal.getByRole('combobox', {name: 'JSON Schema van doelobject'}); + expect(targetPathDropdown).toBeVisible(); - const specificTargetsCheckbox = await canvas.findByRole('checkbox', { + const specificTargetsCheckbox = canvas.getByRole('checkbox', { name: 'Koppel individuele velden', }); await userEvent.click(specificTargetsCheckbox); const postcodeSelect = await canvas.findByLabelText('Bestemmingspad postcode'); + expect(postcodeSelect).toBeVisible(); + const houseNumberSelect = await canvas.findByLabelText('Bestemmingspad huisnummer'); + expect(houseNumberSelect).toBeVisible(); + const houseLetterSelect = await canvas.findByLabelText('Bestemmingspad huisletter'); + expect(houseLetterSelect).toBeVisible(); + const houseNumberAdditionSelect = await canvas.findByLabelText( 'Bestemmingspad huisnummertoevoeging' ); - const citySelect = await canvas.findByLabelText('Bestemmingspad stad/gemeente'); - const streetNameSelect = await canvas.findByLabelText('Bestemmingspad straatnaam'); - - expect(postcodeSelect).toBeVisible(); - expect(houseNumberSelect).toBeVisible(); - expect(houseLetterSelect).toBeVisible(); expect(houseNumberAdditionSelect).toBeVisible(); - expect(citySelect).toBeVisible(); - expect(streetNameSelect).toBeVisible(); + const citySelect = await canvas.findByLabelText('Bestemmingspad stad/gemeente'); + expect(citySelect).toBeVisible(); expect(citySelect).not.toBeDisabled(); + + const streetNameSelect = await canvas.findByLabelText('Bestemmingspad straatnaam'); + expect(streetNameSelect).toBeVisible(); expect(streetNameSelect).not.toBeDisabled(); }, }; diff --git a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillConfigurationForm.js b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillConfigurationForm.js index 3a25d33491..1fe974a474 100644 --- a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillConfigurationForm.js +++ b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillConfigurationForm.js @@ -8,20 +8,36 @@ import Field from 'components/admin/forms/Field'; import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; import SubmitRow from 'components/admin/forms/SubmitRow'; +import {ValidationErrorsProvider} from 'components/admin/forms/ValidationErrors'; import PluginField from './PluginField'; import PLUGIN_COMPONENT_MAPPING from './constants'; +const prepareErrors = errors => { + const allErrors = []; + Object.entries(errors).forEach(([key, errObj]) => { + if (!errObj) return; + if (Array.isArray(errObj)) { + allErrors.push(...errObj.map(err => [key, err])); + } else { + // FIXME: this violates the prop type of ValidationErrorsProvider :/ + allErrors.push([key, errObj]); + } + }); + return allErrors; +}; + const PrefillConfigurationForm = ({ onSubmit, plugin = '', attribute = '', identifierRole = 'main', - // TODO: find a better way to specify this based on the selected plugin + // Plugins are responsible for setting up the default values, since we don't know the + // plugin-specific shape here. options = {}, errors, -}) => { - return ( +}) => ( + } - errors={errors.plugin} > <> @@ -76,7 +91,6 @@ const PrefillConfigurationForm = ({ - ); -}; + +); PrefillConfigurationForm.propTypes = { onSubmit: PropTypes.func.isRequired, @@ -107,6 +121,7 @@ PrefillConfigurationForm.propTypes = { plugin: PropTypes.arrayOf(PropTypes.string), attribute: PropTypes.arrayOf(PropTypes.string), identifierRole: PropTypes.arrayOf(PropTypes.string), + options: PropTypes.object, }).isRequired, }; diff --git a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js index 72b59b3ed5..a6265f56b4 100644 --- a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js +++ b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js @@ -10,6 +10,11 @@ import ErrorBoundary from 'components/errors/ErrorBoundary'; import {IDENTIFIER_ROLE_CHOICES} from '../constants'; import PrefillConfigurationForm from './PrefillConfigurationForm'; +function isTruthy(value) { + if (!value) return false; + return Object.keys(value).length > 0; +} + const PrefillSummary = ({ plugin = '', attribute = '', @@ -29,7 +34,11 @@ const PrefillSummary = ({ intl ); - const hasErrors = hasPluginErrors || hasAttributeErrors || hasIdentifierRoleErrors; + const hasErrors = + hasPluginErrors || + hasAttributeErrors || + hasIdentifierRoleErrors || + isTruthy(errors.prefillOptions); const icons = (
@@ -100,6 +109,9 @@ const PrefillSummary = ({ plugin: pluginErrors, attribute: attributeErrors, identifierRole: identifierRoleErrors, + // Directly pass these without normalizing, because the shape + // depends on the plugin that is used + options: errors.prefillOptions, }} /> diff --git a/src/openforms/js/components/admin/form_design/variables/prefill/default/DefaultFields.js b/src/openforms/js/components/admin/form_design/variables/prefill/default/DefaultFields.js index 0d1c80d5dc..4efaf73818 100644 --- a/src/openforms/js/components/admin/form_design/variables/prefill/default/DefaultFields.js +++ b/src/openforms/js/components/admin/form_design/variables/prefill/default/DefaultFields.js @@ -29,7 +29,7 @@ const getAttributes = async plugin => { * Default (legacy) prefill configuration - after selecting the plugin, the user * selects which attribute to use to grab the prefill value from. */ -const DefaultFields = ({errors}) => { +const DefaultFields = () => { const { values: {plugin = ''}, } = useFormikContext(); @@ -60,7 +60,6 @@ const DefaultFields = ({errors}) => { defaultMessage="Attribute" /> } - errors={errors.attribute} > @@ -75,7 +74,6 @@ const DefaultFields = ({errors}) => { defaultMessage="Identifier role" /> } - errors={errors.identifierRole} > @@ -84,12 +82,6 @@ const DefaultFields = ({errors}) => { ); }; -DefaultFields.propTypes = { - errors: PropTypes.shape({ - plugin: ErrorsType, - attribute: ErrorsType, - identifierRole: ErrorsType, - }).isRequired, -}; +DefaultFields.propTypes = {}; export default DefaultFields; diff --git a/src/openforms/js/components/admin/form_design/variables/prefill/objects_api/CopyConfigurationFromRegistrationBackend.js b/src/openforms/js/components/admin/form_design/variables/prefill/objects_api/CopyConfigurationFromRegistrationBackend.js index 1292b6aba4..156a0a40a3 100644 --- a/src/openforms/js/components/admin/form_design/variables/prefill/objects_api/CopyConfigurationFromRegistrationBackend.js +++ b/src/openforms/js/components/admin/form_design/variables/prefill/objects_api/CopyConfigurationFromRegistrationBackend.js @@ -49,7 +49,6 @@ const CopyConfigurationFromRegistrationBackend = ({backends, setShowCopyButton}) setFieldValue(name, selectedOption.value); }} maxMenuHeight="16em" - menuPlacement="bottom" />