From c0991d96d9917739d305e0ebf151649602b1fe91 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 1 Sep 2025 12:13:58 +0200 Subject: [PATCH] credentials validation (#194) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/194 Reviewed-by: Marc Sandlus --- ...edentialContextResourceToEntityMapper.java | 2 +- .../hs/accounts/HsCredentialsContext.java | 6 +- .../HsCredentialsContextsController.java | 6 +- .../hs/accounts/HsCredentialsController.java | 164 ++++-- .../hs/accounts/HsCredentialsEntity.java | 4 +- .../person/HsOfficePersonRealRepository.java | 16 + .../hsadminng/ping/PingController.java | 2 +- .../rbac/subject/RbacSubjectController.java | 3 +- .../rbac/subject/RbacSubjectRepository.java | 4 + .../accounts/credentials-with-uuid-used.yaml | 1 - .../resources/i18n/messages_de.properties | 7 +- .../resources/i18n/messages_en.properties | 7 +- .../resources/i18n/messages_fr.properties | 7 +- ...sCredentialsContextRbacEntityUnitTest.java | 15 +- ...sCredentialsContextRealEntityUnitTest.java | 21 +- ...CredentialsContextsControllerRestTest.java | 131 ++++- ...HsCredentialsControllerAcceptanceTest.java | 475 ++++++++++++++++++ .../HsCredentialsControllerRestTest.java | 314 ------------ ...sCredentialsRepositoryIntegrationTest.java | 20 +- ...cePersonRealRepositoryIntegrationTest.java | 35 +- .../persistence/EntityManagerWrapperFake.java | 1 - .../RbacSubjectControllerRestTest.java | 9 + .../test/ContextBasedTestWithCleanup.java | 19 +- 23 files changed, 849 insertions(+), 420 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerAcceptanceTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java index a0ab46e1..3807ed6e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java @@ -60,7 +60,7 @@ public class CredentialContextResourceToEntityMapper { (resource.getQualifier() != null && !existingContextEntity.getQualifier().equals(resource.getQualifier()))) { throw new EntityNotFoundException( messageTranslator.translate( - "credentials.existing-{0}-does-not-match-given-resource-{1}", + "credentials.existing-credentials-context-{0}-does-not-match-given-resource-{1}", existingContextEntity, resource)); } entities.add(existingContextEntity); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java index d6cbcc23..c1ed0cc0 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java @@ -61,9 +61,13 @@ public abstract class HsCredentialsContext implements Stringifyable, BaseEntity< @Column(name = "public_access") private boolean publicAccess; + public boolean isHsadminContext() { + return "HSADMIN".equals(type); + } + @Override public String toShortString() { - return toString(); + return type + (qualifier != null ? ":" + qualifier : ""); } @Override diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContextsController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContextsController.java index b61841dd..4b499315 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContextsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContextsController.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.accounts; import java.util.List; import io.micrometer.core.annotation.Timed; +import lombok.val; import net.hostsharing.hsadminng.config.NoSecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ContextsApi; @@ -37,7 +38,10 @@ public class HsCredentialsContextsController implements ContextsApi { if (SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) { context.assumeRoles(assumedRoles); } - final var loginContexts = contextRepo.findAll(); + val isGlobalAdmin = context.isGlobalAdmin(); + final var loginContexts = contextRepo.findAll().stream().filter( + context -> context.isPublicAccess() || isGlobalAdmin + ).toList(); final var result = mapper.mapList(loginContexts, ContextResource.class); return ResponseEntity.ok(result); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java index 23de6056..01e21368 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java @@ -5,9 +5,11 @@ import java.time.ZoneOffset; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; +import java.util.stream.Collectors; import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.val; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ContextResource; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource; @@ -19,7 +21,7 @@ import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsPatc import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsResource; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; @@ -62,7 +64,7 @@ public class HsCredentialsController implements CredentialsApi { private MessageTranslator messageTranslator; @Autowired - private HsOfficePersonRbacRepository rbacPersonRepo; + private HsOfficePersonRealRepository realPersonRepo; @Autowired private HsCredentialsRepository credentialsRepo; @@ -77,11 +79,11 @@ public class HsCredentialsController implements CredentialsApi { context.define(); // without assumed roles, otherwise we cannot access the subject anymore - final var credentialsEntity = credentialsRepo.findByUuid(credentialsUuid); + val credentialsEntity = credentialsRepo.findByUuid(credentialsUuid); if (credentialsEntity.isEmpty()) { return ResponseEntity.notFound().build(); } - final var result = mapper.map( + val result = mapper.map( credentialsEntity.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(result); } @@ -95,10 +97,10 @@ public class HsCredentialsController implements CredentialsApi { ) { context.assumeRoles(assumedRoles); - final var credentials = personUuid == null + val credentials = personUuid == null ? credentialsRepo.findByCurrentSubject() : findByPersonUuid(personUuid); - final var result = mapper.mapList( + val result = mapper.mapList( credentials, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(result); } @@ -112,22 +114,25 @@ public class HsCredentialsController implements CredentialsApi { context.define(); // without assumed roles, otherwise we cannot access the subject anymore // first create and save the subject to get its UUID - final var newlySavedSubject = createSubject(body.getNickname()); + val newlySavedSubject = createSubject(body.getNickname()); // afterward, create and save the credentials entity with the subject's UUID - final var newCredentialsEntity = mapper.map( + val newCredentialsEntity = mapper.map( body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - validate(newCredentialsEntity); - newCredentialsEntity.setSubject(newlySavedSubject); + validateOnCreate(newCredentialsEntity); + + // switch to the new subject to get access to its own subject RBAC object + context.define("activate newly created self-service subject", null, body.getNickname(), null); + newCredentialsEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository! // return the new credentials as a resource - final var uri = + val uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/hs/accounts/credentials/{id}") .buildAndExpand(newCredentialsEntity.getUuid()) .toUri(); - final var newCredentialsResource = mapper.map( + val newCredentialsResource = mapper.map( newCredentialsEntity, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.created(uri).body(newCredentialsResource); } @@ -137,8 +142,9 @@ public class HsCredentialsController implements CredentialsApi { @Timed("app.credentials.credentials.deleteCredentialsByUuid") public ResponseEntity deleteCredentialsByUuid(final UUID credentialsUuid) { context.define(); // without assumed roles, otherwise we cannot access the subject anymore - final var credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid); + val credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid); credentialsEntity.getLoginContexts().clear(); + validateOnDelete(credentialsEntity); em.flush(); em.remove(credentialsEntity); em.remove(credentialsEntity.getSubject()); @@ -154,12 +160,13 @@ public class HsCredentialsController implements CredentialsApi { ) { context.define(); // without assumed roles, otherwise we cannot access the subject anymore - final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); + val current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); new HsCredentialsEntityPatcher(contextMapper, current).apply(body); + validateOnUpdate(current); - final var saved = credentialsRepo.save(current); - final var mapped = mapper.map( + val saved = credentialsRepo.save(current); + val mapped = mapper.map( saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(mapped); } @@ -173,56 +180,129 @@ public class HsCredentialsController implements CredentialsApi { context.define(); // fetch the data - final var currentSubjectUuid = context.fetchCurrentSubjectUuid(); - final var currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); + val currentSubjectUuid = context.fetchCurrentSubjectUuid(); + val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); + val person = credentialsRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson(); + final boolean isGlobalAdmin = context.isGlobalAdmin(); - final var person = credentialsRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson(); // finally, return the result - final var result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin); + val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin); return ResponseEntity.ok(result); } @Override + @Transactional @Timed("app.credentials.credentials.credentialsUsed") - public ResponseEntity credentialsUsed( - final String assumedRoles, - final UUID credentialsUuid) { - context.assumeRoles(assumedRoles); + public ResponseEntity credentialsUsed(final UUID credentialsUuid) { + context.define(); - final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); + val current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); current.setOnboardingToken(null); current.setLastUsed(LocalDateTime.now()); - final var saved = credentialsRepo.save(current); - final var mapped = mapper.map(saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + val saved = credentialsRepo.save(current); + val mapped = mapper.map(saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(mapped); } - private void validate(final HsCredentialsEntity newCredentialsEntity) { - // the referenced person must be represented by currently logged in person - final var personUuid = newCredentialsEntity.getPerson().getUuid(); - final var representedPersonUuids = rbacPersonRepo.findPersonsRepresentedByPersonWithUuid(personUuid) + private void validateOnCreate(final HsCredentialsEntity newCredentialsEntity) { + validateReferencedPersonToBeRepresentedByLoginUserPerson(newCredentialsEntity); + validateNormalUsersOnlyAccessPublicContexts(newCredentialsEntity); + validateNaturalPersonRequirementOfContexts(newCredentialsEntity); + } + + private void validateOnUpdate(final HsCredentialsEntity current) { + validateNormalUsersOnlyAccessPublicContexts(current); + validateNaturalPersonRequirementOfContexts(current); + validateOwnHsadminCredentialsMustNotBeRemoved(current); + } + + private void validateOnDelete(final HsCredentialsEntity credentialsEntity) { + validateOwnHsadminCredentialsMustNotBeRemoved(credentialsEntity); + } + + private void validateReferencedPersonToBeRepresentedByLoginUserPerson(final HsCredentialsEntity newCredentialsEntity) { + if (context.isGlobalAdmin()) { + return; + } + val referredPersonUuid = newCredentialsEntity.getPerson().getUuid(); + val currentSubjectUuid = context.fetchCurrentSubjectUuid(); + val loginPersonUuid = credentialsRepo.findByUuid(currentSubjectUuid) + .map(HsCredentialsEntity::getPerson) + .map(HsOfficePerson::getUuid) + .orElseThrow(); + val representedPersonUuids = realPersonRepo.findPersonsRepresentedByPersonWithUuid(loginPersonUuid) .stream().map(HsOfficePerson::getUuid).toList(); - if ( !representedPersonUuids.contains(personUuid)) { + if ( !representedPersonUuids.contains(referredPersonUuid)) { throw new ValidationException( messageTranslator.translate( - "credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person", - personUuid)); + "credentials.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person", + loginPersonUuid)); } } + private void validateNormalUsersOnlyAccessPublicContexts(final HsCredentialsEntity newCredentialsEntity) { + val forbiddenContexts = newCredentialsEntity.getLoginContexts().stream() + .filter(c -> !c.isPublicAccess() && !context.isGlobalAdmin() ) + .toList(); + if (!forbiddenContexts.isEmpty()) { + throw new ValidationException( + messageTranslator.translate( + "credentials.access-denied-for-contexts-{0}", + toDisplay(forbiddenContexts) + )); + } + } + + private void validateNaturalPersonRequirementOfContexts(final HsCredentialsEntity newCredentialsEntity) { + if (newCredentialsEntity.getPerson().getPersonType().equals(HsOfficePersonType.NATURAL_PERSON)) { + return; + } + val contextsWhichRequireNaturalPerson = newCredentialsEntity.getLoginContexts().stream() + .filter(HsCredentialsContext::isOnlyForNaturalPersons) + .toList(); + if (!contextsWhichRequireNaturalPerson.isEmpty()) { + throw new ValidationException( + messageTranslator.translate( + "credentials.context-requires-natural-person-{0}", + toDisplay(contextsWhichRequireNaturalPerson) + )); + } + } + + private void validateOwnHsadminCredentialsMustNotBeRemoved(final HsCredentialsEntity newCredentialsEntity) { + if (!newCredentialsEntity.getSubject().getUuid().equals(context.fetchCurrentSubjectUuid())) { + return; + } + val hsadminCredentialsContext = newCredentialsEntity.getLoginContexts().stream() + .filter(HsCredentialsContext::isHsadminContext) + .toList(); + if (hsadminCredentialsContext.isEmpty()) { + throw new ValidationException( + messageTranslator.translate( + "credentials.own-hsadmin-credentials-must-not-be-removed" + )); + } + } + + private static String toDisplay(final List contextsWhichRequireNaturalPerson) { + return contextsWhichRequireNaturalPerson.stream() + .map(HsCredentialsContext::toShortString) + .sorted() + .map(s -> "'" + s + "'") + .collect(Collectors.joining(", ")); + } + private RbacSubjectEntity createSubject(final String nickname) { - final var newRbacSubject = subjectRepo.create(new RbacSubjectEntity(null, nickname)); - if(context.fetchCurrentSubject() == null) { - context.define("activate newly created self-service subject", null, nickname, null); - } - return subjectRepo.findByUuid(newRbacSubject.getUuid()); // now attached to EM + val rbacSubjectEntity = new RbacSubjectEntity(null, nickname); + val newRbacSubject = subjectRepo.create(rbacSubjectEntity); + return newRbacSubject; } private List findByPersonUuid(final UUID personUuid) { - final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow( + val person = realPersonRepo.findByUuid(personUuid).orElseThrow( () -> new EntityNotFoundException( messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid) ) @@ -236,7 +316,7 @@ public class HsCredentialsController implements CredentialsApi { final RbacSubjectEntity currentSubject, final HsOfficePerson person, final boolean isGlobalAdmin) { - final var result = new CurrentLoginUserResource(); + val result = new CurrentLoginUserResource(); result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class)); result.setPerson(mapper.map(person, HsOfficePersonResource.class)); result.setGlobalAdmin(isGlobalAdmin); @@ -267,7 +347,7 @@ public class HsCredentialsController implements CredentialsApi { } final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { - final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( + val person = realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( () -> new EntityNotFoundException( messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid()) ) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java index 65655dc4..aa7411fb 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.accounts; import jakarta.persistence.*; import lombok.*; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; import net.hostsharing.hsadminng.repr.Stringify; @@ -46,7 +46,7 @@ public class HsCredentialsEntity implements BaseEntity, Str @ManyToOne(optional = false, fetch = FetchType.EAGER) @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") - private HsOfficePersonRbacEntity person; + private HsOfficePersonRealEntity person; // TODO.impl: add RBAC-Support to CredentialsEntity, see Story # @Version private int version; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepository.java index 0204f8ea..edbc6f54 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepository.java @@ -23,6 +23,22 @@ public interface HsOfficePersonRealRepository extends Repository findPersonByOptionalNameLike(String name); + @Query(value = """ + WITH RECURSIVE + represented_persons AS ( + SELECT relation.anchorUuid person_uuid + FROM hs_office.relation relation + WHERE relation.type = 'REPRESENTATIVE' + AND relation.holderUuid = :personUuid + ) + SELECT person.* + FROM hs_office.person person + WHERE person.uuid IN (SELECT person_uuid FROM represented_persons) + OR person.uuid = :personUuid + """, nativeQuery = true) + @Timed("app.office.persons.repo.findRepresentedPersons.real") + List findPersonsRepresentedByPersonWithUuid(UUID personUuid); + @Timed("app.office.persons.repo.save.real") HsOfficePersonRealEntity save(final HsOfficePersonRealEntity entity); diff --git a/src/main/java/net/hostsharing/hsadminng/ping/PingController.java b/src/main/java/net/hostsharing/hsadminng/ping/PingController.java index 7d47fa0a..d420ff41 100644 --- a/src/main/java/net/hostsharing/hsadminng/ping/PingController.java +++ b/src/main/java/net/hostsharing/hsadminng/ping/PingController.java @@ -31,7 +31,7 @@ public class PingController implements TestApi { public ResponseEntity pong() { final var userName = SecurityContextHolder.getContext().getAuthentication().getName(); // HOWTO translate text with placeholders - also see in resource files i18n/messages_*.properties. - final var translatedMessage = messageTranslator.translate("ping {0} - in English", userName); + final var translatedMessage = messageTranslator.translate("test.ponged-{0}--in-your-language", userName); return ResponseEntity.ok(translatedMessage + "\n"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java index de1a7411..e91dbc74 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java @@ -43,8 +43,7 @@ public class RbacSubjectController implements RbacSubjectsApi { if (body.getUuid() == null) { body.setUuid(UUID.randomUUID()); } - final var saved = mapper.map(body, RbacSubjectEntity.class); - rbacSubjectRepository.create(saved); + final var saved = rbacSubjectRepository.create(mapper.map(body, RbacSubjectEntity.class)); final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/rbac/subjects/{id}") diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java index c07a6385..789536c5 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java @@ -45,6 +45,10 @@ public interface RbacSubjectRepository extends Repository { + builder.loginContexts(new HashSet<>(contextRepo.findAll())); + }); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer " + credentialsEntity.getSubject().getName()) + .port(port) + .when() + .get("http://localhost/api/hs/accounts/credentials/" + credentialsEntity.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("$", lenientlyEquals(""" + { + "person": { + "personType": "LEGAL_PERSON", + "tradeName": "Test Company", + "salutation": null, + "title": null, + "givenName": null, + "familyName": null + }, + "nickname": "test-subject1", + "totpSecrets": null, + "phonePassword": null, + "emailAddress": null, + "smsNumber": null, + "active": false, + "globalUid": null, + "globalGid": null, + "onboardingToken": null, + "contexts": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "type": "SSH", + "qualifier": "external", + "onlyForNaturalPersons": false, + "publicAccess": true + }, + { + "uuid": "66666666-6666-6666-6666-666666666666", + "type": "MASTODON", + "qualifier": "external", + "onlyForNaturalPersons": false, + "publicAccess": true + }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "type": "BBB", + "qualifier": "external", + "onlyForNaturalPersons": false, + "publicAccess": true + } + ], + "lastUsed": null + } + """)); + // @formatter:on + } + } + + @Nested + class PostNewCredentials { + + @Test + void shouldRejectCreatingCredentialsForUnrepresentedPerson() { + // given + val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org"); + val publicContext = contextRepo.findByTypeAndQualifier("SSH", "external").orElseThrow(); + assertThat(publicContext.isPublicAccess()).as("precondition failed").isTrue(); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org") + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "person.uuid": "%s", + "nickname": "new-user", + "active": true, + "globalUid": 30001, + "globalGid": 40001, + "contexts": [ + { + "uuid" : "%s" + } + ] + } + """.formatted(testPerson.getUuid(), publicContext.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/accounts/credentials") + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", containsString("wird von der eingeloggten Person nicht repräsentiert")); + // @formatter:on + } + + @Test + void shouldRejectCreatingCredentialsWithPrivateContextForNormalUser() { + // given + val drewPerson = realPersonRepo.findPersonByOptionalNameLike("Drew").getFirst(); + val privateInternalSshContext = contextRepo.findByTypeAndQualifier("SSH", "internal") + .map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow(); + val privateInternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "internal") + .map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow(); + val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external") + .map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org") + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "person.uuid": "%s", + "nickname": "new-user", + "active": true, + "globalUid": 30001, + "globalGid": 40001, + "contexts": [ + { "uuid" : "%s" }, + { "uuid" : "%s" }, + { "uuid" : "%s" } + ] + } + """.formatted( + drewPerson.getUuid(), + publicExternalMatrixContext.getUuid(), + privateInternalSshContext.getUuid(), + privateInternalMatrixContext.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/accounts/credentials") + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", containsString("Kontext-Zugriff verweigert: 'MATRIX:internal', 'SSH:internal'")); + // @formatter:on + } + + @Test + void shouldRejectCreatingCredentialsWithNaturalPersonRequirementForNonNaturalPerson() { + // given + val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst(); + val hsadminProdContextOnlyForNaturalPersons = contextRepo.findByTypeAndQualifier("HSADMIN", "prod") + .map(HsCredentialsControllerAcceptanceTest::asNaturalPersonContext).orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer superuser-alex@hostsharing.net") + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "person.uuid": "%s", + "nickname": "new-user", + "active": true, + "globalUid": 30001, + "globalGid": 40001, + "contexts": [ + { "uuid" : "%s" } + ] + } + """.formatted( + firstGmbHPerson.getUuid(), + hsadminProdContextOnlyForNaturalPersons.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/accounts/credentials") + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", containsString("Kontext verlangt eine natürliche Person: 'HSADMIN:prod'")); + // @formatter:on + } + } + + @Nested + class PatchCredentials { + + @Test + void shouldRejectPatchingCredentialsWithPrivateContextForNormalUser() { + // given + context.define("selfregistered-user-drew@hostsharing.org"); + val drewCredentialsUuid = credentialsRepo.findByCurrentSubject().stream().findFirst().orElseThrow() + .getSubject().getUuid(); + val privateInternalSshContext = contextRepo.findByTypeAndQualifier("SSH", "internal") + .map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow(); + val privateInternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "internal") + .map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow(); + val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external") + .map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org") + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "contexts": [ + { "uuid" : "%s" }, + { "uuid" : "%s" }, + { "uuid" : "%s" } + ] + } + """.formatted( + privateInternalSshContext.getUuid(), + publicExternalMatrixContext.getUuid(), + privateInternalMatrixContext.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/accounts/credentials/" + drewCredentialsUuid) + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", containsString("Kontext-Zugriff verweigert: 'MATRIX:internal', 'SSH:internal'")); + // @formatter:on + } + + @Test + void shouldRejectPatchingCredentialsAndRemovingTheOwnHsadminCredentials() { + // given + context.define("selfregistered-user-drew@hostsharing.org"); + val drewCredentialsUuid = credentialsRepo.findByCurrentSubject().stream().findFirst().orElseThrow() + .getSubject().getUuid(); + val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external") + .map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org") + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "contexts": [ + { "uuid" : "%s" } + ] + } + """.formatted(publicExternalMatrixContext.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/accounts/credentials/" + drewCredentialsUuid) + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", containsString("die eigenen hsadmin-Credentials dürfen nicht entfernt werden")); + // @formatter:on + } + } + + @Nested + class MarkCredentialsAsUsed { + + @Test + void markCredentialsAsUsed() { + // given + val testPerson = givenNaturalPerson("selfregistered-user-drew@hostsharing.org"); + val credentialsEntity = givenNewCredentials("selfregistered-user-drew@hostsharing.org", + "test-subject2", + testPerson, builder -> { + builder.onboardingToken("some-onboarding-token"); + builder.loginContexts(contextRepo.findAll().stream() + .filter(HsCredentialsContext::isPublicAccess).collect(Collectors.toSet())); + }); + + RestAssured // @formatter:off + .given() + .header("Authorization", "Bearer superuser-alex@hostsharing.net") + .port(port) + .when() + .post("http://localhost/api/hs/accounts/credentials/" + credentialsEntity.getUuid() + "/used") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("uuid", is(credentialsEntity.getUuid().toString())) + .body("onboardingToken", is(nullValue())) + .body("lastUsed", is(not(nullValue()))); + // @formatter:on + } + } + + // Helper methods + private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(LEGAL_PERSON) + .tradeName("Test Company") + .build())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(NATURAL_PERSON) + .familyName("Test") + .givenName("User") + .build())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(NATURAL_PERSON) + .familyName("Test") + .givenName("Person") + .build())); + }).returnedValue(); + } + + private static HsCredentialsContextRealEntity asNaturalPersonContext(@NotNull HsCredentialsContextRealEntity context) { + assertThat(context.isOnlyForNaturalPersons()).as("precondition failed").isTrue(); + return context; + } + + private static HsCredentialsContextRealEntity asPrivateContext(@NotNull HsCredentialsContextRealEntity context) { + assertThat(context.isPublicAccess()).as("precondition failed").isFalse(); + return context; + } + + private static HsCredentialsContextRealEntity asPublicContext(@NotNull HsCredentialsContextRealEntity context) { + assertThat(context.isPublicAccess()).as("precondition failed").isTrue(); + return context; + } + + private HsCredentialsEntity givenNewCredentials( + final String executingSubjectName, + final String newSubjectName, final HsOfficePersonRealEntity person, + final Consumer modifier + ) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + final RbacSubjectEntity rbacSubjectEntity = RbacSubjectEntity.builder() + .name(newSubjectName) + .build(); + val subject = subjectRepo.create(rbacSubjectEntity); + + context.define(subject.getName()); + val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid()); + val credentialsBuilder = HsCredentialsEntity.builder() + .person(attachedPerson) + .subject(subjectRepo.findByUuid(subject.getUuid())) + .loginContexts(Set.of()); + modifier.accept(credentialsBuilder); + return toCleanup(credentialsRepo.save(credentialsBuilder.build())); + }).assertSuccessful().returnedValue(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java deleted file mode 100644 index c3c9a409..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java +++ /dev/null @@ -1,314 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import net.hostsharing.hsadminng.config.DisableSecurityConfig; -import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; -import net.hostsharing.hsadminng.config.MessageTranslator; -import net.hostsharing.hsadminng.config.MessagesResourceConfig; -import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; -import net.hostsharing.hsadminng.mapper.StrictMapper; -import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; -import org.hamcrest.CustomMatcher; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -import jakarta.persistence.EntityManagerFactory; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; - -import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON; -import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON; -import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; -import static org.hamcrest.Matchers.containsString; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(HsCredentialsController.class) -@Import({ - StrictMapper.class, - JsonObjectMapperConfiguration.class, - DisableSecurityConfig.class, - // HOWTO: test i18n translations - MessagesResourceConfig.class, - MessageTranslator.class }) -@ActiveProfiles("test") -class HsCredentialsControllerRestTest { - - private static final UUID PERSON_UUID = UUID.randomUUID(); - - @Autowired - MockMvc mockMvc; - - @MockitoBean - Context contextMock; - - @Autowired - @SuppressWarnings("unused") // not used in test, but in controller class - StrictMapper mapper; - - @MockitoBean - EntityManagerWrapper em; - - @MockitoBean - EntityManagerFactory emf; - - @MockitoBean - RbacSubjectRepository subjectRepo; - - @MockitoBean - HsOfficePersonRealRepository realPersonRepo; - - @MockitoBean - HsOfficePersonRbacRepository rbacPersonRepo; - - @MockitoBean - HsCredentialsContextRbacRepository loginContextRbacRepo; - - @MockitoBean - HsCredentialsRepository credentialsRepo; - - @MockitoBean - CredentialContextResourceToEntityMapper contextMapper; - - @Test - void shouldFetchCurrentLoginUser() throws Exception { - // given - final UUID currentSubjectUuid = UUID.randomUUID(); - given(contextMock.fetchCurrentSubjectUuid()).willReturn(currentSubjectUuid); - given(contextMock.isGlobalAdmin()).willReturn(true); - given(subjectRepo.findByUuid(currentSubjectUuid)).willReturn( - RbacSubjectEntity.builder().uuid(currentSubjectUuid).name("test-user").build() - ); - given(credentialsRepo.findByUuid(currentSubjectUuid)).willReturn( - Optional.of(HsCredentialsEntity.builder() - .uuid(currentSubjectUuid) - .person(HsOfficePersonRbacEntity.builder() - .uuid(PERSON_UUID) - .personType(NATURAL_PERSON) - .familyName("Miller") - .givenName("Steph") - .build()) - .subject(RbacSubjectEntity.builder().name("steph-miller").build()) - .build()) - ); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/hs/accounts/current") - .header("Authorization", "Bearer test") - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$.subject.uuid").value(currentSubjectUuid.toString())) - .andExpect(jsonPath("$.subject.name").value("test-user")) - .andExpect(jsonPath("$.person.uuid").value(PERSON_UUID.toString())) - .andExpect(jsonPath("$.person.familyName").value("Miller")) - .andExpect(jsonPath("$.person.givenName").value("Steph")) - .andExpect(jsonPath("$.globalAdmin").value(true)); - } - - @Test - void shouldFilterInvalidContextsRegardingNonNaturalPerson() throws Exception { - // given - final var givenCredentialsUuid = UUID.randomUUID(); - final var contextForNP = HsCredentialsContextRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("HSADMIN") - .qualifier("prod") - .onlyForNaturalPersons(true) - .build(); - final var contextForAll = HsCredentialsContextRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .onlyForNaturalPersons(false) - .build(); - final var credentialsEntity = HsCredentialsEntity.builder() - .uuid(givenCredentialsUuid) - .person(HsOfficePersonRbacEntity.builder() - .uuid(PERSON_UUID) - .personType(LEGAL_PERSON) - .build()) - .subject(RbacSubjectEntity.builder().name("some-nickname").build()) - .loginContexts(Set.of(contextForNP, contextForAll)) - .build(); - when(credentialsRepo.findByUuid(givenCredentialsUuid)) - .thenReturn(Optional.of(credentialsEntity)); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/hs/accounts/credentials/" + givenCredentialsUuid) - .header("Authorization", "Bearer test") - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$.contexts.length()").value(1)) - .andExpect(jsonPath("$.contexts[0].type").value("SSH")) - .andExpect(jsonPath("$.contexts[0].qualifier").value("prod")) - .andExpect(jsonPath("$.contexts[0].onlyForNaturalPersons").value(false)); - } - - @Test - void shouldRejectCreatingCredentialsForUnrepresentedPerson() throws Exception { - // given - final var personUuid = UUID.randomUUID(); - - final AtomicReference createdSubject = new AtomicReference<>(); - given(subjectRepo.create(any())).willAnswer(invocation -> { - final var passedEntity = (RbacSubjectEntity) invocation.getArgument(0); - passedEntity.setUuid(UUID.randomUUID()); - createdSubject.set(passedEntity); // Capture the instance - return passedEntity; - }); - given(contextMock.fetchCurrentSubject()).willAnswer(invocation -> createdSubject.get().getName()); - given(subjectRepo.findByUuid(any())).willAnswer(invocation -> createdSubject.get()); - given(rbacPersonRepo.findByUuid(personUuid)).willReturn(Optional.of( - HsOfficePersonRbacEntity.builder().uuid(personUuid).personType(NATURAL_PERSON).build() - )); - given(rbacPersonRepo.findPersonsRepresentedByPersonWithUuid(personUuid)).willReturn(List.of( - // some persons, but not the one from the login-user itself - HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(NATURAL_PERSON).build(), - HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(LEGAL_PERSON).build() - )); - - final var givenCredentialsUuid = UUID.randomUUID(); - - final var contextForNP = HsCredentialsContextRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("HSADMIN") - .qualifier("prod") - .onlyForNaturalPersons(true) - .build(); - final var contextForAll = HsCredentialsContextRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .onlyForNaturalPersons(false) - .build(); - final var credentialsEntity = HsCredentialsEntity.builder() - .uuid(givenCredentialsUuid) - .person(HsOfficePersonRbacEntity.builder() - .uuid(PERSON_UUID) - .personType(LEGAL_PERSON) - .build()) - .subject(RbacSubjectEntity.builder().name("some-nickname").build()) - .loginContexts(Set.of(contextForNP, contextForAll)) - .build(); - when(credentialsRepo.findByUuid(givenCredentialsUuid)) - .thenReturn(Optional.of(credentialsEntity)); - - // when - mockMvc.perform(MockMvcRequestBuilders - .post("/api/hs/accounts/credentials") - .header("Authorization", "Bearer test") - // HOWTO: test i18n translations - .header("Accept-Language", "de") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "person.uuid": "${personUuid}", - "nickname": "${nickname}", - "active": true, - "globalUid": 30001, - "globalGid": 40001, - "contexts": [ - { - "uuid" : "11111111-1111-1111-1111-111111111111", - "type" : "HSADMIN", - "qualifier" : "prod", - "onlyForNaturalPersons" : true, - "publicAccess" : true - } - ] - } - """ - .replace("${personUuid}", personUuid.toString()) - .replace("${nickname}", "new-user") - ) - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().is4xxClientError()) - .andExpect(jsonPath("$.message", containsString( - "Zugriff verweigert: personUuid \"${personUuid}\" wird von der eingeloggten Person nicht repräsentiert" - .replace("${personUuid}", personUuid.toString())))); - } - - @Test - void markCredentialsAsUsed() throws Exception { - - // given - final var givenCredentialsUuid = UUID.randomUUID(); - when(credentialsRepo.findByUuid(givenCredentialsUuid)).thenReturn(Optional.of( - HsCredentialsEntity.builder() - .uuid(givenCredentialsUuid) - .person(HsOfficePersonRbacEntity.builder().uuid(PERSON_UUID).build()) - .subject(RbacSubjectEntity.builder().name("some-nickname").build()) - .lastUsed(null) - .onboardingToken("fake-onboarding-token") - .build() - )); - when(credentialsRepo.save(any())).thenAnswer(invocation -> - invocation.getArgument(0) - ); - - // when - mockMvc.perform(MockMvcRequestBuilders - .post("/api/hs/accounts/credentials/%{credentialsUuid}/used" - .replace("%{credentialsUuid}", givenCredentialsUuid.toString())) - .header("Authorization", "Bearer superuser-alex@hostsharing.net") - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath( - "$", lenientlyEquals(""" - { - "uuid": "%{credentialsUuid}", - "onboardingToken": null - } - """.replace("%{credentialsUuid}", givenCredentialsUuid.toString()) - ))) - .andExpect(jsonPath("$.lastUsed").value(new CustomMatcher("lastUsed should have recent timestamp") { - - @Override - public boolean matches(final Object o) { - if (o == null) { - return false; - } - final var lastUsed = ZonedDateTime.parse(o.toString(), DateTimeFormatter.ISO_DATE_TIME) - .toLocalDateTime(); - return lastUsed.isAfter(LocalDateTime.now().minusMinutes(1)) && - lastUsed.isBefore(LocalDateTime.now()); - } - })); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java index afcc5126..c50576be 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.accounts; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; @@ -62,8 +62,8 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup private RbacSubjectEntity alexSubject; private RbacSubjectEntity drewSubject; private RbacSubjectEntity testUserSubject; - private HsOfficePersonRbacEntity drewPerson; - private HsOfficePersonRbacEntity testUserPerson; + private HsOfficePersonRealEntity drewPerson; + private HsOfficePersonRealEntity testUserPerson; @BeforeEach void setUp() { @@ -277,13 +277,13 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup } } - private HsOfficePersonRbacEntity fetchPersonByGivenName(final String givenName) { - final String jpql = "SELECT p FROM HsOfficePersonRbacEntity p WHERE p.givenName = :givenName"; - final Query query = em.createQuery(jpql, HsOfficePersonRbacEntity.class); + private HsOfficePersonRealEntity fetchPersonByGivenName(final String givenName) { + final String jpql = "SELECT p FROM HsOfficePersonRealEntity p WHERE p.givenName = :givenName"; + final Query query = em.createQuery(jpql, HsOfficePersonRealEntity.class); query.setParameter("givenName", givenName); try { context(SUPERUSER_ALEX_SUBJECT_NAME); - return notNull((HsOfficePersonRbacEntity) query.getSingleResult()); + return notNull((HsOfficePersonRealEntity) query.getSingleResult()); } catch (final NoResultException e) { throw new AssertionError( "Failed to find person with name '" + givenName + "'. Ensure test data is present.", e); @@ -315,7 +315,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup private class RelationBuilder { private final HsOfficeRelationType relationType; private HsOfficePersonRealEntity anchorPerson; - private HsOfficePersonRbacEntity holderPerson; + private HsOfficePersonRealEntity holderPerson; private HsOfficeContactRealEntity contact; public RelationBuilder(HsOfficeRelationType relationType) { @@ -327,7 +327,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup return this; } - public RelationBuilder withHolder(HsOfficePersonRbacEntity holderPerson) { + public RelationBuilder withHolder(HsOfficePersonRealEntity holderPerson) { this.holderPerson = holderPerson; return this; } @@ -373,7 +373,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup final var credentials = HsCredentialsEntity.builder() .uuid(subject.getUuid()) .subject(subject) - .person(em.find(HsOfficePersonRbacEntity.class, person.getUuid())) + .person(em.find(HsOfficePersonRealEntity.class, person.getUuid())) .emailAddress(emailAddress) .active(true) .build(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepositoryIntegrationTest.java index 45c24469..b04810c2 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRealRepositoryIntegrationTest.java @@ -150,6 +150,39 @@ class HsOfficePersonRealRepositoryIntegrationTest extends ContextBasedTestWithCl } } + @Test + public void findPersonsRepresentedByPersonWithUuid() { + + // given + context("superuser-alex@hostsharing.net"); + final var personUuid = personRealRepo.findPersonByOptionalNameLike("Fouler").getFirst().getUuid(); + + // when + @SuppressWarnings("unchecked") final List representedPersons = personRealRepo.findPersonsRepresentedByPersonWithUuid(personUuid); + + // then + assertThat(representedPersons).map(Object::toString).containsExactlyInAnyOrder( + "person(personType=NP, familyName='Fouler', givenName='Ellie')", + "person(personType=LP, tradeName='Fourth eG')" + ); + } + + @Test + public void findPersonsRepresentedByPersonWithUuidDrew() { + + // given + context("superuser-alex@hostsharing.net"); + final var personUuid = personRealRepo.findPersonByOptionalNameLike("Drew").getFirst().getUuid(); + + // when + @SuppressWarnings("unchecked") final List representedPersons = personRealRepo.findPersonsRepresentedByPersonWithUuid(personUuid); + + // then + assertThat(representedPersons).map(Object::toString).containsExactlyInAnyOrder( + "person(personType=NP, familyName='User', givenName='Drew')" + ); + } + @Test public void auditJournalLogIsAvailable() { // given @@ -157,7 +190,7 @@ class HsOfficePersonRealRepositoryIntegrationTest extends ContextBasedTestWithCl select currentTask, targetTable, targetOp, targetdelta->>'tradename', targetdelta->>'lastname' from base.tx_journal_v where targettable = 'hs_office.person'; - """); + """); // when @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); diff --git a/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperFake.java b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperFake.java index e1ce8e2e..0dd2eaed 100644 --- a/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperFake.java +++ b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperFake.java @@ -10,7 +10,6 @@ import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; - public class EntityManagerWrapperFake extends EntityManagerWrapper { private Map, Map> entityClasses = new HashMap<>(); diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerRestTest.java index 840ace5e..8b8545e4 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerRestTest.java @@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import net.hostsharing.hsadminng.config.DisableSecurityConfig; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -19,7 +20,9 @@ import java.util.UUID; import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -42,6 +45,12 @@ class RbacSubjectControllerRestTest { @MockitoBean EntityManagerWrapper em; + @BeforeEach + void beforeEach() { + given(rbacSubjectRepository.create(any())).willAnswer(invocation -> + invocation.getArgument(0) + ); + } @Test void postNewSubjectUsesGivenUuid() throws Exception { diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java index d7e71810..ab701959 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java @@ -53,7 +53,7 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { @Autowired JpaAttempt jpaAttempt; - private TreeMap> entitiesToCleanup = new TreeMap<>(); + private LinkedHashMap> entitiesToCleanup = new LinkedHashMap<>(); private static Long latestIntialTestDataSerialId; private static boolean countersInitialized = false; @@ -102,6 +102,10 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { ? tableName.substring(0, tableName.length() - "_rv".length()) : tableName; + final var rawTableName = rvTableName.endsWith("_rv") + ? rvTableName.substring(0, rvTableName.length() - "_rv".length()) + : rvTableName; + allRbacObjects().stream() .filter(o -> o.startsWith(rvTableName + ":")) .filter(o -> !initialRbacObjects.contains(o)) @@ -191,11 +195,11 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { context.define("superuser-alex@hostsharing.net", null); entitiesToCleanup.reversed().forEach((uuid, entityClass) -> { final var rvTableName = entityClass.getAnnotation(Table.class).name(); - if ( !rvTableName.endsWith("_rv") ) { - throw new IllegalStateException(); - } - final var rawTableName = rvTableName.substring(0, rvTableName.length() - "_rv".length()); - final var deletedRows = em.createNativeQuery("DELETE FROM " + rawTableName + " WHERE uuid=:uuid") + final var scope = entityClass.getAnnotation(Table.class).schema(); + final var rawTableName = rvTableName.endsWith("_rv") + ? rvTableName.substring(0, rvTableName.length() - "_rv".length()) + : rvTableName; + final var deletedRows = em.createNativeQuery("DELETE FROM " + scope + "." + rawTableName + " WHERE uuid=:uuid") .setParameter("uuid", uuid).executeUpdate(); out.println("DELETING temporary " + entityClass.getSimpleName() + "#" + uuid + " deleted " + deletedRows + " rows"); }); @@ -264,6 +268,9 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { assertThat(after).isNotNull(); final SetUtils.SetView difference = difference(before, after); assertThat(difference).as("missing entities (deleted initial test data)").isEmpty(); + difference(after, before).stream().iterator().forEachRemaining(e -> { + em.remove(e); + }); assertThat(difference(after, before)).as("spurious entities (test data not cleaned up by this test)").isEmpty(); }