diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java index cb4e3446..26636eb4 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java @@ -12,15 +12,14 @@ import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAsse import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource; import net.hostsharing.hsadminng.mapper.KeyValueMap; import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; 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.EntityManager; import jakarta.persistence.EntityNotFoundException; -import jakarta.persistence.PersistenceContext; import java.util.List; import java.util.Map; import java.util.UUID; @@ -29,8 +28,8 @@ import java.util.function.BiConsumer; @RestController public class HsHostingAssetController implements HsHostingAssetsApi { - @PersistenceContext - private EntityManager em; + @Autowired + private EntityManagerWrapper emw; @Autowired private Context context; @@ -75,7 +74,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi { final var entity = mapper.map(body, HsHostingAssetRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - final var mapped = new HostingAssetEntitySaveProcessor(em, entity) + final var mapped = new HostingAssetEntitySaveProcessor(emw, entity) .preprocessEntity() .validateEntity() .prepareForSave() @@ -134,9 +133,9 @@ public class HsHostingAssetController implements HsHostingAssetsApi { final var entity = rbacAssetRepo.findByUuid(assetUuid).orElseThrow(); - new HsHostingAssetEntityPatcher(em, entity).apply(body); + new HsHostingAssetEntityPatcher(emw, entity).apply(body); - final var mapped = new HostingAssetEntitySaveProcessor(em, entity) + final var mapped = new HostingAssetEntitySaveProcessor(emw, entity) .preprocessEntity() .validateEntity() .prepareForSave() @@ -165,5 +164,5 @@ public class HsHostingAssetController implements HsHostingAssetsApi { @SuppressWarnings("unchecked") final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> resource.setConfig(HostingAssetEntityValidatorRegistry.forType(entity.getType()) - .revampProperties(em, entity, (Map) resource.getConfig())); + .revampProperties(emw, entity, (Map) resource.getConfig())); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java index 085d9e0f..b9f82a87 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java @@ -26,6 +26,10 @@ public class ArrayProperty

, E> extends Valid } public static ArrayProperty arrayOf(final ValidatableProperty elementsOf) { + if (elementsOf.type != String.class) { + // see also net.hostsharing.hsadminng.mapper.PatchableMapWrapper.fixValueType + throw new IllegalArgumentException("currently arrayOf(...) is only implemented for stringProperty(...)"); + } //noinspection unchecked return (ArrayProperty) new ArrayProperty<>(elementsOf); } diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java index 6f08b923..a81d6739 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java @@ -6,6 +6,7 @@ import lombok.SneakyThrows; import org.apache.commons.lang3.tuple.ImmutablePair; import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.Map; @@ -107,7 +108,7 @@ public class PatchableMapWrapper implements Map { if (!Objects.equals(value, delegate.get(key))) { patched.add(key); } - return delegate.put(key, value); + return delegate.put(key, fixValueType(value)); } @Override @@ -146,4 +147,15 @@ public class PatchableMapWrapper implements Map { public Set> entrySet() { return delegate.entrySet(); } + + private T fixValueType(final T value) { + if (value instanceof ArrayList arrayListValue) { + // Jackson deserialization creates ArrayList for JSON arrays, but we need a String[]. + // Jackson could be configured to create Object[], but that does not help. + final var valueToPut = arrayListValue.stream().map(Object::toString).toArray(String[]::new); + //noinspection unchecked + return (T) valueToPut; + } + return value; + } } diff --git a/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java b/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java new file mode 100644 index 00000000..309986bc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java @@ -0,0 +1,300 @@ +package net.hostsharing.hsadminng.persistence; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.FlushModeType; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import jakarta.persistence.StoredProcedureQuery; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.CriteriaUpdate; +import jakarta.persistence.metamodel.Metamodel; +import java.util.List; +import java.util.Map; + +/** A Spring bean wrapper for the EntityManager. + * + *

@PersistenceContext cannot be properly mocked in @WebMvcTest-based tests + * because Spring will always create a proxy for the mock which then fails because it has no active transaction.

+ * + *

Also, @PersistenceContext cannot be used for constructor injection, though a bean factory would solve that problem.

+ * + *

Use this wrapper **only** if needed for a @WebMvcTest with a mocked EntityManager, otherwise use the original EntityManager.

+ */ +@Component +@NoArgsConstructor +@AllArgsConstructor +public class EntityManagerWrapper implements EntityManager { + + @PersistenceContext + EntityManager em; + + @Override + public void persist(final Object entity) { + em.persist(entity); + } + + @Override + public T merge(final T entity) { + return em.merge(entity); + } + + @Override + public void remove(final Object entity) { + em.remove(entity); + } + + @Override + public T find(final Class entityClass, final Object primaryKey) { + return em.find(entityClass, primaryKey); + } + + @Override + public T find(final Class entityClass, final Object primaryKey, final Map properties) { + return em.find(entityClass, primaryKey, properties); + } + + @Override + public T find(final Class entityClass, final Object primaryKey, final LockModeType lockMode) { + return em.find(entityClass, primaryKey, lockMode); + } + + @Override + public T find( + final Class entityClass, + final Object primaryKey, + final LockModeType lockMode, + final Map properties) { + return em.find(entityClass, primaryKey, lockMode, properties); + } + + @Override + public T getReference(final Class entityClass, final Object primaryKey) { + return em.getReference(entityClass, primaryKey); + } + + @Override + public void flush() { + em.flush(); + } + + @Override + public void setFlushMode(final FlushModeType flushMode) { + em.setFlushMode(flushMode); + } + + @Override + public FlushModeType getFlushMode() { + return em.getFlushMode(); + } + + @Override + public void lock(final Object entity, final LockModeType lockMode) { + em.lock(entity, lockMode); + } + + @Override + public void lock(final Object entity, final LockModeType lockMode, final Map properties) { + em.lock(entity, lockMode, properties); + } + + @Override + public void refresh(final Object entity) { + em.refresh(entity); + } + + @Override + public void refresh(final Object entity, final Map properties) { + em.refresh(entity, properties); + } + + @Override + public void refresh(final Object entity, final LockModeType lockMode) { + em.refresh(entity, lockMode); + } + + @Override + public void refresh(final Object entity, final LockModeType lockMode, final Map properties) { + em.refresh(entity, lockMode, properties); + } + + @Override + public void clear() { + em.clear(); + } + + @Override + public void detach(final Object entity) { + em.detach(entity); + } + + @Override + public boolean contains(final Object entity) { + return em.contains(entity); + } + + @Override + public LockModeType getLockMode(final Object entity) { + return em.getLockMode(entity); + } + + @Override + public void setProperty(final String propertyName, final Object value) { + em.setProperty(propertyName, value); + } + + @Override + public Map getProperties() { + return em.getProperties(); + } + + @Override + public Query createQuery(final String qlString) { + return em.createQuery(qlString); + } + + @Override + public TypedQuery createQuery(final CriteriaQuery criteriaQuery) { + return em.createQuery(criteriaQuery); + } + + @Override + public Query createQuery(final CriteriaUpdate updateQuery) { + return em.createQuery(updateQuery); + } + + @Override + public Query createQuery(final CriteriaDelete deleteQuery) { + return em.createQuery(deleteQuery); + } + + @Override + public TypedQuery createQuery(final String qlString, final Class resultClass) { + return em.createQuery(qlString, resultClass); + } + + @Override + public Query createNamedQuery(final String name) { + return em.createNamedQuery(name); + } + + @Override + public TypedQuery createNamedQuery(final String name, final Class resultClass) { + return em.createNamedQuery(name, resultClass); + } + + @Override + public Query createNativeQuery(final String sqlString) { + return em.createNativeQuery(sqlString); + } + + @Override + public Query createNativeQuery(final String sqlString, final Class resultClass) { + return em.createNativeQuery(sqlString, resultClass); + } + + @Override + public Query createNativeQuery(final String sqlString, final String resultSetMapping) { + return em.createNativeQuery(sqlString, resultSetMapping); + } + + @Override + public StoredProcedureQuery createNamedStoredProcedureQuery(final String name) { + return em.createNamedStoredProcedureQuery(name); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName) { + return em.createStoredProcedureQuery(procedureName); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName, final Class... resultClasses) { + return em.createStoredProcedureQuery(procedureName, resultClasses); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName, final String... resultSetMappings) { + return em.createStoredProcedureQuery(procedureName, resultSetMappings); + } + + @Override + public void joinTransaction() { + em.joinTransaction(); + } + + @Override + public boolean isJoinedToTransaction() { + return em.isJoinedToTransaction(); + } + + @Override + public T unwrap(final Class cls) { + return em.unwrap(cls); + } + + @Override + public Object getDelegate() { + return em.getDelegate(); + } + + @Override + public void close() { + em.close(); + } + + @Override + public boolean isOpen() { + return em.isOpen(); + } + + @Override + public EntityTransaction getTransaction() { + return em.getTransaction(); + } + + @Override + public EntityManagerFactory getEntityManagerFactory() { + return em.getEntityManagerFactory(); + } + + @Override + public CriteriaBuilder getCriteriaBuilder() { + return em.getCriteriaBuilder(); + } + + @Override + public Metamodel getMetamodel() { + return em.getMetamodel(); + } + + @Override + public EntityGraph createEntityGraph(final Class rootType) { + return em.createEntityGraph(rootType); + } + + @Override + public EntityGraph createEntityGraph(final String graphName) { + return em.createEntityGraph(graphName); + } + + @Override + public EntityGraph getEntityGraph(final String graphName) { + return em.getEntityGraph(graphName); + } + + @Override + public List> getEntityGraphs(final Class entityClass) { + return em.getEntityGraphs(entityClass); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java index 79e9908e..ff2da459 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java @@ -1,20 +1,26 @@ package net.hostsharing.hsadminng.hs.hosting.asset; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import lombok.SneakyThrows; +import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.runner.RunWith; -import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; @@ -24,8 +30,11 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.SynchronizationType; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; import static java.util.Map.entry; import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; @@ -36,12 +45,14 @@ import static net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealTes import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(HsHostingAssetController.class) -@Import(Mapper.class) +@Import({Mapper.class, JsonObjectMapperConfiguration.class}) @RunWith(SpringRunner.class) public class HsHostingAssetControllerRestTest { @@ -54,8 +65,8 @@ public class HsHostingAssetControllerRestTest { @Autowired Mapper mapper; - @Mock - private EntityManager em; + @MockBean + private EntityManagerWrapper em; @MockBean EntityManagerFactory emf; @@ -70,6 +81,15 @@ public class HsHostingAssetControllerRestTest { @MockBean private HsHostingAssetRbacRepository rbacAssetRepo; + @TestConfiguration + public static class TestConfig { + + @Bean + public EntityManager entityManager() { + return mock(EntityManager.class); + } + + } enum ListTestCases { CLOUD_SERVER( List.of( @@ -584,4 +604,129 @@ public class HsHostingAssetControllerRestTest { assertThat(resultBody.get(n).path("config")).isEqualTo(testCase.expectedConfig(n)); } } + + @Test + void shouldPatchAsset() throws Exception { + // given + final var givenDomainSetup = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.DOMAIN_SETUP) + .identifier("example.org") + .caption("some fake Domain-Setup") + .build(); + final var givenUnixUser = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .caption("some fake Unix-User") + .config(Map.ofEntries( + entry("password", "$6$salt$hashed-salted-password"), + entry("totpKey", "0x0123456789abcdef"), + entry("shell", "/bin/bash"), + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build(); + final var givenDomainHttpSetupUuid = UUID.randomUUID(); + final var givenDomainHttpSetupHostingAsset = HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .identifier("example.org|HTTP") + .caption("some fake Domain-HTTP-Setup") + .parentAsset(givenDomainSetup) + .assignedToAsset(givenUnixUser) + .config(new HashMap<>(Map.ofEntries( + entry("htdocsfallback", false), + entry("indexes", false), + entry("cgi", false), + entry("passenger", false), + entry("passenger-errorpage", true), + entry("fastcgi", false), + entry("autoconfig", false), + entry("greylisting", false), + entry("includes", false), + entry("letsencrypt", false), + entry("multiviews", false), + entry("fcgi-php-bin", "/usr/lib/cgi-bin/php-orig"), + entry("passenger-nodejs", "/usr/bin/node-js7"), + entry("passenger-python", "/usr/bin/python6"), + entry("passenger-ruby", "/usr/bin/ruby5"), + entry("subdomains", Array.of("www", "test1", "test2")) + ))) + .build(); + when(rbacAssetRepo.findByUuid(givenDomainHttpSetupUuid)).thenReturn(Optional.of(givenDomainHttpSetupHostingAsset)); + when(em.contains(givenDomainHttpSetupHostingAsset)).thenReturn(true); + doNothing().when(em).flush(); + + // when + final var result = mockMvc.perform(MockMvcRequestBuilders + .patch("/api/hs/hosting/assets/" + givenDomainHttpSetupUuid) + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "updated example.org|HTTP", + "caption": "some updated fake Domain-HTTP-Setup", + "alarmContact": null, + "config": { + "autoconfig": true, + "multiviews": true, + "passenger": false, + "fcgi-php-bin": null, + "passenger-nodejs": "/usr/bin/node-js8", + "subdomains": ["www","test"] + } + } + """) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "example.org|HTTP", + "caption": "some updated fake Domain-HTTP-Setup", + "alarmContact": null + } + """))) + .andReturn(); + + // and the config properties do match not just leniently but even strictly + final var actualConfig = formatJsonNode(result.getResponse().getContentAsString()); + final var expectedConfig = formatJsonNode(""" + { + "config": { + "autoconfig" : true, + "cgi" : false, + "fastcgi" : false, + // "fcgi-php-bin" : "/usr/lib/cgi-bin/php", TODO.spec: do we want defaults to work like initializers? + "greylisting" : false, + "htdocsfallback" : false, + "includes" : false, + "indexes" : false, + "letsencrypt" : false, + "multiviews" : true, + "passenger" : false, + "passenger-errorpage" : true, + "passenger-nodejs" : "/usr/bin/node-js8", + "passenger-python" : "/usr/bin/python6", + "passenger-ruby" : "/usr/bin/ruby5", + "subdomains" : [ "www", "test" ] + } + } + """); + assertThat(actualConfig).isEqualTo(expectedConfig); + } + + private static final ObjectMapper SORTED_MAPPER = new ObjectMapper(); + static { + SORTED_MAPPER.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + private static String formatJsonNode(final String json) throws JsonProcessingException { + final var node = SORTED_MAPPER.readTree(json.replaceAll("//.*", "")).path("config"); + final var obj = SORTED_MAPPER.treeToValue(node, Object.class); + return SORTED_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(obj); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java new file mode 100644 index 00000000..f9db2070 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java @@ -0,0 +1,61 @@ +package net.hostsharing.hsadminng.persistence; + +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import jakarta.persistence.EntityManager; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +class EntityManagerWrapperUnitTest { + private EntityManagerWrapper wrapper; + private EntityManager delegateMock; + + @BeforeEach + public void setUp() { + delegateMock = mock(EntityManager.class); + wrapper = new EntityManagerWrapper(delegateMock); + } + + @Test + public void testAllMethodsAreForwarded() throws Exception { + final var methods = EntityManager.class.getMethods(); + + for (Method method : methods) { + // given prepared dummy arguments (if any) + final var args = Arrays.stream(method.getParameterTypes()) + .map(this::getDefaultValue) + .toArray(); + + // when + method.invoke(wrapper, args); + + // then verify that the same method was called on the mock delegate + Mockito.verify(delegateMock, times(1)).getClass() + .getMethod(method.getName(), method.getParameterTypes()) + .invoke(delegateMock, args); + } + } + + private Object getDefaultValue(Class type) { + if (type == boolean.class) return false; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0f; + if (type == double.class) return 0.0; + if (type == char.class) return '\0'; + if (type == String.class) return "dummy"; + if (type == String[].class) return Array.of("dummy"); + if (type == Class.class) return String.class; + if (type == Class[].class) return Array.of(String.class); + return mock(type); + } +}