Merge remote-tracking branch 'origin/master' into feature/add-scenario-test-for-deceased-partner-with-community-of-heirs

# Conflicts:
#	src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java
#	src/main/resources/api-definition/hs-office/hs-office-debitors-with-debitorNumber.yaml
#	src/main/resources/api-definition/hs-office/hs-office-debitors.yaml
#	src/main/resources/api-definition/hs-office/hs-office-memberships.yaml
This commit is contained in:
Michael Hoennig 2024-12-16 12:21:43 +01:00
commit 611e491822
23 changed files with 209 additions and 111 deletions

View File

@ -17,10 +17,11 @@ public class WebSecurityConfig {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/**").permitAll() // TODO.impl: implement authentication
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
)
.build();
}
}

View File

@ -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<List<HsOfficeContactResource>> 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 = emailAddress != null
? contactRepo.findContactByEmailAddress(emailAddress)
: contactRepo.findContactByOptionalCaptionLike(caption);
final var resources = mapper.mapList(entities, HsOfficeContactResource.class);
return ResponseEntity.ok(resources);

View File

@ -21,6 +21,16 @@ public interface HsOfficeContactRbacRepository extends Repository<HsOfficeContac
@Timed("app.office.contacts.repo.findContactByOptionalCaptionLike.rbac")
List<HsOfficeContactRbacEntity> findContactByOptionalCaptionLike(String caption);
@Query(value = """
select c.* from hs_office.contact_rv c
where exists (
SELECT 1 FROM jsonb_each_text(c.emailAddresses) AS kv(key, value)
WHERE kv.value LIKE :emailAddressRegEx
)
""", nativeQuery = true)
@Timed("app.office.contacts.repo.findContactByEmailAddress.rbac")
List<HsOfficeContactRbacEntity> findContactByEmailAddress(final String emailAddressRegEx);
@Timed("app.office.contacts.repo.save.rbac")
HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity);

View File

@ -35,10 +35,10 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
public ResponseEntity<List<HsOfficePersonResource>> 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);

View File

@ -54,6 +54,7 @@ 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);
@ -62,7 +63,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData(
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);

View File

@ -26,24 +26,29 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
* *
* @param personUuid the optional UUID of the anchorPerson or holderPerson
* @param relationType the type of the relation
* @param mark the mark (use '%' for wildcard), case ignored
* @param personData a string to match the persons tradeName, familyName or givenName (use '%' for wildcard), case ignored
* @param contactData a string to match the contacts caption, postalAddress, emailAddresses or phoneNumbers (use '%' for wildcard), case ignored
* @return a list of (accessible) relations which match all given criteria
*/
default List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationTypePersonAndContactData(
default List<HsOfficeRelationRbacEntity> 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));
}
// TODO: use ELIKE instead of lower(...) LIKE ...? Or use jsonb_path with RegEx like emailAddressRegEx in ContactRepo?
@Query(value = """
SELECT rel FROM HsOfficeRelationRbacEntity AS rel
WHERE (:relationType IS NULL OR CAST(rel.type AS String) = :relationType)
AND ( :personUuid IS NULL
OR rel.anchor.uuid = :personUuid OR rel.holder.uuid = :personUuid )
AND ( :mark IS NULL OR lower(rel.mark) LIKE :mark )
AND ( :personData IS NULL
OR lower(rel.anchor.tradeName) LIKE :personData OR lower(rel.holder.tradeName) LIKE :personData
OR lower(rel.anchor.familyName) LIKE :personData OR lower(rel.holder.familyName) LIKE :personData
@ -54,10 +59,11 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
OR lower(CAST(rel.contact.emailAddresses AS String)) LIKE :contactData
OR lower(CAST(rel.contact.phoneNumbers AS String)) LIKE :contactData )
""")
@Timed("app.office.relations.repo.findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl.rbac")
List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl(
@Timed("app.office.relations.repo.findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl.rbac")
List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl(
final UUID personUuid,
final String relationType,
final String mark,
final String personData,
final String contactData);

View File

@ -7,12 +7,19 @@ 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:
Email-address to filter the results, use '%' as wildcard.
responses:
"200":
description: OK

View File

@ -27,8 +27,6 @@ components:
nullable: false
membership.memberNumber:
type: string
minLength: 9
maxLength: 9
pattern: 'M-[0-9]{7}'
transactionType:
$ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType'
@ -69,8 +67,6 @@ components:
nullable: false
membership.memberNumber:
type: string
minLength: 9
maxLength: 9
pattern: 'M-[0-9]{7}'
transactionType:
$ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType'
@ -130,8 +126,6 @@ components:
format: uuid
adoptingMembership.memberNumber:
type: string
minLength: 9
maxLength: 9
pattern: 'M-[0-9]{7}'
required:
- membership.uuid

View File

@ -13,8 +13,6 @@ components:
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation'
debitorNumber:
type: string
minLength: 9
maxLength: 9
pattern: 'D-[0-9]{7}'
debitorNumberSuffix:
type: string

View File

@ -12,8 +12,8 @@ get:
schema:
type: number
format: integer
# minimum: 1000000
# maximum: 9999999
minimum: 1000000
maximum: 9999999
description: debitor-number of the debitor to fetch.
responses:
"200":

View File

@ -25,8 +25,6 @@ get:
required: false
schema:
type: string
minLength: 7
maxLength: 7
pattern: 'P-[0-9]{5}'
description: Partner number of the requested debitor.
responses:

View File

@ -27,14 +27,10 @@ components:
$ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
memberNumber:
type: string
minLength: 9
maxLength: 9
pattern: 'M-[0-9]{7}'
memberNumberSuffix:
type: string
minLength: 2
maxLength: 2
pattern: '[0-9]+'
pattern: '[0-9]{2}'
validFrom:
type: string
format: date
@ -69,9 +65,7 @@ components:
nullable: false
memberNumberSuffix:
type: string
minLength: 2
maxLength: 2
pattern: '[0-9]+'
pattern: '[0-9]{2}'
nullable: false
validFrom:
type: string

View File

@ -20,8 +20,6 @@ get:
required: false
schema:
type: string
minLength: 7
maxLength: 7
pattern: 'P-[0-9]{5}'
description: partnerNumber of the partner the memberships belong to
responses:

View File

@ -11,8 +11,6 @@ components:
format: uuid
partnerNumber:
type: string
minLength: 7
maxLength: 7
pattern: 'P-[0-9]{5}'
partnerRel:
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation'
@ -87,8 +85,6 @@ components:
properties:
partnerNumber:
type: string
minLength: 7
maxLength: 7
pattern: 'P-[0-9]{5}'
partnerRel:
$ref: '#/components/schemas/HsOfficePartnerRelInsert'

View File

@ -21,7 +21,13 @@ 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
schema:
type: string
description:
- name: personData
in: query
required: false

View File

@ -42,6 +42,20 @@ class WebSecurityConfigIntegrationTest {
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
public void shouldSupportSwaggerUi() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/swagger-ui/index.html", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
public void shouldSupportApiDocs() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/v3/api-docs/swagger-config", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // permitted but not configured
}
@Test
public void shouldSupportHealthEndpoint() {
final var result = this.restTemplate.getForEntity(

View File

@ -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_canFindContactsByEmailAddress() {
// given
context("superuser-alex@hostsharing.net", null);
// when
final var result = contactRepo.findContactByEmailAddress("%@secondcontact.example.com");
// then
exactlyTheseContactsAreReturned(result, "second contact");
}
}
@Nested
class DeleteByUuid {

View File

@ -306,10 +306,10 @@ public class HsOfficeMembershipControllerRestTest {
public enum InvalidMemberSuffixVariants {
MISSING("", "[memberNumberSuffix must not be null but is \"null\"]"),
TOO_SMALL("\"memberNumberSuffix\": \"9\",", "memberNumberSuffix size must be between 2 and 2 but is \"9\""),
TOO_LARGE("\"memberNumberSuffix\": \"100\",", "memberNumberSuffix size must be between 2 and 2 but is \"100\""),
NOT_NUMERIC("\"memberNumberSuffix\": \"AA\",", "memberNumberSuffix must match \"[0-9]+\" but is \"AA\""),
EMPTY("\"memberNumberSuffix\": \"\",", "memberNumberSuffix size must be between 2 and 2 but is \"\"");
TOO_SMALL("\"memberNumberSuffix\": \"9\",", "memberNumberSuffix must match \"[0-9]{2}\" but is \"9\""),
TOO_LARGE("\"memberNumberSuffix\": \"100\",", "memberNumberSuffix must match \"[0-9]{2}\" but is \"100\""),
NOT_NUMERIC("\"memberNumberSuffix\": \"AA\",", "memberNumberSuffix must match \"[0-9]{2}\" but is \"AA\""),
EMPTY("\"memberNumberSuffix\": \"\",", "memberNumberSuffix must match \"[0-9]{2}\" but is \"\"");
private final String memberNumberSuffixEntry;
private final String expectedErrorMessage;

View File

@ -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(

View File

@ -32,7 +32,8 @@ import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.ReplaceDeceasedPartnerWithCommunityOfHeirs;
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.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;
import net.hostsharing.hsadminng.hs.scenarios.Requires;
@ -570,7 +571,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")
@ -584,7 +585,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")

View File

@ -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<SubscribeExistingPersonAndContactToMailinglist> {
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);
}
}

View File

@ -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<SubscribeNewPersonAndContactToMailinglist> {
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);
}
}

View File

@ -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<SubscribeToMailinglist> {
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);
}
}