From 3603ea911ec60c3edf8dd6be10f088e242d828c6 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 7 Jul 2025 21:09:37 +0200 Subject: [PATCH] bugfix: fixes HTTP POST on credentials, including person+subject (#184) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/184 Reviewed-by: Marc Sandlus --- ...edentialContextResourceToEntityMapper.java | 67 +++++++++++++ .../HsCredentialsContextsController.java | 2 + .../credentials/HsCredentialsController.java | 95 ++++++++++++++++--- .../credentials/HsCredentialsEntity.java | 9 +- .../HsCredentialsEntityPatcher.java | 50 +--------- .../rbac/subject/RbacSubjectEntity.java | 1 + .../rbac/subject/RbacSubjectRepository.java | 2 +- .../api-definition/credentials/contexts.yaml | 2 +- .../credentials/credentials-schemas.yaml | 10 +- .../credentials-with-uuid-used.yaml | 2 +- .../credentials/credentials-with-uuid.yaml | 10 +- .../credentials/credentials.yaml | 4 +- .../hsadminng/arch/ArchitectureTest.java | 1 + .../HsCredentialsControllerRestTest.java | 19 +++- .../HsCredentialsEntityPatcherUnitTest.java | 3 +- ...sCredentialsRepositoryIntegrationTest.java | 14 +-- .../scenarios/CreateCredentials.java | 63 ++++++++++++ .../scenarios/CredentialsScenarioTests.java | 83 ++++++++++++++++ .../scenarios/HsOfficeScenarioTests.java | 31 ++++++ .../hsadminng/hs/scenarios/ScenarioTest.java | 31 +----- .../hs/scenarios/TemplateResolver.java | 48 ++++++++-- .../scenarios/TemplateResolverUnitTest.java | 59 ++++++++++-- .../RbacSubjectControllerAcceptanceTest.java | 2 +- 23 files changed, 482 insertions(+), 126 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/credentials/CredentialContextResourceToEntityMapper.java create mode 100644 src/test/java/net/hostsharing/hsadminng/credentials/scenarios/CreateCredentials.java create mode 100644 src/test/java/net/hostsharing/hsadminng/credentials/scenarios/CredentialsScenarioTests.java diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/CredentialContextResourceToEntityMapper.java b/src/main/java/net/hostsharing/hsadminng/credentials/CredentialContextResourceToEntityMapper.java new file mode 100644 index 00000000..6ffeb3ff --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/CredentialContextResourceToEntityMapper.java @@ -0,0 +1,67 @@ +package net.hostsharing.hsadminng.credentials; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import net.hostsharing.hsadminng.config.MessageTranslator; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class CredentialContextResourceToEntityMapper { + + private final EntityManager em; + private final MessageTranslator messageTranslator; + + @Autowired + public CredentialContextResourceToEntityMapper(EntityManager em, MessageTranslator messageTranslator) { + this.em = em; + this.messageTranslator = messageTranslator; + } + + public Set mapCredentialsToContextEntities( + List resources + ) { + final var entities = new HashSet(); + syncCredentialsContextEntities(resources, entities); + return entities; + } + + public void syncCredentialsContextEntities( + List resources, + Set entities + ) { + final var resourceUuids = resources.stream() + .map(ContextResource::getUuid) + .collect(Collectors.toSet()); + + final var entityUuids = entities.stream() + .map(HsCredentialsContextRealEntity::getUuid) + .collect(Collectors.toSet()); + + entities.removeIf(e -> !resourceUuids.contains(e.getUuid())); + + for (final var resource : resources) { + if (!entityUuids.contains(resource.getUuid())) { + final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid()); + if (existingContextEntity == null) { + throw new EntityNotFoundException( + messageTranslator.translate("{0} \"{1}\" not found or not accessible", + "credentials uuid", resource.getUuid())); + } + if (!existingContextEntity.getType().equals(resource.getType()) && + !existingContextEntity.getQualifier().equals(resource.getQualifier())) { + throw new EntityNotFoundException( + messageTranslator.translate("existing {0} does not match given resource {1}", + existingContextEntity, resource)); + } + entities.add(existingContextEntity); + } + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java index bedcb441..c43ff881 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java @@ -10,6 +10,7 @@ import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResou import net.hostsharing.hsadminng.mapper.StrictMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; @RestController @@ -26,6 +27,7 @@ public class HsCredentialsContextsController implements ContextsApi { private HsCredentialsContextRbacRepository contextRepo; @Override + @Transactional(readOnly = true) @Timed("app.credentials.contexts.getListOfLoginContexts") public ResponseEntity> getListOfContexts(final String assumedRoles) { context.assumeRoles(assumedRoles); diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java index 6c282d4c..08a4875e 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java @@ -2,7 +2,6 @@ package net.hostsharing.hsadminng.credentials; import java.time.LocalDateTime; import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; @@ -15,21 +14,27 @@ import net.hostsharing.hsadminng.credentials.generated.api.v1.api.CredentialsApi import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsInsertResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.HsOfficePersonResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; 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.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; import jakarta.persistence.EntityNotFoundException; +import static java.util.Optional.ofNullable; +import static java.util.Optional.of; + @RestController @SecurityRequirement(name = "casTicket") public class HsCredentialsController implements CredentialsApi { - private static DateTimeFormatter FULL_TIMESTAMP_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; - @Autowired private Context context; @@ -39,16 +44,23 @@ public class HsCredentialsController implements CredentialsApi { @Autowired private StrictMapper mapper; + @Autowired + private RbacSubjectRepository subjectRepo; + + @Autowired + private CredentialContextResourceToEntityMapper contextMapper; + @Autowired private MessageTranslator messageTranslator; @Autowired - private HsOfficePersonRbacRepository personRepo; + private HsOfficePersonRbacRepository rbacPersonRepo; @Autowired private HsCredentialsRepository credentialsRepo; @Override + @Transactional(readOnly = true) @Timed("app.credentials.credentials.getSingleCredentialsByUuid") public ResponseEntity getSingleCredentialsByUuid( final String assumedRoles, @@ -56,11 +68,16 @@ public class HsCredentialsController implements CredentialsApi { context.assumeRoles(assumedRoles); final var credentials = credentialsRepo.findByUuid(credentialsUuid); - final var result = mapper.map(credentials, CredentialsResource.class); + if (credentials.isEmpty()) { + return ResponseEntity.notFound().build(); + } + final var result = mapper.map( + credentials.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(result); } @Override + @Transactional(readOnly = true) @Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid") public ResponseEntity> getListOfCredentialsByPersonUuid( final String assumedRoles, @@ -68,18 +85,20 @@ public class HsCredentialsController implements CredentialsApi { ) { context.assumeRoles(assumedRoles); - final var person = personRepo.findByUuid(personUuid).orElseThrow( + final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow( () -> new EntityNotFoundException( messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid) ) ); final var credentials = credentialsRepo.findByPerson(person); - final var result = mapper.mapList(credentials, CredentialsResource.class); + final var result = mapper.mapList( + credentials, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(result); } @Override + @Transactional @Timed("app.credentials.credentials.postNewCredentials") public ResponseEntity postNewCredentials( final String assumedRoles, @@ -87,13 +106,28 @@ public class HsCredentialsController implements CredentialsApi { ) { context.assumeRoles(assumedRoles); - final var newCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); - final var savedCredentialsEntity = credentialsRepo.save(newCredentialsEntity); - final var newCredentialsResource = mapper.map(savedCredentialsEntity, CredentialsResource.class); - return ResponseEntity.ok(newCredentialsResource); + // first create and save the subject to get its UUID + final var newlySavedSubject = createSubject(body.getNickname()); + + // afterward, create and save the credentials entity with the subject's UUID + final var newCredentialsEntity = mapper.map( + body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + newCredentialsEntity.setSubject(newlySavedSubject); + em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository! + + // return the new credentials as a resource + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/credentials/credentials/{id}") + .buildAndExpand(newCredentialsEntity.getUuid()) + .toUri(); + final var newCredentialsResource = mapper.map( + newCredentialsEntity, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(newCredentialsResource); } @Override + @Transactional @Timed("app.credentials.credentials.deleteCredentialsByUuid") public ResponseEntity deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) { context.assumeRoles(assumedRoles); @@ -103,6 +137,7 @@ public class HsCredentialsController implements CredentialsApi { } @Override + @Transactional @Timed("app.credentials.credentials.patchCredentials") public ResponseEntity patchCredentials( final String assumedRoles, @@ -113,10 +148,11 @@ public class HsCredentialsController implements CredentialsApi { final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); - new HsCredentialsEntityPatcher(em, messageTranslator, current).apply(body); + new HsCredentialsEntityPatcher(contextMapper, current).apply(body); final var saved = credentialsRepo.save(current); - final var mapped = mapper.map(saved, CredentialsResource.class); + final var mapped = mapper.map( + saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(mapped); } @@ -137,7 +173,38 @@ public class HsCredentialsController implements CredentialsApi { return ResponseEntity.ok(mapped); } + 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-servie subject", null, nickname, null); + } + return subjectRepo.findByUuid(newRbacSubject.getUuid()); // attached to EM + } + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { - resource.setLastUsed(entity.getLastUsed().atOffset(ZoneOffset.UTC)); + ofNullable(entity.getLastUsed()).ifPresent( + dt -> resource.setLastUsed(dt.atOffset(ZoneOffset.UTC))); + of(entity.getSubject()).ifPresent( + subject -> resource.setNickname(subject.getName()) + ); + of(entity.getPerson()).ifPresent( + person -> resource.setPerson( + mapper.map(person, HsOfficePersonResource.class) + ) + ); + }; + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + + // TODO.impl: we need to make sure that the current subject is OWNER (or ADMIN?) of the person + final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( + () -> new EntityNotFoundException( + messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", resource.getPersonUuid()) + ) + ); + + entity.setLoginContexts(contextMapper.mapCredentialsToContextEntities(resource.getContexts())); + + entity.setPerson(person); }; } diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java index 6c6a2ae0..8952a247 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.credentials; import jakarta.persistence.*; import lombok.*; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; import net.hostsharing.hsadminng.repr.Stringify; @@ -45,7 +45,7 @@ public class HsCredentialsEntity implements BaseEntity, Str @ManyToOne(optional = false, fetch = FetchType.EAGER) @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") - private HsOfficePersonRealEntity person; + private HsOfficePersonRbacEntity person; @Version private int version; @@ -92,6 +92,11 @@ public class HsCredentialsEntity implements BaseEntity, Str return loginContexts; } + public void setSubject(final RbacSubjectEntity subject) { + this.uuid = subject.getUuid(); + this.subject = subject; + } + @Override public String toShortString() { return active + ":" + emailAddress + ":" + globalUid; diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java index 1e1a7639..005c09ac 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java @@ -1,26 +1,17 @@ package net.hostsharing.hsadminng.credentials; -import net.hostsharing.hsadminng.config.MessageTranslator; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.OptionalFromJson; -import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityNotFoundException; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; public class HsCredentialsEntityPatcher implements EntityPatcher { - private final EntityManager em; - private MessageTranslator messageTranslator; + private CredentialContextResourceToEntityMapper contextMapper; private final HsCredentialsEntity entity; - public HsCredentialsEntityPatcher(final EntityManager em, MessageTranslator messageTranslator, final HsCredentialsEntity entity) { - this.em = em; - this.messageTranslator = messageTranslator; + public HsCredentialsEntityPatcher(final CredentialContextResourceToEntityMapper contextMapper, final HsCredentialsEntity entity) { + this.contextMapper = contextMapper; this.entity = entity; } @@ -38,40 +29,7 @@ public class HsCredentialsEntityPatcher implements EntityPatcher resources, - Set entities - ) { - final var resourceUuids = resources.stream() - .map(ContextResource::getUuid) - .collect(Collectors.toSet()); - - final var entityUuids = entities.stream() - .map(HsCredentialsContextRealEntity::getUuid) - .collect(Collectors.toSet()); - - entities.removeIf(e -> !resourceUuids.contains(e.getUuid())); - - for (final var resource : resources) { - if (!entityUuids.contains(resource.getUuid())) { - final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid()); - if ( existingContextEntity == null ) { - throw new EntityNotFoundException( - messageTranslator.translate("{0} \"{1}\" not found or not accessible", - "credentials uuid", resource.getUuid())); - } - if (!existingContextEntity.getType().equals(resource.getType()) && - !existingContextEntity.getQualifier().equals(resource.getQualifier())) { - throw new EntityNotFoundException( - messageTranslator.translate("existing {0} does not match given resource {1}", - existingContextEntity, resource)); - } - entities.add(existingContextEntity); - } + contextMapper.syncCredentialsContextEntities(resource.getContexts(), entity.getLoginContexts()); } } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java index 3ff2c81d..460f2970 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java @@ -16,6 +16,7 @@ import java.util.UUID; @Table(schema = "rbac", name = "subject_rv") @Getter @Setter +@Builder @ToString @Immutable @NoArgsConstructor 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 36dd2675..c07a6385 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java @@ -45,7 +45,7 @@ public interface RbacSubjectRepository extends Repository { + + public CreateCredentials(final ScenarioTest testSuite) { + super(testSuite); + + introduction("A set of credentials contains the login data for an RBAC subject."); + } + + @Override + protected HttpResponse run() { + + obtain("Person: %{personGivenName} %{personFamilyName}", () -> + httpGet("/api/hs/office/persons?name=%{personFamilyName}") + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In real situations we have more precise measures to find the related person." + ); + + + obtain("CredentialsContexts", () -> + httpGet("/api/hs/credentials/contexts").expecting(OK).expecting(JSON) + ); + + return obtain("newCredentials", () -> + httpPost("/api/hs/credentials/credentials", usingJsonBody(""" + { + "person.uuid": ${Person: %{personGivenName} %{personFamilyName}}, + "nickname": ${nickname}, + "active": %{active}, + "emailAddress": ${emailAddress}, + "telephonePassword": ${telephonePassword}, + "smsNumber": ${smsNumber}, + "globalUid": %{globalUid}, + "globalGid": %{globalGid}, + "contexts": @{contexts} + } + """)) + .expecting(HttpStatus.CREATED).expecting(ContentType.JSON) + ); + } + + @Override + protected void verify(final UseCase.HttpResponse response) { + verify( + "Verify the New Credentials", + () -> httpGet("/api/hs/credentials/credentials/%{newCredentials}") + .expecting(OK).expecting(JSON), + path("uuid").contains("%{newCredentials}"), + path("nickname").contains("%{nickname}"), + path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}") + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/scenarios/CredentialsScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/credentials/scenarios/CredentialsScenarioTests.java new file mode 100644 index 00000000..90d13866 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/scenarios/CredentialsScenarioTests.java @@ -0,0 +1,83 @@ +package net.hostsharing.hsadminng.credentials.scenarios; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.config.DisableSecurityConfig; +import net.hostsharing.hsadminng.hs.scenarios.Produces; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +@Tag("scenarioTest") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, DisableSecurityConfig.class, JpaAttempt.class }, + properties = { + "spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///scenariosTC}", + "spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}", + "spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}", + "hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}" + } +) +@ActiveProfiles("test") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@ExtendWith(IgnoreOnFailureExtension.class) +class CredentialsScenarioTests extends ScenarioTest { + + @SneakyThrows + @BeforeEach + protected void beforeScenario(final TestInfo testInfo) { + super.beforeScenario(testInfo); + } + @Nested + @Order(10) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class CredentialScenarios { + + @Test + @Order(1010) + @Produces(explicitly = "Credentials@hsadmin: firby-susan", implicitly = { "Person: Susan Firby" }) + void shouldCreateInitialCredentialsForExistingNaturalPerson() { + new CreateCredentials(scenarioTest) + // to find a specific existing person + .given("personFamilyName", "Firby") + .given("personGivenName", "Susan") + // a login name, to be stored in the new RBAC subject + .given("nickname", "firby-susan") + // initial credentials + .given("active", true) + .given("emailAddress", "susan.firby@example.com") + .given("telephonePassword", "securePass123") + .given("smsNumber", "+49123456789") + .given("globalUid", 21011) + .given("globalGid", 21011) + .given("contexts", Array.of( + Map.ofEntries( + // a hardcoded context from test-data + // TODO.impl: the uuid should be determined within CreateCredentials just by (HSDAMIN,prod) + Map.entry("uuid", "11111111-1111-1111-1111-111111111111"), + Map.entry("type", "HSADMIN"), + Map.entry("qualifier", "prod") + ) + )) + .doRun() + .keep(); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java index ee9af1d1..368bf2b9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java @@ -1,6 +1,9 @@ package net.hostsharing.hsadminng.hs.office.scenarios; +import lombok.SneakyThrows; import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; import net.hostsharing.hsadminng.hs.office.scenarios.contact.AddPhoneNumberToContactData; import net.hostsharing.hsadminng.hs.office.scenarios.contact.AmendContactData; import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFromContactData; @@ -39,9 +42,11 @@ import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFro import net.hostsharing.hsadminng.hs.scenarios.Produces; import net.hostsharing.hsadminng.hs.scenarios.Requires; import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.lambda.Reducer; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.config.DisableSecurityConfig; import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.MethodOrderer; @@ -50,8 +55,10 @@ import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -71,6 +78,30 @@ import org.springframework.test.context.ActiveProfiles; @ExtendWith(IgnoreOnFailureExtension.class) class HsOfficeScenarioTests extends ScenarioTest { + @Autowired + HsOfficePersonRbacRepository personRepo; + + @SneakyThrows + @BeforeEach + protected void beforeScenario(final TestInfo testInfo) { + createHostsharingPerson(); + super.beforeScenario(testInfo); + } + + private void createHostsharingPerson() { + jpaAttempt.transacted(() -> + { + context.define("superuser-alex@hostsharing.net"); + putAlias( + "Person: Hostsharing eG", + personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream() + .map(HsOfficePersonRbacEntity::getUuid) + .reduce(Reducer::toSingleElement).orElseThrow() + ); + } + ); + } + @Nested @Order(10) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java index 9d65fed2..99d2f42e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java @@ -1,9 +1,6 @@ package net.hostsharing.hsadminng.hs.scenarios; import lombok.SneakyThrows; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; -import net.hostsharing.hsadminng.lambda.Reducer; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver; @@ -58,15 +55,11 @@ public abstract class ScenarioTest extends ContextBasedTest { Integer port; @Autowired - HsOfficePersonRbacRepository personRepo; - - @Autowired - JpaAttempt jpaAttempt; + protected JpaAttempt jpaAttempt; @SneakyThrows @BeforeEach - void beforeScenario(final TestInfo testInfo) { - createHostsharingPerson(); + protected void beforeScenario(final TestInfo testInfo) { try { testInfo.getTestMethod().ifPresent(currentTestMethod -> { callRequiredProducers(currentTestMethod); @@ -86,20 +79,6 @@ public abstract class ScenarioTest extends ContextBasedTest { testReport.close(); } - private void createHostsharingPerson() { - jpaAttempt.transacted(() -> - { - context.define("superuser-alex@hostsharing.net"); - putAlias( - "Person: Hostsharing eG", - personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream() - .map(HsOfficePersonRbacEntity::getUuid) - .reduce(Reducer::toSingleElement).orElseThrow() - ); - } - ); - } - @SneakyThrows private void callRequiredProducers(final Method currentTestMethod) { final var testMethodRequires = Optional.of(currentTestMethod) @@ -200,15 +179,15 @@ public abstract class ScenarioTest extends ContextBasedTest { return alias; } - static void putAlias(final String name, final UUID value) { + protected static void putAlias(final String name, final UUID value) { aliases.put(name, value); } - static void putProperty(final String name, final Object value) { + protected static void putProperty(final String name, final Object value) { properties.put(name, (value instanceof String string) ? resolveTyped(string) : value); } - static void removeProperty(final String propName) { + protected static void removeProperty(final String propName) { properties.remove(propName); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolver.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolver.java index eb82d676..65e4d70d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolver.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolver.java @@ -5,8 +5,10 @@ import org.apache.commons.lang3.StringUtils; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collection; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -14,6 +16,8 @@ import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.D public class TemplateResolver { + public static final String JSON_NULL_VALUE_TO_KEEP = "NULL"; + public enum Resolver { DROP_COMMENTS, // deletes comments ('#{whatever}' -> '') KEEP_COMMENTS // keep comments ('#{whatever}' -> 'whatever') @@ -44,6 +48,12 @@ public class TemplateResolver { return value != null ? URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) : ""; } }, + JSON_ARRAY('@'){ + @Override + String convert(final Object value, final Resolver resolver) { + return jsonArray(value); + } + }, COMMENT('#'){ @Override String convert(final Object value, final Resolver resolver) { @@ -102,13 +112,14 @@ public class TemplateResolver { .collect(Collectors.joining("\n")); } + private static boolean keepLine(final String line) { final var trimmed = line.trim(); return !trimmed.endsWith("null,") && !trimmed.endsWith("null"); } private static String keptNullValues(final String line) { - return line.replace(": NULL", ": null"); + return line.replace(": "+ JSON_NULL_VALUE_TO_KEEP, ": null"); } private String copy() { @@ -163,12 +174,10 @@ public class TemplateResolver { // => last alternative element in expression was null and not optional throw new IllegalStateException("Missing required value in property-chain: " + nameExpression); }); + } else if (properties.containsKey(nameExpression)) { + return properties.get(nameExpression); } else { - final var val = properties.get(nameExpression); - if (val == null) { - throw new IllegalStateException("Missing required property: " + nameExpression); - } - return val; + throw new IllegalStateException("Missing required property: " + nameExpression); } } @@ -212,19 +221,40 @@ public class TemplateResolver { private static String jsonQuoted(final Object value) { return switch (value) { - case null -> null; + case null -> "null"; case Boolean bool -> bool.toString(); case Number number -> number.toString(); case String string -> "\"" + string.replace("\n", "\\n") + "\""; - default -> "\"" + value + "\""; + case UUID uuid -> "\"" + uuid + "\""; + default -> jsonObject(value); }; } private static String jsonObject(final Object value) { return switch (value) { - case null -> null; + case null -> "null"; + case Map map -> "{" + map.entrySet().stream() + .map(entry -> "\"" + entry.getKey() + "\": " + jsonQuoted(entry.getValue())) + .collect(Collectors.joining(", ")) + "}"; case String string -> "{" + string.replace("\n", " ") + "}"; default -> throw new IllegalArgumentException("can not format " + value.getClass() + " (" + value + ") as JSON object"); }; } + + private static String jsonArray(final Object value) { + return switch (value) { + case null -> "null"; + case Object[] array -> "[" + Arrays.stream(array) + .filter(Objects::nonNull) + .map(TemplateResolver::jsonQuoted) + .collect(Collectors.joining(", ")) + "]"; + case Collection collection -> "[" + collection.stream() + .filter(Objects::nonNull) + .map(TemplateResolver::jsonQuoted) + .collect(Collectors.joining(", ")) + "]"; + case String string -> "[" + string.replace("\n", " ") + "]"; + default -> throw new IllegalArgumentException("Cannot format " + value.getClass() + " (" + value + ") as JSON array"); + }; + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolverUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolverUnitTest.java index 34e2783f..9dd26d1d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolverUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TemplateResolverUnitTest.java @@ -2,6 +2,9 @@ package net.hostsharing.hsadminng.hs.scenarios; import org.junit.jupiter.api.Test; +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.Map; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; @@ -12,6 +15,12 @@ class TemplateResolverUnitTest { @Test void resolveTemplate() { final var resolved = new TemplateResolver(""" + JSON arrays: + - arrayWithMixedValues: @{arrayWithMixedValues} + - arrayWithObjects: @{arrayWithObjects} + - emptyArray: @{emptyArray} + - nullArray: @{nullArray} + with optional JSON quotes: ${boolean}, @@ -19,7 +28,7 @@ class TemplateResolverUnitTest { ${simple placeholder}, ${nested %{name}}, ${with-special-chars} - + and without quotes: %{boolean}, @@ -36,16 +45,34 @@ class TemplateResolverUnitTest { &{nested %{name}}, &{with-special-chars} """, - Map.ofEntries( - Map.entry("name", "placeholder"), - Map.entry("boolean", true), - Map.entry("numeric", 42), - Map.entry("simple placeholder", "einfach"), - Map.entry("nested placeholder", "verschachtelt"), - Map.entry("with-special-chars", "3&3 AG") + orderedMapOfElementsWithNullValues( + entry("arrayWithMixedValues", new Object[] { "some string", true, 1234, "another string" }), + entry("arrayWithObjects", new Object[] { + orderedMapOfElementsWithNullValues( + Map.entry("name", "some name"), + Map.entry("number", 12345) + ), + orderedMapOfElementsWithNullValues( + Map.entry("name", "another name"), + Map.entry("number", 98765) + ) + }), + entry("emptyArray", new Object[] {}), + entry("nullArray", null), + entry("name", "placeholder"), + entry("boolean", true), + entry("numeric", 42), + entry("simple placeholder", "einfach"), + entry("nested placeholder", "verschachtelt"), + entry("with-special-chars", "3&3 AG") )).resolve(DROP_COMMENTS); assertThat(resolved).isEqualTo(""" + JSON arrays: + - arrayWithMixedValues: ["some string", true, 1234, "another string"] + - arrayWithObjects: [{"name": "some name", "number": 12345}, {"name": "another name", "number": 98765}] + - emptyArray: [] + with optional JSON quotes: true, @@ -71,4 +98,20 @@ class TemplateResolverUnitTest { 3%263+AG """.trim()); } + + @SafeVarargs + private Map orderedMapOfElementsWithNullValues( + final Map.Entry... entries) { + final var map = new LinkedHashMap(); + if (entries != null) { + Arrays.stream(entries) + .forEach(entry -> map.put(entry.getKey(), entry.getValue())); + } + return map; + } + + private static AbstractMap.SimpleEntry entry(String key, Object value) { + return new AbstractMap.SimpleEntry<>(key, value); + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java index 8923d305..c63f4ede 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java @@ -449,7 +449,7 @@ class RbacSubjectControllerAcceptanceTest { RbacSubjectEntity givenANewUser() { final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; final var givenUser = jpaAttempt.transacted(() -> { - context.define(null); + context.define("superuser-alex@hostsharing.net"); return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName)); }).assumeSuccessful().returnedValue(); assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();