Michael Hoennig
2022-10-17 e6f9484f99551194292b7a9acaba313a467fc4f2
add hs-office-membership entity+repo + fix rbac
5 files added
5 files modified
672 ■■■■■ changed files
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java 88 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepository.java 29 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java 5 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/300-hs-office-membership.sql 2 ●●● patch | view | raw | blame | history
src/main/resources/db/changelog/303-hs-office-membership-rbac.md 6 ●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/303-hs-office-membership-rbac.sql 9 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/308-hs-office-membership-test-data.sql 4 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java 9 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java 64 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java 456 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java
New file
@@ -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<HsOfficeMembershipEntity> 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<LocalDate> 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);
        }
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepository.java
New file
@@ -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<HsOfficeMembershipEntity, UUID> {
    Optional<HsOfficeMembershipEntity> findByUuid(UUID id);
    @Query("""
            SELECT membership FROM HsOfficeMembershipEntity membership
                WHERE :memberNumber is null
                    OR membership.memberNumber = :memberNumber
                ORDER BY membership.memberNumber
               """)
    List<HsOfficeMembershipEntity> findMembershipByOptionalMemberNumber(Integer memberNumber);
    List<HsOfficeMembershipEntity> findMembershipsByPartnerUuid(UUID partnerUuid);
    HsOfficeMembershipEntity save(final HsOfficeMembershipEntity entity);
    long count();
    int deleteByUuid(UUID uuid);
}
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java
New file
@@ -0,0 +1,5 @@
package net.hostsharing.hsadminng.hs.office.membership;
public enum HsOfficeReasonForTermination {
    NONE, CANCELLATION, TRANSFER, DEATH, LIQUIDATION, EXPULSION;
}
src/main/resources/db/changelog/300-hs-office-membership.sql
@@ -15,7 +15,7 @@
    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'
);
--//
src/main/resources/db/changelog/303-hs-office-membership-rbac.md
@@ -51,11 +51,13 @@
       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 @@
       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
src/main/resources/db/changelog/303-hs-office-membership-rbac.sql
@@ -47,26 +47,25 @@
        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. ===
src/main/resources/db/changelog/308-hs-office-membership-test-data.sql
@@ -18,7 +18,7 @@
    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 @@
    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
src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
@@ -84,7 +84,14 @@
    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")
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java
New file
@@ -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);
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java
New file
@@ -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<HsOfficeMembershipEntity> 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<Object[]> 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<HsOfficeMembershipEntity> actualResult,
            final String... membershipNames) {
        assertThat(actualResult)
                .extracting(membershipEntity -> membershipEntity.toString())
                .containsExactlyInAnyOrder(membershipNames);
    }
    void allTheseMembershipsAreReturned(final List<HsOfficeMembershipEntity> actualResult, final String... membershipNames) {
        assertThat(actualResult)
                .extracting(membershipEntity -> membershipEntity.toString())
                .contains(membershipNames);
    }
}