diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java new file mode 100644 index 00000000..b068fdf5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java @@ -0,0 +1,88 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType; +import com.vladmihalcea.hibernate.type.range.PostgreSQLRangeType; +import com.vladmihalcea.hibernate.type.range.Range; +import lombok.*; +import net.hostsharing.hsadminng.Stringify; +import net.hostsharing.hsadminng.Stringifyable; +import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; + +import javax.persistence.*; +import java.time.LocalDate; +import java.util.UUID; + +import static net.hostsharing.hsadminng.Stringify.stringify; + +@Entity +@Table(name = "hs_office_membership_rv") +@TypeDef( + name = "pgsql_enum", + typeClass = PostgreSQLEnumType.class +) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DisplayName("Membership") +@TypeDef( + typeClass = PostgreSQLRangeType.class, + defaultForType = Range.class +) +public class HsOfficeMembershipEntity implements Stringifyable { + + private static Stringify stringify = stringify(HsOfficeMembershipEntity.class) + .withProp(HsOfficeMembershipEntity::getMemberNumber) + .withProp(e -> e.getPartner().toShortString()) + .withProp(e -> e.getMainDebitor().toShortString()) + .withProp(e -> e.getValidity().asString()) + .withProp(HsOfficeMembershipEntity::getReasonForTermination) + .withSeparator(", ") + .quotedValues(false); + + private @Id UUID uuid; + + @ManyToOne + @JoinColumn(name = "partneruuid") + private HsOfficePartnerEntity partner; + + @ManyToOne + @Fetch(FetchMode.JOIN) + @JoinColumn(name = "maindebitoruuid") + private HsOfficeDebitorEntity mainDebitor; + + @Column(name = "membernumber") + private int memberNumber; + + @Column(name = "validity", columnDefinition = "daterange") + private Range validity; + + @Column(name = "reasonfortermination") + @Enumerated(EnumType.STRING) + @Type(type = "pgsql_enum") + private HsOfficeReasonForTermination reasonForTermination; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return String.valueOf(memberNumber); + } + + @PrePersist + void init() { + if (getReasonForTermination() == null) { + setReasonForTermination(HsOfficeReasonForTermination.NONE); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepository.java new file mode 100644 index 00000000..62d0bbfc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepository.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeMembershipRepository extends Repository { + + Optional findByUuid(UUID id); + + @Query(""" + SELECT membership FROM HsOfficeMembershipEntity membership + WHERE :memberNumber is null + OR membership.memberNumber = :memberNumber + ORDER BY membership.memberNumber + """) + List findMembershipByOptionalMemberNumber(Integer memberNumber); + + List findMembershipsByPartnerUuid(UUID partnerUuid); + + HsOfficeMembershipEntity save(final HsOfficeMembershipEntity entity); + + long count(); + + int deleteByUuid(UUID uuid); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java new file mode 100644 index 00000000..c043152d --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java @@ -0,0 +1,5 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +public enum HsOfficeReasonForTermination { + NONE, CANCELLATION, TRANSFER, DEATH, LIQUIDATION, EXPULSION; +} diff --git a/src/main/resources/db/changelog/300-hs-office-membership.sql b/src/main/resources/db/changelog/300-hs-office-membership.sql index d7c04347..9c826891 100644 --- a/src/main/resources/db/changelog/300-hs-office-membership.sql +++ b/src/main/resources/db/changelog/300-hs-office-membership.sql @@ -15,7 +15,7 @@ create table if not exists hs_office_membership mainDebitorUuid uuid not null references hs_office_debitor(uuid), memberNumber numeric(5) not null, validity daterange not null, - reasonForTermination HsOfficeReasonForTermination not null + reasonForTermination HsOfficeReasonForTermination not null default 'NONE' ); --// diff --git a/src/main/resources/db/changelog/303-hs-office-membership-rbac.md b/src/main/resources/db/changelog/303-hs-office-membership-rbac.md index 3ba354a1..8cf604ab 100644 --- a/src/main/resources/db/changelog/303-hs-office-membership-rbac.md +++ b/src/main/resources/db/changelog/303-hs-office-membership-rbac.md @@ -51,11 +51,13 @@ subgraph hsOfficeMembership role:hsOfficeDebitor.admin --> role:hsOfficeMembership.agent %% outgoing role:hsOfficeMembership.agent --> role:hsOfficePartner.tenant - role:hsOfficeMembership.admin --> role:hsOfficeDebitor.tenant + role:hsOfficeMembership.agent --> role:hsOfficeDebitor.tenant role:hsOfficeMembership.tenant[membership.tenant] %% incoming role:hsOfficeMembership.agent --> role:hsOfficeMembership.tenant + role:hsOfficePartner.agent --> role:hsOfficeMembership.tenant + role:hsOfficeDebitor.agent --> role:hsOfficeMembership.tenant %% outgoing role:hsOfficeMembership.tenant --> role:hsOfficePartner.guest role:hsOfficeMembership.tenant --> role:hsOfficeDebitor.guest @@ -65,6 +67,8 @@ subgraph hsOfficeMembership role:hsOfficeMembership.guest --> perm:hsOfficeMembership.view{{membership.view}} %% incoming role:hsOfficeMembership.tenant --> role:hsOfficeMembership.guest + role:hsOfficePartner.tenant --> role:hsOfficeMembership.guest + role:hsOfficeDebitor.tenant --> role:hsOfficeMembership.guest end diff --git a/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql b/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql index 0671e32d..4335c32d 100644 --- a/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql +++ b/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql @@ -47,26 +47,25 @@ begin perform createRoleWithGrants( hsOfficeMembershipAdmin(NEW), permissions => array['edit'], - incomingSuperRoles => array[hsOfficeMembershipOwner(NEW)], - outgoingSubRoles => array[hsOfficeDebitorTenant(newHsOfficeDebitor)] + incomingSuperRoles => array[hsOfficeMembershipOwner(NEW)] ); perform createRoleWithGrants( hsOfficeMembershipAgent(NEW), incomingSuperRoles => array[hsOfficeMembershipAdmin(NEW), hsOfficePartnerAdmin(newHsOfficePartner), hsOfficeDebitorAdmin(newHsOfficeDebitor)], - outgoingSubRoles => array[hsOfficePartnerTenant(newHsOfficePartner)] + outgoingSubRoles => array[hsOfficePartnerTenant(newHsOfficePartner), hsOfficeDebitorTenant(newHsOfficeDebitor)] ); perform createRoleWithGrants( hsOfficeMembershipTenant(NEW), - incomingSuperRoles => array[hsOfficeMembershipAgent(NEW)], + incomingSuperRoles => array[hsOfficeMembershipAgent(NEW), hsOfficePartnerAgent(newHsOfficePartner), hsOfficeDebitorAgent(newHsOfficeDebitor)], outgoingSubRoles => array[hsOfficePartnerGuest(newHsOfficePartner), hsOfficeDebitorGuest(newHsOfficeDebitor)] ); perform createRoleWithGrants( hsOfficeMembershipGuest(NEW), permissions => array['view'], - incomingSuperRoles => array[hsOfficeMembershipTenant(NEW)] + incomingSuperRoles => array[hsOfficeMembershipTenant(NEW), hsOfficePartnerTenant(newHsOfficePartner), hsOfficeDebitorTenant(newHsOfficeDebitor)] ); -- === END of code generated from Mermaid flowchart. === diff --git a/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql b/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql index 7cffb9ea..5b229466 100644 --- a/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql +++ b/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql @@ -18,7 +18,7 @@ declare newMemberNumber numeric; begin idName := cleanIdentifier( forPartnerTradeName || '#' || forMainDebitorNumber); - currentTask := 'creating SEPA-mandate test-data ' || idName; + currentTask := 'creating Membership test-data ' || idName; call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); execute format('set local hsadminng.currentTask to %L', currentTask); @@ -28,7 +28,7 @@ begin select d.* from hs_office_debitor d where d.debitorNumber = forMainDebitorNumber into relatedDebitor; select coalesce(max(memberNumber)+1, 10001) from hs_office_membership into newMemberNumber; - raise notice 'creating test SEPA-mandate: %', idName; + raise notice 'creating test Membership: %', idName; raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; insert diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index 2563ee17..15716bc3 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -84,7 +84,14 @@ public class ArchitectureTest { public static final ArchRule HsOfficePartnerPackageRule = classes() .that().resideInAPackage("..hs.office.partner..") .should().onlyBeAccessed().byClassesThat() - .resideInAnyPackage("..hs.office.partner..", "..hs.office.debitor.."); + .resideInAnyPackage("..hs.office.partner..", "..hs.office.debitor..", "..hs.office.membership.."); + + @ArchTest + @SuppressWarnings("unused") + public static final ArchRule HsOfficeMembershipPackageRule = classes() + .that().resideInAPackage("..hs.office.membership..") + .should().onlyBeAccessed().byClassesThat() + .resideInAnyPackage("..hs.office.membership.."); @ArchTest @SuppressWarnings("unused") diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java new file mode 100644 index 00000000..52ce7b1c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java @@ -0,0 +1,64 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +import com.vladmihalcea.hibernate.type.range.Range; +import org.junit.jupiter.api.Test; + +import javax.persistence.PrePersist; +import java.lang.reflect.InvocationTargetException; +import java.time.LocalDate; +import java.util.Arrays; + +import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.testDebitor; +import static net.hostsharing.hsadminng.hs.office.partner.TestHsOfficePartner.testPartner; +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeMembershipEntityUnitTest { + + final HsOfficeMembershipEntity givenMembership = HsOfficeMembershipEntity.builder() + .memberNumber(10001) + .partner(testPartner) + .mainDebitor(testDebitor) + .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .build(); + + @Test + void toStringContainsAllProps() { + final var result = givenMembership.toString(); + + assertThat(result).isEqualTo("Membership(10001, Test Ltd., 10001, [2020-01-01,))"); + } + + @Test + void toShortStringContainsMemberNumberOnly() { + final var result = givenMembership.toShortString(); + + assertThat(result).isEqualTo("10001"); + } + + @Test + void initializesReasonForTerminationInPrePersistIfNull() throws Exception { + final var givenUninitializedMembership = new HsOfficeMembershipEntity(); + assertThat(givenUninitializedMembership.getReasonForTermination()).as("precondition failed").isNull(); + + invokePrePersist(givenUninitializedMembership); + assertThat(givenUninitializedMembership.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.NONE); + } + + @Test + void doesNotOverwriteReasonForTerminationInPrePersistIfNotNull() throws Exception { + givenMembership.setReasonForTermination(HsOfficeReasonForTermination.CANCELLATION); + + invokePrePersist(givenMembership); + assertThat(givenMembership.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.CANCELLATION); + } + + private static void invokePrePersist(final HsOfficeMembershipEntity membershipEntity) + throws IllegalAccessException, InvocationTargetException { + final var prePersistMethod = Arrays.stream(HsOfficeMembershipEntity.class.getDeclaredMethods()) + .filter(f -> f.getAnnotation(PrePersist.class) != null) + .findFirst(); + assertThat(prePersistMethod).as("@PrePersist method not found").isPresent(); + + prePersistMethod.get().invoke(membershipEntity); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java new file mode 100644 index 00000000..5979fa25 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -0,0 +1,456 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +import com.vladmihalcea.hibernate.type.range.Range; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +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.BeforeEach; +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.time.LocalDate; +import java.util.*; + +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 = { HsOfficeMembershipRepository.class, Context.class, JpaAttempt.class }) +@DirtiesContext +class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { + + @Autowired + HsOfficeMembershipRepository membershipRepo; + + @Autowired + HsOfficePartnerRepository partnerRepo; + + @Autowired + HsOfficeDebitorRepository debitorRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + EntityManager em; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + Set tempEntities = new HashSet<>(); + + @Nested + class CreateMembership { + + @Test + public void globalAdmin_withoutAssumedRole_canCreateNewMembership() { + // given + context("superuser-alex@hostsharing.net"); + final var count = membershipRepo.count(); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + + // when + final var result = attempt(em, () -> { + final var newMembership = toCleanup(HsOfficeMembershipEntity.builder() + .uuid(UUID.randomUUID()) + .memberNumber(20001) + .partner(givenPartner) + .mainDebitor(givenDebitor) + .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .build()); + return membershipRepo.save(newMembership); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeMembershipEntity::getUuid).isNotNull(); + assertThatMembershipIsPersisted(result.returnedValue()); + assertThat(membershipRepo.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()).stream() + .map(s -> s.replace("GmbH-firstcontact", "")) + .map(s -> s.replace("hs_office_", "")) + .toList(); + + // when + attempt(em, () -> { + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + final var newMembership = toCleanup(HsOfficeMembershipEntity.builder() + .uuid(UUID.randomUUID()) + .memberNumber(20002) + .partner(givenPartner) + .mainDebitor(givenDebitor) + .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .build()); + return membershipRepo.save(newMembership); + }); + + // then + final var all = rawRoleRepo.findAll(); + assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_office_membership#20002FirstGmbH-firstcontact.admin", + "hs_office_membership#20002FirstGmbH-firstcontact.agent", + "hs_office_membership#20002FirstGmbH-firstcontact.guest", + "hs_office_membership#20002FirstGmbH-firstcontact.owner", + "hs_office_membership#20002FirstGmbH-firstcontact.tenant")); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())) + .map(s -> s.replace("GmbH-firstcontact", "")) + .map(s -> s.replace("hs_office_", "")) + .containsExactlyInAnyOrder(Array.fromFormatted( + initialGrantNames, + + // owner + "{ grant perm * on membership#20002First to role membership#20002First.owner by system and assume }", + "{ grant role membership#20002First.owner to role global#global.admin by system and assume }", + + // admin + "{ grant perm edit on membership#20002First to role membership#20002First.admin by system and assume }", + "{ grant role membership#20002First.admin to role membership#20002First.owner by system and assume }", + + // agent + "{ grant role membership#20002First.agent to role membership#20002First.admin by system and assume }", + "{ grant role partner#First.tenant to role membership#20002First.agent by system and assume }", + "{ grant role membership#20002First.agent to role debitor#10001First.admin by system and assume }", + "{ grant role membership#20002First.agent to role partner#First.admin by system and assume }", + "{ grant role debitor#10001First.tenant to role membership#20002First.agent by system and assume }", + + // tenant + "{ grant role membership#20002First.tenant to role membership#20002First.agent by system and assume }", + "{ grant role partner#First.guest to role membership#20002First.tenant by system and assume }", + "{ grant role debitor#10001First.guest to role membership#20002First.tenant by system and assume }", + "{ grant role membership#20002First.tenant to role debitor#10001First.agent by system and assume }", + "{ grant role membership#20002First.tenant to role partner#First.agent by system and assume }", + + // guest + "{ grant perm view on membership#20002First to role membership#20002First.guest by system and assume }", + "{ grant role membership#20002First.guest to role membership#20002First.tenant by system and assume }", + "{ grant role membership#20002First.guest to role partner#First.tenant by system and assume }", + "{ grant role membership#20002First.guest to role debitor#10001First.tenant by system and assume }", + + null)); + } + + private void assertThatMembershipIsPersisted(final HsOfficeMembershipEntity saved) { + final var found = membershipRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + } + } + + @Nested + class FindByPartnerUuidMemberships { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllMemberships() { + // given + context("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + + // when + final var result = membershipRepo.findMembershipsByPartnerUuid(givenPartner.getUuid()); + + // then + allTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)"); + } + + @Test + public void normalUser_canViewOnlyRelatedMemberships() { + // given: + context("person-FirstGmbH@example.com"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + + // when: + final var result = membershipRepo.findMembershipsByPartnerUuid(givenPartner.getUuid()); + + // then: + exactlyTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)"); + } + } + + @Nested + class FindByOptionalMemberNumber { + + @Test + public void globalAdmin_canViewArbitraryMembership() { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = membershipRepo.findMembershipByOptionalMemberNumber(10002); + + // then + exactlyTheseMembershipsAreReturned(result, "Membership(10002, Second e.K., 10002, [2022-10-01,), NONE)"); + } + + @Test + public void debitorAdmin_canViewRelatedMemberships() { + // given + // context("person-FirstGmbH@example.com"); + context("superuser-alex@hostsharing.net", "hs_office_partner#FirstGmbH-firstcontact.agent"); + // context("superuser-alex@hostsharing.net", "hs_office_debitor#10001FirstGmbH-firstcontact.agent"); + // context("superuser-alex@hostsharing.net", "hs_office_membership#10001FirstGmbH-firstcontact.admin"); + + // when + final var result = membershipRepo.findMembershipByOptionalMemberNumber(null); + + // then + exactlyTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)"); + } + } + + @Nested + class UpdateMembership { + + @Test + public void globalAdmin_canUpdateValidityOfArbitraryMembership() { + // given + context("superuser-alex@hostsharing.net"); + final var givenMembership = givenSomeTemporaryMembership("First", "First"); + assertThatMembershipIsVisibleForUserWithRole( + givenMembership, + "hs_office_debitor#10001FirstGmbH-firstcontact.admin"); + assertThatMembershipExistsAndIsAccessibleToCurrentContext(givenMembership); + final var newValidityEnd = LocalDate.now(); + + // when + context("superuser-alex@hostsharing.net"); + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + givenMembership.setValidity(Range.closedOpen( + givenMembership.getValidity().lower(), newValidityEnd)); + givenMembership.setReasonForTermination(HsOfficeReasonForTermination.CANCELLATION); + return toCleanup(membershipRepo.save(givenMembership)); + }); + + // then + result.assertSuccessful(); + + membershipRepo.deleteByUuid(givenMembership.getUuid()); + } + + @Test + public void debitorAdmin_canViewButNotUpdateRelatedMembership() { + // given + context("superuser-alex@hostsharing.net"); + final var givenMembership = givenSomeTemporaryMembership("First", "First"); + assertThatMembershipIsVisibleForUserWithRole( + givenMembership, + "hs_office_debitor#10001FirstGmbH-firstcontact.admin"); + assertThatMembershipExistsAndIsAccessibleToCurrentContext(givenMembership); + final var newValidityEnd = LocalDate.now(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_debitor#10001FirstGmbH-firstcontact.admin"); + givenMembership.setValidity(Range.closedOpen( + givenMembership.getValidity().lower(), newValidityEnd)); + return membershipRepo.save(givenMembership); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_membership uuid"); + } + + private void assertThatMembershipExistsAndIsAccessibleToCurrentContext(final HsOfficeMembershipEntity saved) { + final var found = membershipRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved); + } + + private void assertThatMembershipIsVisibleForUserWithRole( + final HsOfficeMembershipEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + assertThatMembershipExistsAndIsAccessibleToCurrentContext(entity); + }).assertSuccessful(); + } + + private void assertThatMembershipIsNotVisibleForUserWithRole( + final HsOfficeMembershipEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + final var found = membershipRepo.findByUuid(entity.getUuid()); + assertThat(found).isEmpty(); + }).assertSuccessful(); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyMembership() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenMembership = givenSomeTemporaryMembership("First", "Second"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + membershipRepo.deleteByUuid(givenMembership.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return membershipRepo.findByUuid(givenMembership.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void nonGlobalAdmin_canNotDeleteTheirRelatedMembership() { + // given + context("superuser-alex@hostsharing.net"); + final var givenMembership = givenSomeTemporaryMembership("First", "Third"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_debitor#10003ThirdOHG-thirdcontact.admin"); + assumeThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent(); + + membershipRepo.deleteByUuid(givenMembership.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " not allowed to delete hs_office_membership"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return membershipRepo.findByUuid(givenMembership.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingAMembershipAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); + final var givenMembership = givenSomeTemporaryMembership("First", "First"); + assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created") + .isEqualTo(initialRoleNames.length + 5); + assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created") + .isEqualTo(initialGrantNames.length + 18); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return membershipRepo.deleteByUuid(givenMembership.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select c.currenttask, j.targettable, j.targetop + from tx_journal j + join tx_context c on j.contextId = c.contextId + where targettable = 'hs_office_membership'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating Membership test-data FirstGmbH10001, hs_office_membership, INSERT]", + "[creating Membership test-data Seconde.K.10002, hs_office_membership, INSERT]"); + } + + @BeforeEach + @AfterEach + void cleanup() { + tempEntities.forEach(tempMembership -> { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + System.out.println("DELETING temporary membership: " + tempMembership.toString()); + membershipRepo.deleteByUuid(tempMembership.getUuid()); + }); + }); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + em.createQuery("DELETE FROM HsOfficeMembershipEntity WHERE memberNumber >= 20000"); + }); + } + + private HsOfficeMembershipEntity givenSomeTemporaryMembership(final String partnerTradeName, final String debitorName) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partnerTradeName).get(0); + final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).get(0); + final var newMembership = HsOfficeMembershipEntity.builder() + .uuid(UUID.randomUUID()) + .memberNumber(20002) + .partner(givenPartner) + .mainDebitor(givenDebitor) + .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .build(); + + toCleanup(newMembership); + + return membershipRepo.save(newMembership); + }).assertSuccessful().returnedValue(); + } + + private HsOfficeMembershipEntity toCleanup(final HsOfficeMembershipEntity tempEntity) { + tempEntities.add(tempEntity); + return tempEntity; + } + + void exactlyTheseMembershipsAreReturned( + final List actualResult, + final String... membershipNames) { + assertThat(actualResult) + .extracting(membershipEntity -> membershipEntity.toString()) + .containsExactlyInAnyOrder(membershipNames); + } + + void allTheseMembershipsAreReturned(final List actualResult, final String... membershipNames) { + assertThat(actualResult) + .extracting(membershipEntity -> membershipEntity.toString()) + .contains(membershipNames); + } +}