From 956ee581c6c6c77b562d378f006554f775fe642a Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 26 Sep 2022 10:57:22 +0200 Subject: [PATCH] implements table hs_office_relationship to HsOfficeRelationshipController --- .../net/hostsharing/hsadminng/Mapper.java | 3 + .../net/hostsharing/hsadminng/Stringify.java | 63 +++ .../hostsharing/hsadminng/Stringifyable.java | 6 + .../office/contact/HsOfficeContactEntity.java | 22 +- .../office/person/HsOfficePersonEntity.java | 30 +- .../HsOfficeRelationshipController.java | 151 ++++++ .../HsOfficeRelationshipEntity.java | 60 +++ .../HsOfficeRelationshipEntityPatcher.java | 34 ++ .../HsOfficeRelationshipRepository.java | 37 ++ .../HsOfficeRelationshipType.java | 8 + .../hs-office/api-mappings.yaml | 2 + .../hs-office/hs-office-person-schemas.yaml | 6 - .../hs-office-relationship-schemas.yaml | 55 ++ .../hs-office-relationships-with-uuid.yaml | 83 +++ .../hs-office/hs-office-relationships.yaml | 63 +++ .../api-definition/hs-office/hs-office.yaml | 10 + .../resources/db/changelog/050-rbac-base.sql | 26 +- .../resources/db/changelog/055-rbac-views.sql | 2 +- .../db/changelog/057-rbac-role-builder.sql | 10 +- .../db/changelog/058-rbac-generators.sql | 1 + .../208-hs-office-contact-test-data.sql | 3 +- .../218-hs-office-person-test-data.sql | 4 +- .../changelog/230-hs-office-relationship.sql | 19 + .../233-hs-office-relationship-rbac.sql | 192 +++++++ .../238-hs-office-relationship-test-data.sql | 81 +++ .../db/changelog/db.changelog-master.yaml | 6 + .../hostsharing/hsadminng/MapperUnitTest.java | 57 ++ .../hsadminng/StringifyUnitTest.java | 99 ++++ ...OfficeContactControllerAcceptanceTest.java | 5 +- .../HsOfficeContactEntityUnitTest.java | 21 + ...OfficePartnerControllerAcceptanceTest.java | 4 +- .../HsOfficePartnerEntityPatcherUnitTest.java | 1 + ...fficePartnerRepositoryIntegrationTest.java | 3 - ...sOfficePersonControllerAcceptanceTest.java | 70 ++- .../person/HsOfficePersonEntityUnitTest.java | 53 ++ ...OfficePersonRepositoryIntegrationTest.java | 2 +- ...eRelationshipControllerAcceptanceTest.java | 510 ++++++++++++++++++ ...ficeRelationshipEntityPatcherUnitTest.java | 90 ++++ ...RelationshipRepositoryIntegrationTest.java | 421 +++++++++++++++ .../rbac/rbacgrant/RawRbacGrantEntity.java | 2 +- src/test/java/net/hostsharing/test/Array.java | 11 +- .../java/net/hostsharing/test/JpaAttempt.java | 2 +- tools/generate | 66 ++- 43 files changed, 2292 insertions(+), 102 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/Stringify.java create mode 100644 src/main/java/net/hostsharing/hsadminng/Stringifyable.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationships.yaml create mode 100644 src/main/resources/db/changelog/230-hs-office-relationship.sql create mode 100644 src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql create mode 100644 src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql create mode 100644 src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/Mapper.java b/src/main/java/net/hostsharing/hsadminng/Mapper.java index 8976f4b2..4d82cf32 100644 --- a/src/main/java/net/hostsharing/hsadminng/Mapper.java +++ b/src/main/java/net/hostsharing/hsadminng/Mapper.java @@ -35,6 +35,9 @@ public abstract class Mapper { } public static T map(final S source, final Class targetClass, final BiConsumer postMapper) { + if (source == null ) { + return null; + } final var target = modelMapper.map(source, targetClass); if (postMapper != null) { postMapper.accept(source, target); diff --git a/src/main/java/net/hostsharing/hsadminng/Stringify.java b/src/main/java/net/hostsharing/hsadminng/Stringify.java new file mode 100644 index 00000000..76078329 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/Stringify.java @@ -0,0 +1,63 @@ +package net.hostsharing.hsadminng; + +import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +// TODO.refa: use this instead of toDisplayName everywhere and add JavaDoc +public class Stringify { + + private final Class clazz; + private final String name; + private final List> props = new ArrayList<>(); + + public static Stringify stringify(final Class clazz, final String name) { + return new Stringify(clazz, name); + } + + public static Stringify stringify(final Class clazz) { + return new Stringify(clazz, null); + } + + private Stringify(final Class clazz, final String name) { + this.clazz = clazz; + this.name = name; + } + + public Stringify withProp(final String propName, final Function getter) { + props.add(new Property(propName, getter)); + return this; + } + + public String apply(@NotNull B object) { + final var propValues = props.stream() + .map(prop -> PropertyValue.of(prop, prop.getter.apply(object))) + .filter(Objects::nonNull) + .map(propVal -> { + if (propVal.rawValue instanceof Stringifyable stringifyable) { + return new PropertyValue<>(propVal.prop, propVal.rawValue, stringifyable.toShortString()); + } + return propVal; + }) + .map(propVal -> propVal.prop.name + "=" + optionallyQuoted(propVal)) + .collect(Collectors.joining(", ")); + return (name != null ? name : object.getClass().getSimpleName()) + "(" + propValues + ")"; + } + + private String optionallyQuoted(final PropertyValue propVal) { + return (propVal.rawValue instanceof Number) || (propVal.rawValue instanceof Boolean) + ? propVal.value + : "'" + propVal.value + "'"; + } + + private record Property(String name, Function getter) {} + + private record PropertyValue(Property prop, Object rawValue, String value) { + static PropertyValue of(Property prop, Object rawValue) { + return rawValue != null ? new PropertyValue<>(prop, rawValue, rawValue.toString()) : null; + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/Stringifyable.java b/src/main/java/net/hostsharing/hsadminng/Stringifyable.java new file mode 100644 index 00000000..2e4cec93 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/Stringifyable.java @@ -0,0 +1,6 @@ +package net.hostsharing.hsadminng; + +public interface Stringifyable { + + String toShortString(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java index cf41c0f2..23ee3f6e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java @@ -1,6 +1,9 @@ package net.hostsharing.hsadminng.hs.office.contact; import lombok.*; +import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.Stringify; +import net.hostsharing.hsadminng.Stringifyable; import javax.persistence.Column; import javax.persistence.Entity; @@ -8,6 +11,8 @@ import javax.persistence.Id; import javax.persistence.Table; import java.util.UUID; +import static net.hostsharing.hsadminng.Stringify.stringify; + @Entity @Table(name = "hs_office_contact_rv") @Getter @@ -15,7 +20,12 @@ import java.util.UUID; @Builder @NoArgsConstructor @AllArgsConstructor -public class HsOfficeContactEntity { +@FieldNameConstants +public class HsOfficeContactEntity implements Stringifyable { + + private static Stringify toString = stringify(HsOfficeContactEntity.class, "contact") + .withProp(Fields.label, HsOfficeContactEntity::getLabel) + .withProp(Fields.emailAddresses, HsOfficeContactEntity::getEmailAddresses); private @Id UUID uuid; private String label; @@ -28,4 +38,14 @@ public class HsOfficeContactEntity { @Column(name = "phonenumbers", columnDefinition = "json") private String phoneNumbers; + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { + return label; + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java index 79ad1a52..cadcd4ab 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java @@ -2,6 +2,9 @@ package net.hostsharing.hsadminng.hs.office.person; import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType; import lombok.*; +import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.Stringify; +import net.hostsharing.hsadminng.Stringifyable; import org.apache.commons.lang3.StringUtils; import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; @@ -9,6 +12,8 @@ import org.hibernate.annotations.TypeDef; import javax.persistence.*; import java.util.UUID; +import static net.hostsharing.hsadminng.Stringify.stringify; + @Entity @Table(name = "hs_office_person_rv") @TypeDef( @@ -20,7 +25,14 @@ import java.util.UUID; @Builder @NoArgsConstructor @AllArgsConstructor -public class HsOfficePersonEntity { +@FieldNameConstants +public class HsOfficePersonEntity implements Stringifyable { + + private static Stringify toString = stringify(HsOfficePersonEntity.class, "person") + .withProp(Fields.personType, HsOfficePersonEntity::getPersonType) + .withProp(Fields.tradeName, HsOfficePersonEntity::getTradeName) + .withProp(Fields.familyName, HsOfficePersonEntity::getFamilyName) + .withProp(Fields.givenName, HsOfficePersonEntity::getGivenName); private @Id UUID uuid; @@ -32,13 +44,23 @@ public class HsOfficePersonEntity { @Column(name = "tradename") private String tradeName; - @Column(name = "givenname") - private String givenName; - @Column(name = "familyname") private String familyName; + @Column(name = "givenname") + private String givenName; + public String getDisplayName() { + return toShortString(); + } + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { return !StringUtils.isEmpty(tradeName) ? tradeName : (familyName + ", " + givenName); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java new file mode 100644 index 00000000..e30a0b31 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java @@ -0,0 +1,151 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import net.hostsharing.hsadminng.Mapper; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationshipsApi; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import javax.persistence.EntityManager; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static net.hostsharing.hsadminng.Mapper.map; +import static net.hostsharing.hsadminng.Mapper.mapList; + +@RestController + +public class HsOfficeRelationshipController implements HsOfficeRelationshipsApi { + + @Autowired + private Context context; + + @Autowired + private HsOfficeRelationshipRepository relationshipRepo; + + @Autowired + private HsOfficePersonRepository relHolderRepo; + + @Autowired + private HsOfficeContactRepository contactRepo; + + @Autowired + private EntityManager em; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listRelationships( + final String currentUser, + final String assumedRoles, + final UUID personUuid, + final HsOfficeRelationshipTypeResource relationshipType) { + context.define(currentUser, assumedRoles); + + final var entities = relationshipRepo.findRelationshipRelatedToPersonUuid(personUuid, + map(relationshipType, HsOfficeRelationshipType.class)); + + final var resources = mapList(entities, HsOfficeRelationshipResource.class, + RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addRelationship( + final String currentUser, + final String assumedRoles, + final HsOfficeRelationshipInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = new HsOfficeRelationshipEntity(); + entityToSave.setRelType(HsOfficeRelationshipType.valueOf(body.getRelType())); + entityToSave.setUuid(UUID.randomUUID()); + entityToSave.setRelAnchor(relHolderRepo.findByUuid(body.getRelAnchorUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find relAnchorUuid " + body.getRelAnchorUuid()) + )); + entityToSave.setRelHolder(relHolderRepo.findByUuid(body.getRelHolderUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find relHolderUuid " + body.getRelHolderUuid()) + )); + entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find contactUuid " + body.getContactUuid()) + )); + + final var saved = relationshipRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/office/relationships/{id}") + .buildAndExpand(entityToSave.getUuid()) + .toUri(); + final var mapped = map(saved, HsOfficeRelationshipResource.class, + RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getRelationshipByUuid( + final String currentUser, + final String assumedRoles, + final UUID relationshipUuid) { + + context.define(currentUser, assumedRoles); + + final var result = relationshipRepo.findByUuid(relationshipUuid); + if (result.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(map(result.get(), HsOfficeRelationshipResource.class, RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER)); + } + + @Override + @Transactional + public ResponseEntity deleteRelationshipByUuid( + final String currentUser, + final String assumedRoles, + final UUID relationshipUuid) { + context.define(currentUser, assumedRoles); + + final var result = relationshipRepo.deleteByUuid(relationshipUuid); + if (result == 0) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchRelationship( + final String currentUser, + final String assumedRoles, + final UUID relationshipUuid, + final HsOfficeRelationshipPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = relationshipRepo.findByUuid(relationshipUuid).orElseThrow(); + + new HsOfficeRelationshipEntityPatcher(em, current).apply(body); + + final var saved = relationshipRepo.save(current); + final var mapped = map(saved, HsOfficeRelationshipResource.class); + return ResponseEntity.ok(mapped); + } + + + final BiConsumer RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + resource.setRelAnchor(map(entity.getRelAnchor(), HsOfficePersonResource.class)); + resource.setRelHolder(map(entity.getRelHolder(), HsOfficePersonResource.class)); + resource.setContact(map(entity.getContact(), HsOfficeContactResource.class)); + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java new file mode 100644 index 00000000..5513ceee --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java @@ -0,0 +1,60 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType; +import lombok.*; +import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.Stringify; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; + +import javax.persistence.*; +import java.util.UUID; + +import static net.hostsharing.hsadminng.Stringify.stringify; + +@Entity +@Table(name = "hs_office_relationship_rv") +@TypeDef( + name = "pgsql_enum", + typeClass = PostgreSQLEnumType.class +) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@FieldNameConstants +public class HsOfficeRelationshipEntity { + + private static Stringify toString = stringify(HsOfficeRelationshipEntity.class, "rel") + .withProp(Fields.relAnchor, HsOfficeRelationshipEntity::getRelAnchor) + .withProp(Fields.relType, HsOfficeRelationshipEntity::getRelType) + .withProp(Fields.relHolder, HsOfficeRelationshipEntity::getRelHolder) + .withProp(Fields.contact, HsOfficeRelationshipEntity::getContact); + + private @Id UUID uuid; + + @ManyToOne + @JoinColumn(name = "relanchoruuid") + private HsOfficePersonEntity relAnchor; + + @ManyToOne + @JoinColumn(name = "relholderuuid") + private HsOfficePersonEntity relHolder; + + @ManyToOne + @JoinColumn(name = "contactuuid") + private HsOfficeContactEntity contact; + + @Column(name = "reltype") + @Enumerated(EnumType.STRING) + @Type( type = "pgsql_enum" ) + private HsOfficeRelationshipType relType; + + @Override + public String toString() { + return toString.apply(this); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java new file mode 100644 index 00000000..c01a3467 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import net.hostsharing.hsadminng.EntityPatcher; +import net.hostsharing.hsadminng.OptionalFromJson; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource; + +import javax.persistence.EntityManager; +import java.util.UUID; + +class HsOfficeRelationshipEntityPatcher implements EntityPatcher { + + private final EntityManager em; + private final HsOfficeRelationshipEntity entity; + + HsOfficeRelationshipEntityPatcher(final EntityManager em, final HsOfficeRelationshipEntity entity) { + this.em = em; + this.entity = entity; + } + + @Override + public void apply(final HsOfficeRelationshipPatchResource resource) { + OptionalFromJson.of(resource.getContactUuid()).ifPresent(newValue -> { + verifyNotNull(newValue, "contact"); + entity.setContact(em.getReference(HsOfficeContactEntity.class, newValue)); + }); + } + + private void verifyNotNull(final UUID newValue, final String propertyName) { + if (newValue == null) { + throw new IllegalArgumentException("property '" + propertyName + "' must not be null"); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java new file mode 100644 index 00000000..6412e590 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java @@ -0,0 +1,37 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import javax.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeRelationshipRepository extends Repository { + + Optional findByUuid(UUID id); + + default List findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid, HsOfficeRelationshipType relationshipType) { + return findRelationshipRelatedToPersonUuid(personUuid, relationshipType.toString()); + } + + @Query(value = """ + SELECT p.* FROM hs_office_relationship_rv AS p + WHERE p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid + """, nativeQuery = true) + List findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid); + + @Query(value = """ + SELECT p.* FROM hs_office_relationship_rv AS p + WHERE (:relationshipType IS NULL OR p.relType = cast(:relationshipType AS HsOfficeRelationshipType)) + AND ( p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid) + """, nativeQuery = true) + List findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid, String relationshipType); + + HsOfficeRelationshipEntity save(final HsOfficeRelationshipEntity entity); + + long count(); + + int deleteByUuid(UUID uuid); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java new file mode 100644 index 00000000..7a5097a3 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java @@ -0,0 +1,8 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +public enum HsOfficeRelationshipType { + SOLE_AGENT, + JOINT_AGENT, + ACCOUNTING_CONTACT, + TECHNICAL_CONTACT +} diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml index d3ade71b..c77f5c86 100644 --- a/src/main/resources/api-definition/hs-office/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml @@ -18,3 +18,5 @@ map: null: org.openapitools.jackson.nullable.JsonNullable /api/hs/office/persons/{personUUID}: null: org.openapitools.jackson.nullable.JsonNullable + /api/hs/office/relationships/{relationshipUUID}: + null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml index 5a3d4596..34636034 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml @@ -3,12 +3,6 @@ components: schemas: - HsOfficePersonTypeValues: - - NATURAL # a human - - LEGAL # e.g. Corp., Inc., AG, GmbH, eG - - SOLE_REPRESENTATION # e.g. OHG, GbR - - JOINT_REPRESENTATION # e.g. community of heirs - HsOfficePersonType: type: string enum: diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml new file mode 100644 index 00000000..23af0ff0 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml @@ -0,0 +1,55 @@ + +components: + + schemas: + + HsOfficeRelationshipType: + type: string + enum: + - SOLE_AGENT # e.g. CEO + - JOINT_AGENT # e.g. heir + - ACCOUNTING_CONTACT + - TECHNICAL_CONTACT + + HsOfficeRelationship: + type: object + properties: + uuid: + type: string + format: uuid + relAnchor: + $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + relHolder: + $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + relType: + type: string + contact: + $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + + HsOfficeRelationshipPatch: + type: object + properties: + contactUuid: + type: string + format: uuid + nullable: true + + HsOfficeRelationshipInsert: + type: object + properties: + relAnchorUuid: + type: string + format: uuid + relHolderUuid: + type: string + format: uuid + relType: + type: string + nullable: true + contactUuid: + type: string + format: uuid + required: + - relAnchorUuid + - relHolderUuid + - relType diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml new file mode 100644 index 00000000..d3b9605e --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-office-relationships + description: 'Fetch a single person relationship by its uuid, if visible for the current subject.' + operationId: getRelationshipByUuid + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: relationshipUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the relationship to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' + + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-office-relationships + description: 'Updates a single person relationship by its uuid, if permitted for the current subject.' + operationId: patchRelationship + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: relationshipUUID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-office-relationships + description: 'Delete a single person relationship by its uuid, if permitted for the current subject.' + operationId: deleteRelationshipByUuid + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: relationshipUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the relationship to delete. + responses: + "204": + description: No Content + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: './error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml new file mode 100644 index 00000000..2d7ed2fd --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml @@ -0,0 +1,63 @@ +get: + summary: Returns a list of (optionally filtered) person relationships for a given person. + description: Returns the list of (optionally filtered) person relationships of a given person and which are visible to the current user or any of it's assumed roles. + tags: + - hs-office-relationships + operationId: listRelationships + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: personUuid + in: query + required: true + schema: + type: string + format: uuid + description: Prefix of name properties from relHolder or contact to filter the results. + - name: relationshipType + in: query + required: false + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipType' + description: Prefix of name properties from relHolder or contact to filter the results. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new person relationship. + tags: + - hs-office-relationships + operationId: addRelationship + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + requestBody: + content: + 'application/json': + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipInsert' + required: true + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: './error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml index 125ae698..856209ab 100644 --- a/src/main/resources/api-definition/hs-office/hs-office.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office.yaml @@ -34,3 +34,13 @@ paths: /api/hs/office/persons/{personUUID}: $ref: "./hs-office-persons-with-uuid.yaml" + + + # Relationships + + /api/hs/office/relationships: + $ref: "./hs-office-relationships.yaml" + + /api/hs/office/relationships/{relationshipUUID}: + $ref: "./hs-office-relationships-with-uuid.yaml" + diff --git a/src/main/resources/db/changelog/050-rbac-base.sql b/src/main/resources/db/changelog/050-rbac-base.sql index 8b98808f..ac09e7b8 100644 --- a/src/main/resources/db/changelog/050-rbac-base.sql +++ b/src/main/resources/db/changelog/050-rbac-base.sql @@ -20,6 +20,10 @@ create or replace function assertReferenceType(argument varchar, referenceId uui declare actualType ReferenceType; begin + if referenceId is null then + raise exception '% must be a % and not null', argument, expectedType; + end if; + actualType = (select type from RbacReference where uuid = referenceId); if (actualType <> expectedType) then raise exception '% must reference a %, but got a %', argument, expectedType, actualType; @@ -608,21 +612,29 @@ begin into RbacGrants (ascendantuuid, descendantUuid, assumed) values (superRoleId, subRoleId, doAssume) on conflict do nothing; -- allow granting multiple times - delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId; - insert - into RbacGrants (ascendantuuid, descendantUuid, assumed) - values (superRoleId, subRoleId, doAssume); -- allow granting multiple times end; $$; -create or replace procedure revokeRoleFromRole(subRoleId uuid, superRoleId uuid) +create or replace procedure grantRoleToRoleIfNotNull(subRole RbacRoleDescriptor, superRole RbacRoleDescriptor, doAssume bool = true) language plpgsql as $$ +declare + superRoleId uuid; + subRoleId uuid; begin + superRoleId := findRoleId(superRole); + if ( subRoleId is null ) then return; end if; + subRoleId := findRoleId(subRole); + perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole'); perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); - if (isGranted(superRoleId, subRoleId)) then - delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId; + if isGranted(subRoleId, superRoleId) then + raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId; end if; + + insert + into RbacGrants (ascendantuuid, descendantUuid, assumed) + values (superRoleId, subRoleId, doAssume) + on conflict do nothing; -- allow granting multiple times end; $$; create or replace procedure revokeRoleFromRole(subRole RbacRoleDescriptor, superRole RbacRoleDescriptor) diff --git a/src/main/resources/db/changelog/055-rbac-views.sql b/src/main/resources/db/changelog/055-rbac-views.sql index f4337162..68ea11b5 100644 --- a/src/main/resources/db/changelog/055-rbac-views.sql +++ b/src/main/resources/db/changelog/055-rbac-views.sql @@ -61,7 +61,7 @@ create or replace view rbacgrants_ev as x.descendingIdName as descendantIdName, x.grantedByRoleUuid, x.ascendantUuid as ascendantUuid, - x.descendantUuid as descenantUuid, + x.descendantUuid as descendantUuid, x.assumed from ( select g.uuid as grantUuid, diff --git a/src/main/resources/db/changelog/057-rbac-role-builder.sql b/src/main/resources/db/changelog/057-rbac-role-builder.sql index f9f83ea8..32e740ab 100644 --- a/src/main/resources/db/changelog/057-rbac-role-builder.sql +++ b/src/main/resources/db/changelog/057-rbac-role-builder.sql @@ -51,7 +51,9 @@ declare begin foreach superRoleDescriptor in array roleDescriptors loop - superRoleUuids := superRoleUuids || getRoleId(superRoleDescriptor, 'fail'); + if superRoleDescriptor is not null then + superRoleUuids := superRoleUuids || getRoleId(superRoleDescriptor, 'fail'); + end if; end loop; return row (superRoleUuids)::RbacSuperRoles; @@ -96,7 +98,6 @@ create type RbacSubRoles as roleUuids uuid[] ); --- drop FUNCTION beingItselfA(roleUuid uuid) create or replace function beingItselfA(roleUuid uuid) returns RbacSubRoles language plpgsql @@ -105,7 +106,6 @@ begin return row (array [roleUuid]::uuid[])::RbacSubRoles; end; $$; --- drop FUNCTION beingItselfA(roleDescriptor RbacRoleDescriptor) create or replace function beingItselfA(roleDescriptor RbacRoleDescriptor) returns RbacSubRoles language plpgsql @@ -124,7 +124,9 @@ declare begin foreach subRoleDescriptor in array roleDescriptors loop - subRoleUuids := subRoleUuids || getRoleId(subRoleDescriptor, 'fail'); + if subRoleDescriptor is not null then + subRoleUuids := subRoleUuids || getRoleId(subRoleDescriptor, 'fail'); + end if; end loop; return row (subRoleUuids)::RbacSubRoles; diff --git a/src/main/resources/db/changelog/058-rbac-generators.sql b/src/main/resources/db/changelog/058-rbac-generators.sql index 6c897eaf..934005d7 100644 --- a/src/main/resources/db/changelog/058-rbac-generators.sql +++ b/src/main/resources/db/changelog/058-rbac-generators.sql @@ -127,6 +127,7 @@ begin /* Creates a restricted view based on the 'view' permission of the current subject. */ + -- TODO.refa: hoist `select queryAccessibleObjectUuidsOfSubjectIds(...)` into WITH CTE for performance sql := format($sql$ set session session authorization default; create view %1$s_rv as diff --git a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql b/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql index 2d843611..1d651a69 100644 --- a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql +++ b/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql @@ -17,7 +17,7 @@ begin currentTask = 'creating RBAC test contact ' || contLabel; execute format('set local hsadminng.currentTask to %L', currentTask); - emailAddr = 'customer-admin@' || cleanIdentifier(contLabel) || '.example.com'; + emailAddr = 'contact-admin@' || cleanIdentifier(contLabel) || '.example.com'; call defineContext(currentTask); perform createRbacUser(emailAddr); call defineContext(currentTask, null, emailAddr); @@ -64,6 +64,7 @@ do language plpgsql $$ call createHsOfficeContactTestData('forth contact'); call createHsOfficeContactTestData('fifth contact'); call createHsOfficeContactTestData('sixth contact'); + call createHsOfficeContactTestData('seventh contact'); call createHsOfficeContactTestData('eighth contact'); call createHsOfficeContactTestData('ninth contact'); call createHsOfficeContactTestData('tenth contact'); diff --git a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql index d7ac1169..382c5f0e 100644 --- a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql +++ b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql @@ -60,10 +60,12 @@ end; $$; do language plpgsql $$ begin call createHsOfficePersonTestData('LEGAL', 'First Impressions GmbH'); - call createHsOfficePersonTestData('NATURAL', null, 'Peter', 'Smith'); + call createHsOfficePersonTestData('NATURAL', null, 'Smith', 'Peter'); call createHsOfficePersonTestData('LEGAL', 'Rockshop e.K.', 'Sandra', 'Miller'); call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Ostfriesische Kuhhandel OHG'); call createHsOfficePersonTestData('JOINT_REPRESENTATION', 'Erben Bessler', 'Mel', 'Bessler'); + call createHsOfficePersonTestData('NATURAL', null, 'Bessler', 'Anita'); + call createHsOfficePersonTestData('NATURAL', null, 'Winkler', 'Paul'); end; $$; --// diff --git a/src/main/resources/db/changelog/230-hs-office-relationship.sql b/src/main/resources/db/changelog/230-hs-office-relationship.sql new file mode 100644 index 00000000..2f17e88e --- /dev/null +++ b/src/main/resources/db/changelog/230-hs-office-relationship.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-relationship-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE TYPE HsOfficeRelationshipType AS ENUM ('SOLE_AGENT', 'JOINT_AGENT', 'CO_OWNER', 'ACCOUNTING_CONTACT', 'TECHNICAL_CONTACT'); + +CREATE CAST (character varying as HsOfficeRelationshipType) WITH INOUT AS IMPLICIT; + +create table if not exists hs_office_relationship +( + uuid uuid unique references RbacObject (uuid) initially deferred, -- on delete cascade + relAnchorUuid uuid not null references hs_office_person(uuid), + relHolderUuid uuid not null references hs_office_person(uuid), + contactUuid uuid references hs_office_contact(uuid), + relType HsOfficeRelationshipType not null +); +--// diff --git a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql new file mode 100644 index 00000000..8c494bbe --- /dev/null +++ b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql @@ -0,0 +1,192 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-relationship-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_relationship'); +--// + + +-- ============================================================================ +--changeset hs-office-relationship-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeRelationship', 'hs_office_relationship'); +--// + + +-- ============================================================================ +--changeset hs-office-relationship-rbac-ROLES-CREATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates and updates the roles and their assignments for relationship entities. + */ + +create or replace function hsOfficeRelationshipRbacRolesTrigger() + returns trigger + language plpgsql + strict as $$ +declare + hsOfficeRelationshipTenant RbacRoleDescriptor; + ownerRole uuid; + adminRole uuid; + newRelAnchor hs_office_person; + newRelHolder hs_office_person; + oldContact hs_office_contact; + newContact hs_office_contact; +begin + + hsOfficeRelationshipTenant := hsOfficeRelationshipTenant(NEW); + + select * from hs_office_person as p where p.uuid = NEW.relAnchorUuid into newRelAnchor; + select * from hs_office_person as p where p.uuid = NEW.relHolderUuid into newRelHolder; + select * from hs_office_contact as c where c.uuid = NEW.contactUuid into newContact; + + if TG_OP = 'INSERT' then + + -- the owner role with full access for admins of the relAnchor global admins + ownerRole = createRole( + hsOfficeRelationshipOwner(NEW), + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']), + beneathRoles(array[ + globalAdmin(), + hsOfficePersonAdmin(newRelAnchor)]) + ); + + -- the admin role with full access for the owner + adminRole = createRole( + hsOfficeRelationshipAdmin(NEW), + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']), + beneathRole(ownerRole) + ); + + -- the tenant role for those related users who can view the data + perform createRole( + hsOfficeRelationshipTenant, + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']), + beneathRoles(array[ + hsOfficePersonAdmin(newRelAnchor), + hsOfficePersonAdmin(newRelHolder), + hsOfficeContactAdmin(newContact)]), + withSubRoles(array[ + hsOfficePersonTenant(newRelAnchor), + hsOfficePersonTenant(newRelHolder), + hsOfficeContactTenant(newContact)]) + ); + + -- anchor and holder admin roles need each others tenant role + -- to be able to see the joined relationship + call grantRoleToRole(hsOfficePersonTenant(newRelAnchor), hsOfficePersonAdmin(newRelHolder)); + call grantRoleToRole(hsOfficePersonTenant(newRelHolder), hsOfficePersonAdmin(newRelAnchor)); + call grantRoleToRoleIfNotNull(hsOfficePersonTenant(newRelHolder), hsOfficeContactAdmin(newContact)); + + elsif TG_OP = 'UPDATE' then + + if OLD.contactUuid <> NEW.contactUuid then + -- nothing but the contact can be updated, + -- in other cases, a new relationship needs to be created and the old updated + + select * from hs_office_contact as c where c.uuid = OLD.contactUuid into oldContact; + + call revokeRoleFromRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(oldContact) ); + call grantRoleToRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(newContact) ); + + call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeRelationshipTenant ); + call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeRelationshipTenant ); + end if; + else + raise exception 'invalid usage of TRIGGER'; + end if; + + return NEW; +end; $$; + +/* + An AFTER INSERT TRIGGER which creates the role structure for a new customer. + */ +create trigger createRbacRolesForHsOfficeRelationship_Trigger + after insert + on hs_office_relationship + for each row +execute procedure hsOfficeRelationshipRbacRolesTrigger(); + +/* + An AFTER UPDATE TRIGGER which updates the role structure of a customer. + */ +create trigger updateRbacRolesForHsOfficeRelationship_Trigger + after update + on hs_office_relationship + for each row +execute procedure hsOfficeRelationshipRbacRolesTrigger(); +--// + + +-- ============================================================================ +--changeset hs-office-relationship-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacIdentityView('hs_office_relationship', $idName$ + (select idName from hs_office_person_iv p where p.uuid = target.relAnchorUuid) + || '-with-' || target.relType || '-' || + (select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid) + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-relationship-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_relationship', + '(select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid)', + $updates$ + contactUuid = new.contactUuid + $updates$); +--// + +-- TODO: exception if one tries to amend any other column + + +-- ============================================================================ +--changeset hs-office-relationship-rbac-NEW-RELATHIONSHIP:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Creates a global permission for new-relationship and assigns it to the hostsharing admins role. + */ +do language plpgsql $$ + declare + addCustomerPermissions uuid[]; + globalObjectUuid uuid; + globalAdminRoleUuid uuid ; + begin + call defineContext('granting global new-relationship permission to global admin role', null, null, null); + + globalAdminRoleUuid := findRoleId(globalAdmin()); + globalObjectUuid := (select uuid from global); + addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-relationship']); + call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); + end; +$$; + +/** + Used by the trigger to prevent the add-customer to current user respectively assumed roles. + */ +create or replace function addHsOfficeRelationshipNotAllowedForCurrentSubjects() + returns trigger + language PLPGSQL +as $$ +begin + raise exception '[403] new-relationship not permitted for %', + array_to_string(currentSubjects(), ';', 'null'); +end; $$; + +/** + Checks if the user or assumed roles are allowed to create a new customer. + */ +create trigger hs_office_relationship_insert_trigger + before insert + on hs_office_relationship + for each row + -- TODO.spec: who is allowed to create new relationships + when ( not hasAssumedRole() ) +execute procedure addHsOfficeRelationshipNotAllowedForCurrentSubjects(); +--// + diff --git a/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql b/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql new file mode 100644 index 00000000..a2b71ccc --- /dev/null +++ b/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql @@ -0,0 +1,81 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-relationship-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single relationship test record. + */ +create or replace procedure createHsOfficeRelationshipTestData( + anchorPersonTradeName varchar, + holderPersonFamilyName varchar, + relationshipType HsOfficeRelationshipType, + contactLabel varchar) + language plpgsql as $$ +declare + currentTask varchar; + idName varchar; + anchorPerson hs_office_person; + holderPerson hs_office_person; + contact hs_office_contact; + +begin + idName := cleanIdentifier( anchorPersonTradeName|| '-' || holderPersonFamilyName); + currentTask := 'creating RBAC test relationship ' || idName; + call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); + execute format('set local hsadminng.currentTask to %L', currentTask); + + select p.* from hs_office_person p where p.tradeName = anchorPersonTradeName into anchorPerson; + select p.* from hs_office_person p where p.familyName = holderPersonFamilyName into holderPerson; + select c.* from hs_office_contact c where c.label = contactLabel into contact; + + raise notice 'creating test relationship: %', idName; + raise notice '- using anchor person (%): %', anchorPerson.uuid, anchorPerson; + raise notice '- using holder person (%): %', holderPerson.uuid, holderPerson; + raise notice '- using contact (%): %', contact.uuid, contact; + insert + into hs_office_relationship (uuid, relanchoruuid, relholderuuid, reltype, contactUuid) + values (uuid_generate_v4(), anchorPerson.uuid, holderPerson.uuid, relationshipType, contact.uuid); +end; $$; +--// + +/* + Creates a range of test relationship for mass data generation. + */ +create or replace procedure createHsOfficeRelationshipTestData( + startCount integer, -- count of auto generated rows before the run + endCount integer -- count of auto generated rows after the run +) + language plpgsql as $$ +declare + person hs_office_person; + contact hs_office_contact; +begin + for t in startCount..endCount + loop + select p.* from hs_office_person p where tradeName = intToVarChar(t, 4) into person; + select c.* from hs_office_contact c where c.label = intToVarChar(t, 4) || '#' || t into contact; + + call createHsOfficeRelationshipTestData(person.uuid, contact.uuid, 'SOLE_AGENT'); + commit; + end loop; +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-relationship-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call createHsOfficeRelationshipTestData('First Impressions GmbH', 'Smith', 'SOLE_AGENT', 'first contact'); + + call createHsOfficeRelationshipTestData('Rockshop e.K.', 'Smith', 'SOLE_AGENT', 'second contact'); + + call createHsOfficeRelationshipTestData('Ostfriesische Kuhhandel OHG', 'Smith', 'SOLE_AGENT', 'third contact'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index c62f182e..1ed2aa8c 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -65,3 +65,9 @@ databaseChangeLog: file: db/changelog/223-hs-office-partner-rbac.sql - include: file: db/changelog/228-hs-office-partner-test-data.sql + - include: + file: db/changelog/230-hs-office-relationship.sql + - include: + file: db/changelog/233-hs-office-relationship-rbac.sql + - include: + file: db/changelog/238-hs-office-relationship-test-data.sql diff --git a/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java b/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java new file mode 100644 index 00000000..b23e6c3c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java @@ -0,0 +1,57 @@ +package net.hostsharing.hsadminng; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class MapperUnitTest { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class SourceBean { + private String a; + private String b; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class TargetBean { + private String a; + private String b; + private String c; + } + + @Test + void mapsNullBeanToNull() { + final SourceBean givenSource = null; + final var result = Mapper.map(givenSource, TargetBean.class, (s, t) -> { fail("should not have been called"); }); + assertThat(result).isNull(); + } + + @Test + void mapsBean() { + final SourceBean givenSource = new SourceBean("1234", "Text"); + final var result = Mapper.map(givenSource, TargetBean.class, null); + assertThat(result).usingRecursiveComparison().isEqualTo( + new TargetBean("1234", "Text", null) + ); + } + + @Test + void mapsBeanWithPostmapper() { + final SourceBean givenSource = new SourceBean("1234", "Text"); + final var result = Mapper.map(givenSource, TargetBean.class, (s, t) -> { t.setC("Extra"); }); + assertThat(result).usingRecursiveComparison().isEqualTo( + new TargetBean("1234", "Text", "Extra") + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java new file mode 100644 index 00000000..e7e22e6b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java @@ -0,0 +1,99 @@ +package net.hostsharing.hsadminng; + +import lombok.*; +import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import org.junit.jupiter.api.Test; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.UUID; + +import static net.hostsharing.hsadminng.Stringify.stringify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class StringifyUnitTest { + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @FieldNameConstants + public static class TestBean implements Stringifyable { + + private static Stringify toString = stringify(TestBean.class, "bean") + .withProp(TestBean.Fields.label, TestBean::getLabel) + .withProp(TestBean.Fields.content, TestBean::getContent) + .withProp(TestBean.Fields.active, TestBean::isActive); + + private UUID uuid; + + private String label; + + private SubBean content; + + private boolean active; + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { + return label; + } + } + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @FieldNameConstants + public static class SubBean implements Stringifyable { + + private static Stringify toString = stringify(SubBean.class) + .withProp(SubBean.Fields.key, SubBean::getKey) + .withProp(Fields.value, SubBean::getValue); + + private String key; + private Integer value; + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { + return key + ":" + value; + } + } + + @Test + void stringifyWhenAllPropsHaveValues() { + final var given = new TestBean(UUID.randomUUID(), "some label", + new SubBean("some content", 1234), false); + final var result = given.toString(); + assertThat(result).isEqualTo("bean(label='some label', content='some content:1234', active=false)"); + } + + @Test + void stringifyWhenAllNullablePropsHaveNulValues() { + final var given = new TestBean(); + final var result = given.toString(); + assertThat(result).isEqualTo("bean(active=false)"); + } + + @Test + void stringifyWithoutExplicitNameUsesSimpleClassName() { + final var given = new SubBean("some key", 1234); + final var result = given.toString(); + assertThat(result).isEqualTo("SubBean(key='some key', value=1234)"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java index 8bf0c71b..b018b9fb 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java @@ -74,6 +74,7 @@ class HsOfficeContactControllerAcceptanceTest { { "label": "forth contact" }, { "label": "fifth contact" }, { "label": "sixth contact" }, + { "label": "seventh contact" }, { "label": "eighth contact" }, { "label": "ninth contact" }, { "label": "tenth contact" }, @@ -173,7 +174,7 @@ class HsOfficeContactControllerAcceptanceTest { RestAssured // @formatter:off .given() - .header("current-user", "customer-admin@firstcontact.example.com") + .header("current-user", "contact-admin@firstcontact.example.com") .port(port) .when() .get("http://localhost/api/hs/office/contacts/" + givenContactUuid) @@ -183,7 +184,7 @@ class HsOfficeContactControllerAcceptanceTest { .body("", lenientlyEquals(""" { "label": "first contact", - "emailAddresses": "customer-admin@firstcontact.example.com", + "emailAddresses": "contact-admin@firstcontact.example.com", "phoneNumbers": "+49 123 1234567" } """)); // @formatter:on diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java new file mode 100644 index 00000000..8f779b5b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeContactEntityUnitTest { + + @Test + void toStringReturnsNullForNullContact() { + final HsOfficeContactEntity givenContact = null; + assertThat("" + givenContact).isEqualTo("null"); + } + + @Test + void toStringReturnsLabel() { + final var givenContact = HsOfficeContactEntity.builder().label("given label").build(); + assertThat("" + givenContact).isEqualTo("contact(label='given label')"); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java index f600dfe9..73a6d0c5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java @@ -244,7 +244,7 @@ class HsOfficePartnerControllerAcceptanceTest { RestAssured // @formatter:off .given() - .header("current-user", "customer-admin@firstcontact.example.com") + .header("current-user", "contact-admin@firstcontact.example.com") .port(port) .when() .get("http://localhost/api/hs/office/partners/" + givenPartnerUuid) @@ -390,7 +390,7 @@ class HsOfficePartnerControllerAcceptanceTest { RestAssured // @formatter:off .given() - .header("current-user", "customer-admin@forthcontact.example.com") + .header("current-user", "contact-admin@forthcontact.example.com") .port(port) .when() .delete("http://localhost/api/hs/office/partners/" + givenPartner.getUuid()) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java index 5d80f623..46f51001 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java @@ -56,6 +56,7 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase< lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation -> HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build()); } + @Override protected HsOfficePartnerEntity newInitialEntity() { final var entity = new HsOfficePartnerEntity(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java index ba63488a..9917e9d5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java @@ -393,9 +393,6 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net", null); tempPartners.forEach(tempPartner -> { System.out.println("DELETING temporary partner: " + tempPartner.getDisplayName()); - if ( tempPartner.getContact().getLabel().equals("sixth contact")) { - toString(); - } partnerRepo.deleteByUuid(tempPartner.getUuid()); }); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java index ef9e00da..4c56dde4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java @@ -70,35 +70,47 @@ class HsOfficePersonControllerAcceptanceTest { .body("", lenientlyEquals(""" [ { - "personType": "JOINT_REPRESENTATION", - "tradeName": "Erben Bessler", - "givenName": "Bessler", - "familyName": "Mel" - }, - { - "personType": "LEGAL", - "tradeName": "First Impressions GmbH", - "givenName": null, - "familyName": null - }, - { - "personType": "SOLE_REPRESENTATION", - "tradeName": "Ostfriesische Kuhhandel OHG", - "givenName": null, - "familyName": null - }, - { - "personType": "NATURAL", - "tradeName": null, - "givenName": "Smith", - "familyName": "Peter" - }, - { - "personType": "LEGAL", - "tradeName": "Rockshop e.K.", - "givenName": "Miller", - "familyName": "Sandra" - } + "personType": "NATURAL", + "tradeName": null, + "givenName": "Anita", + "familyName": "Bessler" + }, + { + "personType": "JOINT_REPRESENTATION", + "tradeName": "Erben Bessler", + "givenName": "Bessler", + "familyName": "Mel" + }, + { + "personType": "LEGAL", + "tradeName": "First Impressions GmbH", + "givenName": null, + "familyName": null + }, + { + "personType": "SOLE_REPRESENTATION", + "tradeName": "Ostfriesische Kuhhandel OHG", + "givenName": null, + "familyName": null + }, + { + "personType": "LEGAL", + "tradeName": "Rockshop e.K.", + "givenName": "Miller", + "familyName": "Sandra" + }, + { + "personType": "NATURAL", + "tradeName": null, + "givenName": "Peter", + "familyName": "Smith" + }, + { + "personType": "NATURAL", + "tradeName": null, + "givenName": "Paul", + "familyName": "Winkler" + } ] """ )); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java index fe3f5074..fc7392b6 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java @@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.person; import org.junit.jupiter.api.Test; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; class HsOfficePersonEntityUnitTest { @@ -29,4 +31,55 @@ class HsOfficePersonEntityUnitTest { assertThat(actualDisplay).isEqualTo("some family name, some given name"); } + @Test + void toShortStringWithTradeNameReturnsTradeName() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .tradeName("some trade name") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toShortString(); + + assertThat(actualDisplay).isEqualTo("some trade name"); + } + + @Test + void toShortStringWithoutTradeNameReturnsFamilyAndGivenName() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toShortString(); + + assertThat(actualDisplay).isEqualTo("some family name, some given name"); + } + + @Test + void toStringWithAllFieldsReturnsAllButUuid() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .uuid(UUID.randomUUID()) + .personType(HsOfficePersonType.NATURAL) + .tradeName("some trade name") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toString(); + + assertThat(actualDisplay).isEqualTo("person(personType='NATURAL', tradeName='some trade name', familyName='some family name', givenName='some given name')"); + } + + @Test + void toStringSkipsNullFields() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toString(); + + assertThat(actualDisplay).isEqualTo("person(familyName='some family name', givenName='some given name')"); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java index 733b0ca9..1280f595 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java @@ -143,7 +143,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { // then allThesePersonsAreReturned( result, - "Peter, Smith", + "Smith, Peter", "Rockshop e.K.", "Ostfriesische Kuhhandel OHG", "Erben Bessler"); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java new file mode 100644 index 00000000..79ccbc71 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java @@ -0,0 +1,510 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.Accepts; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipTypeResource; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.test.JpaAttempt; +import org.json.JSONException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +class HsOfficeRelationshipControllerAcceptanceTest { + + @LocalServerPort + private Integer port; + + @Autowired + Context context; + + @Autowired + Context contextMock; + + @Autowired + HsOfficeRelationshipRepository relationshipRepo; + + @Autowired + HsOfficePersonRepository personRepo; + + @Autowired + HsOfficeContactRepository contactRepo; + + @Autowired + JpaAttempt jpaAttempt; + + Set tempRelationshipUuids = new HashSet<>(); + + @Nested + @Accepts({ "Relationship:F(Find)" }) + class ListRelationships { + + @Test + void globalAdmin_withoutAssumedRoles_canViewAllRelationshipsOfGivenPersonAndType_ifNoCriteriaGiven() throws JSONException { + + // given + context.define("superuser-alex@hostsharing.net"); + final var givenPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/relationships?personUuid=%s&relationshipType=%s" + .formatted(givenPerson.getUuid(), HsOfficeRelationshipTypeResource.SOLE_AGENT)) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "relAnchor": { + "personType": "SOLE_REPRESENTATION", + "tradeName": "Ostfriesische Kuhhandel OHG" + }, + "relHolder": { + "personType": "NATURAL", + "givenName": "Peter", + "familyName": "Smith" + }, + "relType": "SOLE_AGENT", + "contact": { "label": "third contact" } + }, + { + "relAnchor": { + "personType": "LEGAL", + "tradeName": "Rockshop e.K.", + "givenName": "Miller", + "familyName": "Sandra" + }, + "relHolder": { + "personType": "NATURAL", + "givenName": "Peter", + "familyName": "Smith" + }, + "relType": "SOLE_AGENT", + "contact": { "label": "second contact" } + }, + { + "relAnchor": { + "personType": "LEGAL", + "tradeName": "First Impressions GmbH" + }, + "relHolder": { + "personType": "NATURAL", + "tradeName": null, + "givenName": "Peter", + "familyName": "Smith" + }, + "relType": "SOLE_AGENT", + "contact": { "label": "first contact" } + } + ] + """)); + // @formatter:on + } + } + + @Nested + @Accepts({ "Relationship:C(Create)" }) + class AddRelationship { + + @Test + void globalAdmin_withoutAssumedRole_canAddRelationship() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "relType": "%s", + "relAnchorUuid": "%s", + "relHolderUuid": "%s", + "contactUuid": "%s" + } + """.formatted( + HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT, + givenAnchorPerson.getUuid(), + givenHolderPerson.getUuid(), + givenContact.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/relationships") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("relType", is("ACCOUNTING_CONTACT")) + .body("relAnchor.tradeName", is("Ostfriesische Kuhhandel OHG")) + .body("relHolder.givenName", is("Paul")) + .body("contact.label", is("forth contact")) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on + + // finally, the new relationship can be accessed under the generated UUID + final var newUserUuid = toCleanup(UUID.fromString( + location.substring(location.lastIndexOf('/') + 1))); + assertThat(newUserUuid).isNotNull(); + } + + @Test + void globalAdmin_canNotAddRelationship_ifAnchorPersonDoesNotExist() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "relType": "%s", + "relAnchorUuid": "%s", + "relHolderUuid": "%s", + "contactUuid": "%s" + } + """.formatted( + HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT, + givenAnchorPersonUuid, + givenHolderPerson.getUuid(), + givenContact.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/relationships") + .then().log().all().assertThat() + .statusCode(404) + .body("message", is("cannot find relAnchorUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // @formatter:on + } + + @Test + void globalAdmin_canNotAddRelationship_ifHolderPersonDoesNotExist() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0); + final var givenHolderPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "relType": "%s", + "relAnchorUuid": "%s", + "relHolderUuid": "%s", + "contactUuid": "%s" + } + """.formatted( + HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT, + givenAnchorPerson.getUuid(), + givenHolderPersonUuid, + givenContact.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/relationships") + .then().log().all().assertThat() + .statusCode(404) + .body("message", is("cannot find relHolderUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // @formatter:on + } + + @Test + void globalAdmin_canNotAddRelationship_ifContactDoesNotExist() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0); + final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "relType": "%s", + "relAnchorUuid": "%s", + "relHolderUuid": "%s", + "contactUuid": "%s" + } + """.formatted( + HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT, + givenAnchorPerson.getUuid(), + givenHolderPerson.getUuid(), + givenContactUuid)) + .port(port) + .when() + .post("http://localhost/api/hs/office/relationships") + .then().log().all().assertThat() + .statusCode(404) + .body("message", is("cannot find contactUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // @formatter:on + } + } + + @Nested + @Accepts({ "Relationship:R(Read)" }) + class GetRelationship { + + @Test + void globalAdmin_withoutAssumedRole_canGetArbitraryRelationship() { + context.define("superuser-alex@hostsharing.net"); + final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid) + .then().log().body().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "relAnchor": { "tradeName": "First Impressions GmbH" }, + "relHolder": { "familyName": "Smith" }, + "contact": { "label": "first contact" } + } + """)); // @formatter:on + } + + @Test + @Accepts({ "Relationship:X(Access Control)" }) + void normalUser_canNotGetUnrelatedRelationship() { + context.define("superuser-alex@hostsharing.net"); + final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + @Accepts({ "Relationship:X(Access Control)" }) + void contactAdminUser_canGetRelatedRelationship() { + context.define("superuser-alex@hostsharing.net"); + final var givenRelationship = findRelationship("First", "Smith"); + assertThat(givenRelationship.getContact().getLabel()).isEqualTo("first contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "contact-admin@firstcontact.example.com") + .port(port) + .when() + .get("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .then().log().body().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "relAnchor": { "tradeName": "First Impressions GmbH" }, + "relHolder": { "familyName": "Smith" }, + "contact": { "label": "first contact" } + } + """)); // @formatter:on + } + } + + private HsOfficeRelationshipEntity findRelationship( + final String anchorPersonName, + final String holderPersoneName) { + final var anchorPersonUuid = personRepo.findPersonByOptionalNameLike(anchorPersonName).get(0).getUuid(); + final var holderPersonUuid = personRepo.findPersonByOptionalNameLike(holderPersoneName).get(0).getUuid(); + final var givenRelationship = relationshipRepo + .findRelationshipRelatedToPersonUuid(anchorPersonUuid) + .stream() + .filter(r -> r.getRelHolder().getUuid().equals(holderPersonUuid)) + .findFirst().orElseThrow(); + return givenRelationship; + } + + @Nested + @Accepts({ "Relationship:U(Update)" }) + class PatchRelationship { + + @Test + void globalAdmin_withoutAssumedRole_canPatchContactOfArbitraryRelationship() { + + context.define("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler(); + assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "contactUuid": "%s" + } + """.formatted(givenContact.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("relType", is("JOINT_AGENT")) + .body("relAnchor.tradeName", is("Erben Bessler")) + .body("relHolder.familyName", is("Winkler")) + .body("contact.label", is("forth contact")); + // @formatter:on + + // finally, the relationship is actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent().get() + .matches(rel -> { + assertThat(rel.getRelAnchor().getTradeName()).contains("Bessler"); + assertThat(rel.getRelHolder().getFamilyName()).contains("Winkler"); + assertThat(rel.getContact().getLabel()).isEqualTo("forth contact"); + assertThat(rel.getRelType()).isEqualTo(HsOfficeRelationshipType.JOINT_AGENT); + return true; + }); + } + } + + @Nested + @Accepts({ "Relationship:D(Delete)" }) + class DeleteRelationship { + + @Test + void globalAdmin_withoutAssumedRole_canDeleteArbitraryRelationship() { + context.define("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given relationship is gone + assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isEmpty(); + } + + @Test + @Accepts({ "Relationship:X(Access Control)" }) + void contactAdminUser_canNotDeleteRelatedRelationship() { + context.define("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler(); + assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "contact-admin@seventhcontact.example.com") + .port(port) + .when() + .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .then().log().body().assertThat() + .statusCode(403); // @formatter:on + + // then the given relationship is still there + assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty(); + } + + @Test + @Accepts({ "Relationship:X(Access Control)" }) + void normalUser_canNotDeleteUnrelatedRelationship() { + context.define("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler(); + assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + + // then the given relationship is still there + assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty(); + } + } + + private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Winkler").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("seventh contact").get(0); + final var newRelationship = HsOfficeRelationshipEntity.builder() + .uuid(UUID.randomUUID()) + .relType(HsOfficeRelationshipType.JOINT_AGENT) + .relAnchor(givenAnchorPerson) + .relHolder(givenHolderPerson) + .contact(givenContact) + .build(); + + toCleanup(newRelationship.getUuid()); + + return relationshipRepo.save(newRelationship); + }).assertSuccessful().returnedValue(); + } + + private UUID toCleanup(final UUID tempRelationshipUuid) { + tempRelationshipUuids.add(tempRelationshipUuid); + return tempRelationshipUuid; + } + + @AfterEach + void cleanup() { + tempRelationshipUuids.forEach(uuid -> { + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + System.out.println("DELETING temporary relationship: " + uuid); + final var count = relationshipRepo.deleteByUuid(uuid); + System.out.println("DELETED temporary relationship: " + uuid + (count > 0 ? " successful" : " failed")); + }); + }); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java new file mode 100644 index 00000000..65ece28c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java @@ -0,0 +1,90 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import net.hostsharing.hsadminng.PatchUnitTestBase; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.persistence.EntityManager; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase< + HsOfficeRelationshipPatchResource, + HsOfficeRelationshipEntity + > { + + static final UUID INITIAL_RELATIONSHIP_UUID = UUID.randomUUID(); + static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); + + @Mock + EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + final HsOfficePersonEntity givenInitialAnchorPerson = HsOfficePersonEntity.builder() + .uuid(UUID.randomUUID()) + .build(); + final HsOfficePersonEntity givenInitialHolderPerson = HsOfficePersonEntity.builder() + .uuid(UUID.randomUUID()) + .build(); + final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() + .uuid(UUID.randomUUID()) + .build(); + + @Override + protected HsOfficeRelationshipEntity newInitialEntity() { + final var entity = new HsOfficeRelationshipEntity(); + entity.setUuid(INITIAL_RELATIONSHIP_UUID); + entity.setRelType(HsOfficeRelationshipType.SOLE_AGENT); + entity.setRelAnchor(givenInitialAnchorPerson); + entity.setRelHolder(givenInitialHolderPerson); + entity.setContact(givenInitialContact); + return entity; + } + + @Override + protected HsOfficeRelationshipPatchResource newPatchResource() { + return new HsOfficeRelationshipPatchResource(); + } + + @Override + protected HsOfficeRelationshipEntityPatcher createPatcher(final HsOfficeRelationshipEntity relationship) { + return new HsOfficeRelationshipEntityPatcher(em, relationship); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "contact", + HsOfficeRelationshipPatchResource::setContactUuid, + PATCHED_CONTACT_UUID, + HsOfficeRelationshipEntity::setContact, + newContact(PATCHED_CONTACT_UUID)) + .notNullable() + ); + } + + static HsOfficeContactEntity newContact(final UUID uuid) { + final var newContact = new HsOfficeContactEntity(); + newContact.setUuid(uuid); + return newContact; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java new file mode 100644 index 00000000..bfbfd4ff --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java @@ -0,0 +1,421 @@ +package net.hostsharing.hsadminng.hs.office.relationship; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.test.Array; +import net.hostsharing.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.test.annotation.DirtiesContext; + +import javax.persistence.EntityManager; +import javax.servlet.http.HttpServletRequest; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; +import static net.hostsharing.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +@DataJpaTest +@ComponentScan(basePackageClasses = { HsOfficeRelationshipRepository.class, Context.class, JpaAttempt.class }) +@DirtiesContext +class HsOfficeRelationshipRepositoryIntegrationTest extends ContextBasedTest { + + @Autowired + HsOfficeRelationshipRepository relationshipRepo; + + @Autowired + HsOfficePersonRepository personRepo; + + @Autowired + HsOfficeContactRepository contactRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + EntityManager em; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + Set tempRelationships = new HashSet<>(); + + @Nested + class CreateRelationship { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewRelationship() { + // given + context("superuser-alex@hostsharing.net"); + final var count = relationshipRepo.count(); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + + // when + final var result = attempt(em, () -> { + final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder() + .uuid(UUID.randomUUID()) + .relAnchor(givenAnchorPerson) + .relHolder(givenHolderPerson) + .relType(HsOfficeRelationshipType.JOINT_AGENT) + .contact(givenContact) + .build()); + return relationshipRepo.save(newRelationship); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeRelationshipEntity::getUuid).isNotNull(); + assertThatRelationshipIsPersisted(result.returnedValue()); + assertThat(relationshipRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + + // when + attempt(em, () -> { + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder() + .uuid(UUID.randomUUID()) + .relAnchor(givenAnchorPerson) + .relHolder(givenHolderPerson) + .relType(HsOfficeRelationshipType.JOINT_AGENT) + .contact(givenContact) + .build()); + return relationshipRepo.save(newRelationship); + }); + + // then + assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin", + "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner", + "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant")); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromSkippingNull( + initialGrantNames, + + "{ grant perm * on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner by system and assume }", + "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner to role global#global.admin by system and assume }", + "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner to role hs_office_person#BesslerAnita.admin by system and assume }", + + "{ grant perm edit on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin by system and assume }", + "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner by system and assume }", + + "{ grant perm view on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }", + "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant to role hs_office_contact#forthcontact.admin by system and assume }", + "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant to role hs_office_person#BesslerAnita.admin by system and assume }", + + "{ grant role hs_office_contact#forthcontact.tenant to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }", + "{ grant role hs_office_person#BesslerAnita.tenant to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }", + null) + ); + } + + private void assertThatRelationshipIsPersisted(final HsOfficeRelationshipEntity saved) { + final var found = relationshipRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + } + } + + @Nested + class FindAllRelationships { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllRelationshipsOfArbitraryPerson() { + // given + context("superuser-alex@hostsharing.net"); + final var person = personRepo.findPersonByOptionalNameLike("Smith").stream().findFirst().orElseThrow(); + + // when + final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid()); + + // then + allTheseRelationshipsAreReturned( + result, + "rel(relAnchor='First Impressions GmbH', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='first contact')", + "rel(relAnchor='Ostfriesische Kuhhandel OHG', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='third contact')", + "rel(relAnchor='Rockshop e.K.', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='second contact')"); + } + + @Test + public void normalUser_canViewRelationshipsOfOwnedPersons() { + // given: + context("person-FirstImpressionsGmbH@example.com"); + final var person = personRepo.findPersonByOptionalNameLike("First").stream().findFirst().orElseThrow(); + + // when: + final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid()); + + // then: + exactlyTheseRelationshipsAreReturned( + result, + "rel(relAnchor='First Impressions GmbH', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='first contact')"); + } + } + + @Nested + class UpdateRelationship { + + @Test + public void hostsharingAdmin_withoutAssumedRole_canUpdateContactOfArbitraryRelationship() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "fifth contact"); + assertThatRelationshipIsVisibleForUserWithRole( + givenRelationship, + "hs_office_person#ErbenBesslerMelBessler.admin"); + assertThatRelationshipActuallyInDatabase(givenRelationship); + context("superuser-alex@hostsharing.net"); + final var givenContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + givenRelationship.setContact(givenContact); + return toCleanup(relationshipRepo.save(givenRelationship)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue().getContact().getLabel()).isEqualTo("sixth contact"); + assertThatRelationshipIsVisibleForUserWithRole( + result.returnedValue(), + "global#global.admin"); + assertThatRelationshipIsVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#sixthcontact.admin"); + + assertThatRelationshipIsNotVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#fifthcontact.admin"); + + relationshipRepo.deleteByUuid(givenRelationship.getUuid()); + } + + @Test + public void relHolderAdmin_canNotUpdateRelatedRelationship() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "eighth"); + assertThatRelationshipIsVisibleForUserWithRole( + givenRelationship, + "hs_office_person#BesslerAnita.admin"); + assertThatRelationshipActuallyInDatabase(givenRelationship); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_person#BesslerAnita.admin"); + givenRelationship.setContact(null); + return relationshipRepo.save(givenRelationship); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_relationship uuid"); + } + + @Test + public void contactAdmin_canNotUpdateRelatedRelationship() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "ninth"); + assertThatRelationshipIsVisibleForUserWithRole( + givenRelationship, + "hs_office_contact#ninthcontact.admin"); + assertThatRelationshipActuallyInDatabase(givenRelationship); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin"); + givenRelationship.setContact(null); // TODO + return relationshipRepo.save(givenRelationship); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_relationship uuid"); + } + + private void assertThatRelationshipActuallyInDatabase(final HsOfficeRelationshipEntity saved) { + final var found = relationshipRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved); + } + + private void assertThatRelationshipIsVisibleForUserWithRole( + final HsOfficeRelationshipEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + assertThatRelationshipActuallyInDatabase(entity); + }).assertSuccessful(); + } + + private void assertThatRelationshipIsNotVisibleForUserWithRole( + final HsOfficeRelationshipEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + final var found = relationshipRepo.findByUuid(entity.getUuid()); + assertThat(found).isEmpty(); + }).assertSuccessful(); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyRelationship() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "tenth"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + relationshipRepo.deleteByUuid(givenRelationship.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return relationshipRepo.findByUuid(givenRelationship.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void contactUser_canViewButNotDeleteTheirRelatedRelationship() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "eleventh"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("contact-admin@eleventhcontact.example.com"); + assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent(); + relationshipRepo.deleteByUuid(givenRelationship.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " not allowed to delete hs_office_relationship"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return relationshipRepo.findByUuid(givenRelationship.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingARelationshipAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); + final var givenRelationship = givenSomeTemporaryRelationshipBessler( + "Anita", "twelfth"); + assertThat(rawRoleRepo.findAll().size()).as("unexpected number of roles created") + .isEqualTo(initialRoleNames.length + 3); + assertThat(rawGrantRepo.findAll().size()).as("unexpected number of grants created") + .isEqualTo(initialGrantNames.length + 12); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return relationshipRepo.deleteByUuid(givenRelationship.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler(final String holderPerson, final String contact) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike(holderPerson).get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0); + final var newRelationship = HsOfficeRelationshipEntity.builder() + .uuid(UUID.randomUUID()) + .relType(HsOfficeRelationshipType.JOINT_AGENT) + .relAnchor(givenAnchorPerson) + .relHolder(givenHolderPerson) + .contact(givenContact) + .build(); + + toCleanup(newRelationship); + + return relationshipRepo.save(newRelationship); + }).assertSuccessful().returnedValue(); + } + + private HsOfficeRelationshipEntity toCleanup(final HsOfficeRelationshipEntity tempRelationship) { + tempRelationships.add(tempRelationship); + return tempRelationship; + } + + @AfterEach + void cleanup() { + context("superuser-alex@hostsharing.net", null); + tempRelationships.forEach(tempRelationship -> { + System.out.println("DELETING temporary relationship: " + tempRelationship); + relationshipRepo.deleteByUuid(tempRelationship.getUuid()); + }); + } + + void exactlyTheseRelationshipsAreReturned( + final List actualResult, + final String... relationshipNames) { + assertThat(actualResult) + .extracting(HsOfficeRelationshipEntity::toString) + .containsExactlyInAnyOrder(relationshipNames); + } + + void allTheseRelationshipsAreReturned( + final List actualResult, + final String... relationshipNames) { + assertThat(actualResult) + .extracting(HsOfficeRelationshipEntity::toString) + .contains(relationshipNames); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java index fa9fc7ad..ccdd332c 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java @@ -38,7 +38,7 @@ public class RawRbacGrantEntity { @Column(name = "descendantidname", updatable = false, insertable = false) private String descendantIdName; - @Column(name = "descenantuuid", updatable = false, insertable = false) + @Column(name = "descendantuuid", updatable = false, insertable = false) private UUID descendantUuid; @Column(name = "assumed", updatable = false, insertable = false) diff --git a/src/test/java/net/hostsharing/test/Array.java b/src/test/java/net/hostsharing/test/Array.java index 1c98ce57..321efafe 100644 --- a/src/test/java/net/hostsharing/test/Array.java +++ b/src/test/java/net/hostsharing/test/Array.java @@ -3,6 +3,7 @@ package net.hostsharing.test; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; /** * Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter, @@ -17,13 +18,19 @@ public class Array { public static String[] from(final List initialList, final String... additionalStrings) { final var resultList = new ArrayList<>(initialList); - resultList.addAll(Arrays.asList(additionalStrings)); + resultList.addAll(Arrays.stream(additionalStrings).toList()); + return resultList.toArray(String[]::new); + } + + public static String[] fromSkippingNull(final List initialList, final String... additionalStrings) { + final var resultList = new ArrayList<>(initialList); + resultList.addAll(Arrays.stream(additionalStrings).filter(Objects::nonNull).toList()); return resultList.toArray(String[]::new); } public static String[] from(final String[] initialStrings, final String... additionalStrings) { final var resultList = Arrays.asList(initialStrings); - resultList.addAll(Arrays.asList(additionalStrings)); + resultList.addAll(Arrays.stream(additionalStrings).toList()); return resultList.toArray(String[]::new); } diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/test/JpaAttempt.java index 7ffb270d..6a71711c 100644 --- a/src/test/java/net/hostsharing/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/test/JpaAttempt.java @@ -124,7 +124,7 @@ public class JpaAttempt { public void assertExceptionWithRootCauseMessage( final Class expectedExceptionClass, final String... expectedRootCauseMessages) { - assertThat(wasSuccessful()).isFalse(); + assertThat(wasSuccessful()).as("wasSuccessful").isFalse(); final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass)); for (String expectedRootCauseMessage : expectedRootCauseMessages) { assertThat(firstRootCauseMessageLine).contains(expectedRootCauseMessage); diff --git a/tools/generate b/tools/generate index ea23a83a..93aa5c7c 100755 --- a/tools/generate +++ b/tools/generate @@ -1,47 +1,41 @@ #!/bin/bash -mkdir -p src/test/java/net/hostsharing/hsadminng/hs/office/partner +sourceLower=partner +targetLower=relationship -sed -e 's/hs-admin-contact/hs-office-partner/g' \ - -e 's/hs_admin_contact/hs_office_partner/g' \ - -e 's/HsOfficeContact/HsOfficePartner/g' \ - -e 's/HsOfficeContact/HsOfficePartner/g' \ - -e 's/contact/partner/g' \ -src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java +sourceStudly=Partner +targetStudly=Relationship + +## for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml -o -iname \*.sql -o -iname \*.java \)`; do +for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml \)`; do + target=`echo $source | sed -e "s/$sourceStudly/$targetStudly/g" -e "s/$sourceLower/$targetLower/g"` + echo "Generating $target from $source:" + + mkdir -p `dirname $target` + + sed -e 's/hs-office-partner/hs-office-relationship/g' \ + -e 's/hs_office_partner/hs_office_relationship/g' \ + -e 's/HsOfficePartner/HsOfficeRelationship/g' \ + -e 's/hsOfficePartner/hsOfficeRelationship/g' \ + -e 's/partner/relationship/g' \ + \ + -e 's/addPartner/addRelationship/g' \ + -e 's/listPartners/listRelationships/g' \ + -e 's/getPartnerByUuid/getRelationshipByUuid/g' \ + -e 's/patchPartner/patchRelationship/g' \ + -e 's/person/relHolder/g' \ + -e 's/registrationOffice/relType/g' \ + <$source >$target + +done exit -sed -e 's/hs-admin-contact/hs-office-partner/g' \ - -e 's/hs_admin_contact/hs_office_partner/g' \ - src/main/resources/db/changelog/220-hs-office-partner.sql - -sed -e 's/hs-admin-contact/hs-office-partner/g' \ - -e 's/hs_admin_contact/hs_office_partner/g' \ - -e 's/HsAdminCustomer/HsOfficePartner/g' \ - -e 's/HsOfficeContact/HsOfficePartner/g' \ - -e 's/contact/partner/g' \ - src/main/resources/db/changelog/223-hs-office-partner-rbac.sql - -sed -e 's/hs-admin-contact/hs-office-partner/g' \ - -e 's/hs_admin_contact/hs_office_partner/g' \ - -e 's/HsOfficeContact/HsOfficePartner/g' \ - -e 's/HsOfficeContact/HsOfficePartner/g' \ - -e 's/contact/partner/g' \ - src/main/resources/db/changelog/228-hs-office-partner-test-data.sql - - -# mkdir -p src/main/java/net/hostsharing/hsadminng/hs/office/partner -# -# sed -e 's/HsOfficeContactEntity/HsOfficePartnerEntity/g' \ -# sed -e 's/admin.contact/admin.partner/g' \ -# src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java - cat >>src/main/resources/db/changelog/db.changelog-master.yaml <