From 4cdf6f7068ed66ba1f06f964282fe9528175d1f5 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 11 Dec 2024 16:08:32 +0100 Subject: [PATCH 01/12] implement holder and contact data in HTTP POST to relations --- .../hsadminng/errors/Validate.java | 10 ++- .../HsOfficeMembershipController.java | 2 +- .../relation/HsOfficeRelationController.java | 58 +++++++++++++----- .../hs-office/hs-office-relation-schemas.yaml | 16 +++-- .../hsadminng/errors/ValidateUnitTest.java | 61 ++++++++++++++----- ...fficeRelationControllerAcceptanceTest.java | 61 ++++++++++++++++++- 6 files changed, 169 insertions(+), 39 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/errors/Validate.java b/src/main/java/net/hostsharing/hsadminng/errors/Validate.java index 3ce68fe9..73f02e89 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/Validate.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/Validate.java @@ -13,8 +13,16 @@ public class Validate { return new Validate(variableNames); } - public final void atMaxOneNonNull(final Object var1, final Object var2) { + public final void atMaxOne(final Object var1, final Object var2) { if (var1 != null && var2 != null) { + throw new ValidationException( + "At maximum one of (" + variableNames + ") must be non-null, " + + "but are (" + var1 + ", " + var2 + ")"); + } + } + + public final void exactlyOne(final Object var1, final Object var2) { + if ((var1 != null) == (var2 != null)) { throw new ValidationException( "Exactly one of (" + variableNames + ") must be non-null, " + "but are (" + var1 + ", " + var2 + ")"); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java index 84994ceb..43cc292d 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -43,7 +43,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi { final String partnerNumber) { context.define(currentSubject, assumedRoles); - validate("partnerUuid, partnerNumber").atMaxOneNonNull(partnerUuid, partnerNumber); + validate("partnerUuid, partnerNumber").atMaxOne(partnerUuid, partnerNumber); final var entities = partnerNumber != null ? membershipRepo.findMembershipsByPartnerNumber( diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java index d543e81e..97e7dac0 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -2,9 +2,12 @@ package net.hostsharing.hsadminng.hs.office.relation; import io.micrometer.core.annotation.Timed; import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.errors.Validate; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.mapper.StandardMapper; import org.springframework.beans.factory.annotation.Autowired; @@ -20,6 +23,7 @@ import java.util.NoSuchElementException; import java.util.UUID; import java.util.function.BiConsumer; +import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; @RestController public class HsOfficeRelationController implements HsOfficeRelationsApi { @@ -31,10 +35,10 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { private StandardMapper mapper; @Autowired - private HsOfficeRelationRbacRepository relationRbacRepo; + private HsOfficeRelationRbacRepository rbacRelationRepo; @Autowired - private HsOfficePersonRealRepository personRepo; + private HsOfficePersonRealRepository realPersonRepo; @Autowired private HsOfficeContactRealRepository realContactRepo; @@ -55,7 +59,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); final List entities = - relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( + rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( personUuid, relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()), personData, contactData); @@ -78,17 +82,34 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { final var entityToSave = new HsOfficeRelationRbacEntity(); entityToSave.setType(HsOfficeRelationType.valueOf(body.getType())); entityToSave.setMark(body.getMark()); - entityToSave.setAnchor(personRepo.findByUuid(body.getAnchorUuid()).orElseThrow( + + entityToSave.setAnchor(realPersonRepo.findByUuid(body.getAnchorUuid()).orElseThrow( () -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid()) )); - entityToSave.setHolder(personRepo.findByUuid(body.getHolderUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) - )); - entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) - )); - final var saved = relationRbacRepo.save(entityToSave); + Validate.validate("anchor, anchor.uuid").exactlyOne(body.getHolder(), body.getHolderUuid()); + if ( body.getHolderUuid() != null) { + entityToSave.setHolder(realPersonRepo.findByUuid(body.getHolderUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) + )); + } else { + entityToSave.setHolder(realPersonRepo.save( + mapper.map(body.getHolder(), HsOfficePersonRealEntity.class) + ) ); + } + + Validate.validate("contact, contact.uuid").exactlyOne(body.getContact(), body.getContactUuid()); + if ( body.getContactUuid() != null) { + entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) + )); + } else { + entityToSave.setContact(realContactRepo.save( + mapper.map(body.getContact(), HsOfficeContactRealEntity.class, CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER) + ) ); + } + + final var saved = rbacRelationRepo.save(entityToSave); final var uri = MvcUriComponentsBuilder.fromController(getClass()) @@ -110,7 +131,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); - final var result = relationRbacRepo.findByUuid(relationUuid); + final var result = rbacRelationRepo.findByUuid(relationUuid); if (result.isEmpty()) { return ResponseEntity.notFound().build(); } @@ -126,7 +147,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { final UUID relationUuid) { context.define(currentSubject, assumedRoles); - final var result = relationRbacRepo.deleteByUuid(relationUuid); + final var result = rbacRelationRepo.deleteByUuid(relationUuid); if (result == 0) { return ResponseEntity.notFound().build(); } @@ -145,11 +166,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); - final var current = relationRbacRepo.findByUuid(relationUuid).orElseThrow(); + final var current = rbacRelationRepo.findByUuid(relationUuid).orElseThrow(); new HsOfficeRelationEntityPatcher(em, current).apply(body); - final var saved = relationRbacRepo.save(current); + final var saved = rbacRelationRepo.save(current); final var mapped = mapper.map(saved, HsOfficeRelationResource.class); return ResponseEntity.ok(mapped); } @@ -159,4 +180,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class)); resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); }; + + + @SuppressWarnings("unchecked") + final BiConsumer CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.putEmailAddresses(from(resource.getEmailAddresses())); + entity.putPhoneNumbers(from(resource.getPhoneNumbers())); + }; } diff --git a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml index 8cf1f03f..9b8d2e46 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml @@ -52,6 +52,8 @@ components: holder.uuid: type: string format: uuid + holder: + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' type: type: string nullable: true @@ -61,11 +63,17 @@ components: contact.uuid: type: string format: uuid + contact: + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert' required: - - anchor.uuid - - holder.uuid - - type - - contact.uuid + - anchor.uuid + - type + # soon we might need to be able to use this: + # https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410 + # For now we just describe the conditionally required properties: + description: + Additionally to `type` and `anchor.uuid`, either `anchor.uuid` or `anchor` + and either `contact` or `contact.uuid` need to be given. # relation created as a sub-element with implicitly known type HsOfficeRelationSubInsert: diff --git a/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java b/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java index 0d212c90..97e73ec1 100644 --- a/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.errors; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import jakarta.validation.ValidationException; @@ -9,23 +10,53 @@ import static org.assertj.core.api.Assertions.catchThrowable; class ValidateUnitTest { - @Test - void shouldFailValidationIfBothParametersAreNotNull() { - final var throwable = catchThrowable(() -> - Validate.validate("var1, var2").atMaxOneNonNull("val1", "val2") - ); - assertThat(throwable).isInstanceOf(ValidationException.class) - .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); + @Nested + class AtMaxOne { + @Test + void shouldFailValidationIfBothParametersAreNotNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").atMaxOne("val1", "val2") + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("At maximum one of (var1, var2) must be non-null, but are (val1, val2)"); + } + + @Test + void shouldNotFailValidationIfBothParametersAreNull() { + Validate.validate("var1, var2").atMaxOne(null, null); + } + + @Test + void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { + Validate.validate("var1, var2").atMaxOne("val1", null); + Validate.validate("var1, var2").atMaxOne(null, "val2"); + } } - @Test - void shouldNotFailValidationIfBothParametersAreull() { - Validate.validate("var1, var2").atMaxOneNonNull(null, null); - } + @Nested + class ExactlyOne { + @Test + void shouldFailValidationIfBothParametersAreNotNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").exactlyOne("val1", "val2") + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); + } - @Test - void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { - Validate.validate("var1, var2").atMaxOneNonNull("val1", null); - Validate.validate("var1, var2").atMaxOneNonNull(null, "val2"); + @Test + void shouldFailValidationIfBothParametersAreNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").exactlyOne(null, null) + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("Exactly one of (var1, var2) must be non-null, but are (null, null)"); + } + + @Test + void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { + Validate.validate("var1, var2").exactlyOne("val1", null); + Validate.validate("var1, var2").exactlyOne(null, "val2"); + } } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java index 53e1f591..d79ddbfa 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java @@ -223,7 +223,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean class AddRelation { @Test - void globalAdmin_withoutAssumedRole_canAddRelation() { + void globalAdmin_withoutAssumedRole_canAddRelationWithHolderUuidAndContactUuid() { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); @@ -261,7 +261,62 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean .body("holder.givenName", is("Paul")) .body("contact.caption", is("second contact")) .header("Location", startsWith("http://localhost")) - .extract().header("Location"); // @formatter:on + .extract().header("Location"); // @formatter:on + + // finally, the new relation can be accessed under the generated UUID + final var newSubjectUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString( + location.substring(location.lastIndexOf('/') + 1))); + assertThat(newSubjectUuid).isNotNull(); + } + + @Test + void globalAdmin_withoutAssumedRole_canAddRelationWithHolderAndContactData() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-subject", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "type": "%s", + "mark": "%s", + "anchor.uuid": "%s", + "holder": { + "personType": "NATURAL_PERSON", + "familyName": "Person", + "givenName": "Temp" + }, + "contact": { + "caption": "Temp Contact", + "emailAddresses": { + "main": "test@example.org" + } + } + } + """.formatted( + HsOfficeRelationTypeResource.SUBSCRIBER, + "operations-discuss", + givenAnchorPerson.getUuid() + ) + ) + .port(port) + .when() + .post("http://localhost/api/hs/office/relations") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("type", is("SUBSCRIBER")) + .body("mark", is("operations-discuss")) + .body("anchor.tradeName", is("Third OHG")) + .body("holder.givenName", is("Temp")) + .body("holder.familyName", is("Person")) + .body("contact.caption", is("Temp Contact")) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on // finally, the new relation can be accessed under the generated UUID final var newSubjectUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString( @@ -277,7 +332,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-subject", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) -- 2.39.5 From 599e165d0057a059f34ad942beaa38f0e4695257 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 12 Dec 2024 09:00:48 +0100 Subject: [PATCH 02/12] amend SubscribeToMailinglist to SubscribeNewPersonAndContactToMailinglist without intermediate steps --- .../scenarios/HsOfficeScenarioTests.java | 4 +- ...cribeNewPersonAndContactToMailinglist.java | 46 ++++++++++++++ .../subscription/SubscribeToMailinglist.java | 62 ------------------- 3 files changed, 48 insertions(+), 64 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeNewPersonAndContactToMailinglist.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java index 9f957472..c7a2236a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java @@ -31,7 +31,7 @@ import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner; import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner; import net.hostsharing.hsadminng.hs.office.scenarios.person.ShouldUpdatePersonData; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner; -import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeToMailinglist; +import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeNewPersonAndContactToMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist; import net.hostsharing.hsadminng.hs.scenarios.Produces; import net.hostsharing.hsadminng.hs.scenarios.Requires; @@ -564,7 +564,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Requires("Person: Test AG") @Produces("Subscription: Michael Miller to operations-announce") void shouldSubscribeNewPersonAndContactToMailinglist() { - new SubscribeToMailinglist(scenarioTest) + new SubscribeNewPersonAndContactToMailinglist(scenarioTest) // TODO.spec: do we need the personType? or is an operational contact always a natural person? what about distribution lists? .given("partnerPersonTradeName", "Test AG") .given("subscriberFamilyName", "Miller") diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeNewPersonAndContactToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeNewPersonAndContactToMailinglist.java new file mode 100644 index 00000000..5638e440 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeNewPersonAndContactToMailinglist.java @@ -0,0 +1,46 @@ +package net.hostsharing.hsadminng.hs.office.scenarios.subscription; + +import net.hostsharing.hsadminng.hs.scenarios.UseCase; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; + +import static io.restassured.http.ContentType.JSON; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +public class SubscribeNewPersonAndContactToMailinglist extends UseCase { + + public SubscribeNewPersonAndContactToMailinglist(final ScenarioTest testSuite) { + super(testSuite); + } + + @Override + protected HttpResponse run() { + + obtain("Person: %{partnerPersonTradeName}", () -> + httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one." + ); + + return httpPost("/api/hs/office/relations", usingJsonBody(""" + { + "type": "SUBSCRIBER", + "mark": ${mailingList}, + "anchor.uuid": ${Person: %{partnerPersonTradeName}}, + "holder": { + "personType": "NATURAL_PERSON", + "familyName": ${subscriberFamilyName}, + "givenName": ${subscriberGivenName} + }, + "contact": { + "caption": "%{subscriberGivenName} %{subscriberFamilyName}", + "emailAddresses": { + "main": ${subscriberEMailAddress} + } + } + } + """)) + .expecting(CREATED).expecting(JSON); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java deleted file mode 100644 index c524fb18..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java +++ /dev/null @@ -1,62 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.scenarios.subscription; - -import io.restassured.http.ContentType; -import net.hostsharing.hsadminng.hs.scenarios.UseCase; -import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; -import org.springframework.http.HttpStatus; - -import static io.restassured.http.ContentType.JSON; -import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.http.HttpStatus.OK; - -public class SubscribeToMailinglist extends UseCase { - - public SubscribeToMailinglist(final ScenarioTest testSuite) { - super(testSuite); - } - - @Override - protected HttpResponse run() { - - obtain("Person: %{partnerPersonTradeName}", () -> - httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) - .expecting(OK).expecting(JSON), - response -> response.expectArrayElements(1).getFromBody("[0].uuid"), - "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one." - ); - - obtain("Person: %{subscriberGivenName} %{subscriberFamilyName}", () -> - httpPost("/api/hs/office/persons", usingJsonBody(""" - { - "personType": "NATURAL_PERSON", - "familyName": ${subscriberFamilyName}, - "givenName": ${subscriberGivenName} - } - """)) - .expecting(HttpStatus.CREATED).expecting(ContentType.JSON) - ); - - obtain("Contact: %{subscriberGivenName} %{subscriberFamilyName}", () -> - httpPost("/api/hs/office/contacts", usingJsonBody(""" - { - "caption": "%{subscriberGivenName} %{subscriberFamilyName}", - "emailAddresses": { - "main": ${subscriberEMailAddress} - } - } - """)) - .expecting(CREATED).expecting(JSON) - ); - - return httpPost("/api/hs/office/relations", usingJsonBody(""" - { - "type": "SUBSCRIBER", - "mark": ${mailingList}, - "anchor.uuid": ${Person: %{partnerPersonTradeName}}, - "holder.uuid": ${Person: %{subscriberGivenName} %{subscriberFamilyName}}, - "contact.uuid": ${Contact: %{subscriberGivenName} %{subscriberFamilyName}} - } - """)) - .expecting(CREATED).expecting(JSON); - } -} -- 2.39.5 From 5bd635a4cd2007567979ca2d9b291fe2c9788028 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 12 Dec 2024 14:04:32 +0100 Subject: [PATCH 03/12] implement query for relation mark --- .../contact/HsOfficeContactController.java | 9 +++- .../HsOfficeContactRbacRepository.java | 11 ++++ .../person/HsOfficePersonController.java | 4 +- .../relation/HsOfficeRelationController.java | 5 +- .../HsOfficeRelationRbacRepository.java | 15 ++++-- .../hs-office/hs-office-contacts.yaml | 10 +++- .../hs-office/hs-office-relations.yaml | 6 +++ ...eContactRbacRepositoryIntegrationTest.java | 18 ++++++- ...ficeRelationRepositoryIntegrationTest.java | 2 +- .../scenarios/HsOfficeScenarioTests.java | 18 ++++++- ...ExistingPersonAndContactToMailinglist.java | 54 +++++++++++++++++++ 11 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index fe08bd4f..64915d31 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; +import static net.hostsharing.hsadminng.errors.Validate.validate; import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; @RestController @@ -38,10 +39,14 @@ public class HsOfficeContactController implements HsOfficeContactsApi { public ResponseEntity> getListOfContacts( final String currentSubject, final String assumedRoles, - final String caption) { + final String caption, + final String emailAddress) { context.define(currentSubject, assumedRoles); - final var entities = contactRepo.findContactByOptionalCaptionLike(caption); + validate("caption, emailAddress").atMaxOne(caption, emailAddress); + final var entities = caption != null + ? contactRepo.findContactByOptionalCaptionLike(caption) + : contactRepo.findContactByEmailAddress(emailAddress); final var resources = mapper.mapList(entities, HsOfficeContactResource.class); return ResponseEntity.ok(resources); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index f3a88bc0..8f304ce8 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -21,6 +21,17 @@ public interface HsOfficeContactRbacRepository extends Repository findContactByOptionalCaptionLike(String caption); + @Query(value = """ + SELECT c.* FROM hs_office.contact_rv c + WHERE jsonb_path_exists(c.emailaddresses, cast(:emailAddressExpression as jsonpath)) + """, nativeQuery = true) + @Timed("app.office.contacts.repo.findContactByEmailAddressImpl.rbac") + List findContactByEmailAddressImpl(final String emailAddressExpression); + + default List findContactByEmailAddress(final String emailAddress) { + return findContactByEmailAddressImpl("$.** ? (@ == \"" + emailAddress + "\")"); + } + @Timed("app.office.contacts.repo.save.rbac") HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java index 3c39c616..02012820 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java @@ -35,10 +35,10 @@ public class HsOfficePersonController implements HsOfficePersonsApi { public ResponseEntity> getListOfPersons( final String currentSubject, final String assumedRoles, - final String caption) { + final String name) { context.define(currentSubject, assumedRoles); - final var entities = personRepo.findPersonByOptionalNameLike(caption); + final var entities = personRepo.findPersonByOptionalNameLike(name); final var resources = mapper.mapList(entities, HsOfficePersonResource.class); return ResponseEntity.ok(resources); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java index 97e7dac0..a4f17edf 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -54,15 +54,16 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { final String assumedRoles, final UUID personUuid, final HsOfficeRelationTypeResource relationType, + final String mark, final String personData, final String contactData) { context.define(currentSubject, assumedRoles); final List entities = - rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( + rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData( personUuid, relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()), - personData, contactData); + mark, personData, contactData); final var resources = mapper.mapList(entities, HsOfficeRelationResource.class, RELATION_ENTITY_TO_RESOURCE_POSTMAPPER); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java index 0443bc00..0353f02a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java @@ -26,17 +26,20 @@ public interface HsOfficeRelationRbacRepository extends Repository findRelationRelatedToPersonUuidRelationTypePersonAndContactData( + default List findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData( final UUID personUuid, final HsOfficeRelationType relationType, + final String mark, final String personData, final String contactData) { - return findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl( - personUuid, toStringOrNull(relationType), toSqlLikeOperand(personData), toSqlLikeOperand(contactData)); + return findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl( + personUuid, toStringOrNull(relationType), + toSqlLikeOperand(mark), toSqlLikeOperand(personData), toSqlLikeOperand(contactData)); } @Query(value = """ @@ -44,6 +47,7 @@ public interface HsOfficeRelationRbacRepository extends Repository findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl( + @Timed("app.office.relations.repo.findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl.rbac") + List findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl( final UUID personUuid, final String relationType, + final String mark, final String personData, final String contactData); diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml index f7412ddf..4694660b 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml @@ -7,12 +7,18 @@ get: parameters: - $ref: 'auth.yaml#/components/parameters/currentSubject' - $ref: 'auth.yaml#/components/parameters/assumedRoles' - - name: name + - name: caption in: query required: false schema: type: string - description: Prefix of caption to filter the results. + description: Beginning of caption to filter the results. + - name: emailAddress + in: query + required: false + schema: + type: string + description: Beginning of email-address to filter the results. responses: "200": description: OK diff --git a/src/main/resources/api-definition/hs-office/hs-office-relations.yaml b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml index 70218c00..35619f07 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relations.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml @@ -22,6 +22,12 @@ get: schema: $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationType' description: Prefix of name properties from holder or contact to filter the results. + - name: mark + in: query + required: false + schema: + type: string + description: - name: personData in: query required: false diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java index b05b6da7..379c8137 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java @@ -127,7 +127,7 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC } @Nested - class FindAllContacts { + class FindContacts { @Test public void globalAdmin_withoutAssumedRole_canViewAllContacts() { @@ -184,6 +184,22 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC } } + @Nested + class FindByEmailAddress { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllContacts() { + // given + context("superuser-alex@hostsharing.net", null); + + // when + final var result = contactRepo.findContactByEmailAddress("contact-admin@secondcontact.example.com"); + + // then + exactlyTheseContactsAreReturned(result, "second contact"); + } + } + @Nested class DeleteByUuid { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java index b2f50977..e55959c7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java @@ -193,7 +193,7 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea .findFirst().orElseThrow(); // when: - final var result = relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData(person.getUuid(), null, null, null); + final var result = relationRbacRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(person.getUuid(), null, null, null, null); // then: exactlyTheseRelationsAreReturned( diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java index c7a2236a..d23a4250 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java @@ -31,6 +31,7 @@ import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner; import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner; import net.hostsharing.hsadminng.hs.office.scenarios.person.ShouldUpdatePersonData; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner; +import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeExistingPersonAndContactToMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeNewPersonAndContactToMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist; import net.hostsharing.hsadminng.hs.scenarios.Produces; @@ -578,7 +579,22 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(5001) @Requires("Subscription: Michael Miller to operations-announce") - void shouldUnsubscribeNewPersonAndContactToMailinglist() { + @Produces("Subscription: Michael Miller to operations-discussion") + void shouldSubscribeExistingPersonAndContactToMailinglist() { + new SubscribeExistingPersonAndContactToMailinglist(scenarioTest) + .given("partnerPersonTradeName", "Test AG") + .given("subscriberFamilyName", "Miller") + .given("subscriberGivenName", "Michael") + .given("subscriberEMailAddress", "michael.miller@example.org") + .given("mailingList", "operations-discussion") + .doRun() + .keep(); + } + + @Test + @Order(5002) + @Requires("Subscription: Michael Miller to operations-announce") + void shouldUnsubscribePersonAndContactFromMailinglist() { new UnsubscribeFromMailinglist(scenarioTest) .given("mailingList", "operations-announce") .given("subscriberEMailAddress", "michael.miller@example.org") diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java new file mode 100644 index 00000000..f018c454 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java @@ -0,0 +1,54 @@ +package net.hostsharing.hsadminng.hs.office.scenarios.subscription; + +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.scenarios.UseCase; +import org.springframework.http.HttpStatus; + +import static io.restassured.http.ContentType.JSON; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +public class SubscribeExistingPersonAndContactToMailinglist extends UseCase { + + public SubscribeExistingPersonAndContactToMailinglist(final ScenarioTest testSuite) { + super(testSuite); + } + + @Override + protected HttpResponse run() { + + obtain("Person: %{partnerPersonTradeName}", () -> + httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one." + ); + + obtain( + "Person: %{subscriberGivenName} %{subscriberFamilyName}", () -> + httpGet("/api/hs/office/persons?name=%{subscriberFamilyName}") + .expecting(HttpStatus.OK).expecting(ContentType.JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In real scenarios there are most likely multiple results and you have to choose the right one." + ); + + obtain("Contact: %{subscriberEMailAddress}", () -> + httpGet("/api/hs/office/contacts?emailAddress=%{subscriberEMailAddress}") + .expecting(HttpStatus.OK).expecting(ContentType.JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In real scenarios there are most likely multiple results and you have to choose the right one." + ); + + return httpPost("/api/hs/office/relations", usingJsonBody(""" + { + "type": "SUBSCRIBER", + "mark": ${mailingList}, + "anchor.uuid": ${Person: %{partnerPersonTradeName}}, + "holder.uuid": ${Person: %{subscriberGivenName} %{subscriberFamilyName}}, + "contact.uuid": ${Contact: %{subscriberEMailAddress}} + } + """)) + .expecting(CREATED).expecting(JSON); + } +} -- 2.39.5 From ee0e59bd558e3bba4f8bba5c4f00ea8fa83b1152 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 12 Dec 2024 14:50:03 +0100 Subject: [PATCH 04/12] fix getListOfContacts if neither caption nor emailAddress is given --- .../hs/office/contact/HsOfficeContactController.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index 64915d31..b45f070c 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -44,9 +44,10 @@ public class HsOfficeContactController implements HsOfficeContactsApi { context.define(currentSubject, assumedRoles); validate("caption, emailAddress").atMaxOne(caption, emailAddress); - final var entities = caption != null - ? contactRepo.findContactByOptionalCaptionLike(caption) - : contactRepo.findContactByEmailAddress(emailAddress); + final var entities = emailAddress != null + ? contactRepo.findContactByEmailAddress(emailAddress) + : contactRepo.findContactByOptionalCaptionLike(caption); + final var resources = mapper.mapList(entities, HsOfficeContactResource.class); return ResponseEntity.ok(resources); -- 2.39.5 From 025ebac84625a1a54244343edfe7f612a0e1f4a2 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 12 Dec 2024 16:00:03 +0100 Subject: [PATCH 05/12] implement wildcard for contacts emailAddress search --- .../hs/office/contact/HsOfficeContactRbacRepository.java | 5 +++-- .../api-definition/hs-office/hs-office-contacts.yaml | 2 +- .../HsOfficeContactRbacRepositoryIntegrationTest.java | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index 8f304ce8..51c3990e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -4,6 +4,7 @@ import io.micrometer.core.annotation.Timed; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -28,8 +29,8 @@ public interface HsOfficeContactRbacRepository extends Repository findContactByEmailAddressImpl(final String emailAddressExpression); - default List findContactByEmailAddress(final String emailAddress) { - return findContactByEmailAddressImpl("$.** ? (@ == \"" + emailAddress + "\")"); + default List findContactByEmailAddress(@NotNull final String emailAddress) { + return findContactByEmailAddressImpl("$.** ? (@ like_regex \"" + emailAddress.replace("%", ".*") + "\")"); } @Timed("app.office.contacts.repo.save.rbac") diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml index 4694660b..0dff9a63 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml @@ -18,7 +18,7 @@ get: required: false schema: type: string - description: Beginning of email-address to filter the results. + description: Beginning of email-address to filter the results, use '%' as wildcard. responses: "200": description: OK diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java index 379c8137..5b59f3b6 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java @@ -188,12 +188,12 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC class FindByEmailAddress { @Test - public void globalAdmin_withoutAssumedRole_canViewAllContacts() { + public void globalAdmin_withoutAssumedRole_canFindContactsByEmailAddress() { // given context("superuser-alex@hostsharing.net", null); // when - final var result = contactRepo.findContactByEmailAddress("contact-admin@secondcontact.example.com"); + final var result = contactRepo.findContactByEmailAddress("%@secondcontact.example.com"); // then exactlyTheseContactsAreReturned(result, "second contact"); -- 2.39.5 From 804ff2a5ee916602b8158ba00b6a16a70eb6ca37 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 13 Dec 2024 06:14:36 +0100 Subject: [PATCH 06/12] findContactByEmailAddress -> findContactByEmailAddressRegEx with validation --- .../contact/HsOfficeContactController.java | 8 ++--- .../HsOfficeContactRbacRepository.java | 17 ++++++++--- .../hs-office/hs-office-contacts.yaml | 5 ++-- ...eContactRbacRepositoryIntegrationTest.java | 2 +- ...HsOfficeContactRbacRepositoryUnitTest.java | 29 +++++++++++++++++++ 5 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index b45f070c..744a9342 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -40,12 +40,12 @@ public class HsOfficeContactController implements HsOfficeContactsApi { final String currentSubject, final String assumedRoles, final String caption, - final String emailAddress) { + final String emailAddressRegEx) { context.define(currentSubject, assumedRoles); - validate("caption, emailAddress").atMaxOne(caption, emailAddress); - final var entities = emailAddress != null - ? contactRepo.findContactByEmailAddress(emailAddress) + validate("caption, emailAddress").atMaxOne(caption, emailAddressRegEx); + final var entities = emailAddressRegEx != null + ? contactRepo.findContactByEmailAddressRegEx(emailAddressRegEx) : contactRepo.findContactByOptionalCaptionLike(caption); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index 51c3990e..924f3277 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -4,6 +4,7 @@ import io.micrometer.core.annotation.Timed; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import jakarta.validation.ValidationException; import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Optional; @@ -23,14 +24,22 @@ public interface HsOfficeContactRbacRepository extends Repository findContactByOptionalCaptionLike(String caption); @Query(value = """ - SELECT c.* FROM hs_office.contact_rv c - WHERE jsonb_path_exists(c.emailaddresses, cast(:emailAddressExpression as jsonpath)) + select c.* from hs_office.contact_rv c + where jsonb_path_exists(c.emailaddresses, cast(:emailAddressExpression as jsonpath)) """, nativeQuery = true) @Timed("app.office.contacts.repo.findContactByEmailAddressImpl.rbac") List findContactByEmailAddressImpl(final String emailAddressExpression); - default List findContactByEmailAddress(@NotNull final String emailAddress) { - return findContactByEmailAddressImpl("$.** ? (@ like_regex \"" + emailAddress.replace("%", ".*") + "\")"); + default List findContactByEmailAddressRegEx(@NotNull final String emailAddress) { + return findContactByEmailAddressImpl("$.** ? (@ like_regex \"" + emailRegEx(emailAddress) + "\")"); + } + + static String emailRegEx(@NotNull String emailAddress) { + if (emailAddress.contains("'") || emailAddress.endsWith("\\") ) { + throw new ValidationException( + "emailAddress contains invalid characters: " + emailAddress); + } + return emailAddress.replace("%", ".*"); // the JSON-matcher in PostgreSQL needs a wildcard } @Timed("app.office.contacts.repo.save.rbac") diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml index 0dff9a63..022756d8 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml @@ -13,12 +13,13 @@ get: schema: type: string description: Beginning of caption to filter the results. - - name: emailAddress + - name: emailAddressRegEx in: query required: false schema: type: string - description: Beginning of email-address to filter the results, use '%' as wildcard. + description: + Regular-expression for an email-address to filter the results. responses: "200": description: OK diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java index 5b59f3b6..6a7f45e3 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java @@ -193,7 +193,7 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC context("superuser-alex@hostsharing.net", null); // when - final var result = contactRepo.findContactByEmailAddress("%@secondcontact.example.com"); + final var result = contactRepo.findContactByEmailAddressRegEx("@secondcontact.example.com"); // then exactlyTheseContactsAreReturned(result, "second contact"); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java new file mode 100644 index 00000000..d6c6574f --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import org.junit.jupiter.api.Test; + +import jakarta.validation.ValidationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HsOfficeContactRbacRepositoryUnitTest { + + @Test + void rejectsSingleQuoteInEmailAddressRegEx() { + final var throwable = catchThrowable( () -> + HsOfficeContactRbacRepository.emailRegEx("target@'example.org") + ); + + assertThat(throwable).isInstanceOf(ValidationException.class); + } + + @Test + void rejectsTrailingBackslashInEmailAddressRegEx() { + final var throwable = catchThrowable( () -> + HsOfficeContactRbacRepository.emailRegEx("target@example.org\\") + ); + + assertThat(throwable).isInstanceOf(ValidationException.class); + } +} -- 2.39.5 From a0560d2bfd2113353a9b31fd6ef46818f8410c73 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 13 Dec 2024 10:00:24 +0100 Subject: [PATCH 07/12] fix contacts?emailAddress -> contacts?emailAddressRegEx in scenario test --- .../hsadminng/hs/office/contact/HsOfficeContactController.java | 1 - .../SubscribeExistingPersonAndContactToMailinglist.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index 744a9342..548282d3 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -48,7 +48,6 @@ public class HsOfficeContactController implements HsOfficeContactsApi { ? contactRepo.findContactByEmailAddressRegEx(emailAddressRegEx) : contactRepo.findContactByOptionalCaptionLike(caption); - final var resources = mapper.mapList(entities, HsOfficeContactResource.class); return ResponseEntity.ok(resources); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java index f018c454..e3f10185 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java @@ -34,7 +34,7 @@ public class SubscribeExistingPersonAndContactToMailinglist extends UseCase - httpGet("/api/hs/office/contacts?emailAddress=%{subscriberEMailAddress}") + httpGet("/api/hs/office/contacts?emailAddressRegEx=%{subscriberEMailAddress}") .expecting(HttpStatus.OK).expecting(ContentType.JSON), response -> response.expectArrayElements(1).getFromBody("[0].uuid"), "In real scenarios there are most likely multiple results and you have to choose the right one." -- 2.39.5 From 0ebaec6908196071b6709b48dbce83825a740129 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 13 Dec 2024 10:09:58 +0100 Subject: [PATCH 08/12] =?UTF-8?q?TODO=20f=C3=BCr=20potentielle=20SQL-Injec?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hs/office/contact/HsOfficeContactRbacRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index 924f3277..ec2b15d1 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -35,6 +35,7 @@ public interface HsOfficeContactRbacRepository extends Repository Date: Fri, 13 Dec 2024 10:11:00 +0100 Subject: [PATCH 09/12] add " to "emailAddressRegEx validation --- .../hs/office/contact/HsOfficeContactRbacRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index ec2b15d1..1e82a7ac 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -36,9 +36,9 @@ public interface HsOfficeContactRbacRepository extends Repository Date: Fri, 13 Dec 2024 10:19:54 +0100 Subject: [PATCH 10/12] TODO TODO: use ELIKE instead of ... --- .../hs/office/relation/HsOfficeRelationRbacRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java index 0353f02a..82e245ae 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java @@ -42,6 +42,7 @@ public interface HsOfficeRelationRbacRepository extends Repository Date: Fri, 13 Dec 2024 12:34:13 +0100 Subject: [PATCH 11/12] /contacts?emailAddressRegEx -> emailAddress with optional '%' as wildcard --- .../contact/HsOfficeContactController.java | 8 ++--- .../HsOfficeContactRbacRepository.java | 24 ++++----------- .../hs-office/hs-office-contacts.yaml | 4 +-- .../hs-office/hs-office-relations.yaml | 2 +- ...eContactRbacRepositoryIntegrationTest.java | 2 +- ...HsOfficeContactRbacRepositoryUnitTest.java | 29 ------------------- 6 files changed, 14 insertions(+), 55 deletions(-) delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index 548282d3..572a0ce8 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -40,12 +40,12 @@ public class HsOfficeContactController implements HsOfficeContactsApi { final String currentSubject, final String assumedRoles, final String caption, - final String emailAddressRegEx) { + final String emailAddress) { context.define(currentSubject, assumedRoles); - validate("caption, emailAddress").atMaxOne(caption, emailAddressRegEx); - final var entities = emailAddressRegEx != null - ? contactRepo.findContactByEmailAddressRegEx(emailAddressRegEx) + validate("caption, emailAddress").atMaxOne(caption, emailAddress); + final var entities = emailAddress != null + ? contactRepo.findContactByEmailAddress(emailAddress) : contactRepo.findContactByOptionalCaptionLike(caption); final var resources = mapper.mapList(entities, HsOfficeContactResource.class); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java index 1e82a7ac..4576ef58 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -4,8 +4,6 @@ import io.micrometer.core.annotation.Timed; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; -import jakarta.validation.ValidationException; -import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -25,23 +23,13 @@ public interface HsOfficeContactRbacRepository extends Repository findContactByEmailAddressImpl(final String emailAddressExpression); - - default List findContactByEmailAddressRegEx(@NotNull final String emailAddress) { - return findContactByEmailAddressImpl("$.** ? (@ like_regex \"" + emailRegEx(emailAddress) + "\")"); - } - - static String emailRegEx(@NotNull String emailAddress) { - // TODO.impl: find more secure solution, maybe we substitute a placeholder with the whole expression? - if (emailAddress.contains("'") || emailAddress.contains("\"") || emailAddress.endsWith("\\") ) { - throw new ValidationException( - "emailAddressRegEx contains invalid characters: " + emailAddress); - } - return emailAddress.replace("%", ".*"); // the JSON-matcher in PostgreSQL needs a wildcard - } + @Timed("app.office.contacts.repo.findContactByEmailAddress.rbac") + List findContactByEmailAddress(final String emailAddressRegEx); @Timed("app.office.contacts.repo.save.rbac") HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity); diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml index 022756d8..d6ca9db5 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml @@ -13,13 +13,13 @@ get: schema: type: string description: Beginning of caption to filter the results. - - name: emailAddressRegEx + - name: emailAddress in: query required: false schema: type: string description: - Regular-expression for an email-address to filter the results. + Email-address to filter the results, use '%' as wildcard. responses: "200": description: OK diff --git a/src/main/resources/api-definition/hs-office/hs-office-relations.yaml b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml index 35619f07..93d5fdc5 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relations.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml @@ -21,7 +21,7 @@ get: required: false schema: $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationType' - description: Prefix of name properties from holder or contact to filter the results. + description: Beginning of name properties from holder or contact to filter the results. - name: mark in: query required: false diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java index 6a7f45e3..5b59f3b6 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java @@ -193,7 +193,7 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC context("superuser-alex@hostsharing.net", null); // when - final var result = contactRepo.findContactByEmailAddressRegEx("@secondcontact.example.com"); + final var result = contactRepo.findContactByEmailAddress("%@secondcontact.example.com"); // then exactlyTheseContactsAreReturned(result, "second contact"); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java deleted file mode 100644 index d6c6574f..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryUnitTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import org.junit.jupiter.api.Test; - -import jakarta.validation.ValidationException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; - -class HsOfficeContactRbacRepositoryUnitTest { - - @Test - void rejectsSingleQuoteInEmailAddressRegEx() { - final var throwable = catchThrowable( () -> - HsOfficeContactRbacRepository.emailRegEx("target@'example.org") - ); - - assertThat(throwable).isInstanceOf(ValidationException.class); - } - - @Test - void rejectsTrailingBackslashInEmailAddressRegEx() { - final var throwable = catchThrowable( () -> - HsOfficeContactRbacRepository.emailRegEx("target@example.org\\") - ); - - assertThat(throwable).isInstanceOf(ValidationException.class); - } -} -- 2.39.5 From 8f07febcc9ab4e693d44d507a91df3319a143ae9 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 13 Dec 2024 13:32:28 +0100 Subject: [PATCH 12/12] fix ?emailAddressRegEx->?emailAddress in Scenario-test --- .../SubscribeExistingPersonAndContactToMailinglist.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java index e3f10185..f018c454 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeExistingPersonAndContactToMailinglist.java @@ -34,7 +34,7 @@ public class SubscribeExistingPersonAndContactToMailinglist extends UseCase - httpGet("/api/hs/office/contacts?emailAddressRegEx=%{subscriberEMailAddress}") + httpGet("/api/hs/office/contacts?emailAddress=%{subscriberEMailAddress}") .expecting(HttpStatus.OK).expecting(ContentType.JSON), response -> response.expectArrayElements(1).getFromBody("[0].uuid"), "In real scenarios there are most likely multiple results and you have to choose the right one." -- 2.39.5