Compare commits

...

3 Commits

14 changed files with 139 additions and 37 deletions

View File

@ -158,8 +158,8 @@ public class HsBookingItemEntity implements Stringifyable, BaseEntity<HsBookingI
} }
@Override @Override
public Map<String, Object> directProps() { public PatchableMapWrapper<Object> directProps() {
return resources; return getResources();
} }
@Override @Override

View File

@ -128,8 +128,8 @@ public class HsHostingAssetEntity implements HsHostingAsset {
} }
@Override @Override
public Map<String, Object> directProps() { public PatchableMapWrapper<Object> directProps() {
return config; return getConfig();
} }
@Override @Override

View File

@ -14,7 +14,7 @@ public class HsHostingAssetEntityPatcher implements EntityPatcher<HsHostingAsset
private final EntityManager em; private final EntityManager em;
private final HsHostingAssetEntity entity; private final HsHostingAssetEntity entity;
HsHostingAssetEntityPatcher(final EntityManager em, final HsHostingAssetEntity entity) { public HsHostingAssetEntityPatcher(final EntityManager em, final HsHostingAssetEntity entity) {
this.em = em; this.em = em;
this.entity = entity; this.entity = entity;
} }

View File

@ -1,11 +1,11 @@
package net.hostsharing.hsadminng.hs.validation; package net.hostsharing.hsadminng.hs.validation;
import java.util.Map; import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
public interface PropertiesProvider { public interface PropertiesProvider {
boolean isLoaded(); boolean isLoaded();
Map<String, Object> directProps(); PatchableMapWrapper directProps();
Object getContextValue(final String propName); Object getContextValue(final String propName);
default <T> T getDirectValue(final String propName, final Class<T> clazz) { default <T> T getDirectValue(final String propName, final Class<T> clazz) {
@ -16,6 +16,10 @@ public interface PropertiesProvider {
return cast(propName, directProps().get(propName), clazz, defaultValue); return cast(propName, directProps().get(propName), clazz, defaultValue);
} }
default boolean isPatched(String propertyName) {
return directProps().isPatched(propertyName);
}
default <T> T getContextValue(final String propName, final Class<T> clazz) { default <T> T getContextValue(final String propName, final Class<T> clazz) {
return cast(propName, getContextValue(propName), clazz, null); return cast(propName, getContextValue(propName), clazz, null);
} }

View File

@ -206,6 +206,9 @@ public abstract class ValidatableProperty<P extends ValidatableProperty<?, ?>, T
if (required == TRUE) { if (required == TRUE) {
result.add(propertyName + "' is required but missing"); result.add(propertyName + "' is required but missing");
} }
if (isWriteOnce() && propsProvider.isLoaded() && propsProvider.isPatched(propertyName) ) {
result.add(propertyName + "' is write-once but got removed");
}
validateRequiresAtLeastOneOf(result, propsProvider); validateRequiresAtLeastOneOf(result, propsProvider);
} }
if (propValue != null){ if (propValue != null){
@ -248,10 +251,12 @@ public abstract class ValidatableProperty<P extends ValidatableProperty<?, ?>, T
} }
protected void validate(final List<String> result, final T propValue, final PropertiesProvider propProvider) { protected void validate(final List<String> result, final T propValue, final PropertiesProvider propProvider) {
if (isReadOnly() && propValue != null) { if (isReadOnly() && propValue != null) {
result.add(propertyName + "' is readonly but given as " + display(propValue)); result.add(propertyName + "' is readonly but given as " + display(propValue));
} }
if (isWriteOnce() && propProvider.isLoaded() && propValue != null && propProvider.isPatched(propertyName) ) {
result.add(propertyName + "' is write-once but given as " + display(propValue));
}
} }
public void verifyConsistency(final Map.Entry<? extends Enum<?>, ?> typeDef) { public void verifyConsistency(final Map.Entry<? extends Enum<?>, ?> typeDef) {

View File

@ -7,7 +7,9 @@ import org.apache.commons.lang3.tuple.ImmutablePair;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -23,6 +25,7 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
.configure(SerializationFeature.INDENT_OUTPUT, true); .configure(SerializationFeature.INDENT_OUTPUT, true);
private final Map<String, T> delegate; private final Map<String, T> delegate;
private final Set<String> patched = new HashSet<>();
private PatchableMapWrapper(final Map<String, T> map) { private PatchableMapWrapper(final Map<String, T> map) {
delegate = map; delegate = map;
@ -36,6 +39,10 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
}); });
} }
public static <T> PatchableMapWrapper<T> of(final Map<String, T> delegate) {
return new PatchableMapWrapper<T>(delegate);
}
@NotNull @NotNull
public static <E> ImmutablePair<String, E> entry(final String key, final E value) { public static <E> ImmutablePair<String, E> entry(final String key, final E value) {
return new ImmutablePair<>(key, value); return new ImmutablePair<>(key, value);
@ -45,6 +52,7 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
if (entries != null ) { if (entries != null ) {
delegate.clear(); delegate.clear();
delegate.putAll(entries); delegate.putAll(entries);
patched.clear();
} }
} }
@ -58,6 +66,10 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
}); });
} }
public boolean isPatched(final String propertyName) {
return patched.contains(propertyName);
}
@SneakyThrows @SneakyThrows
public String toString() { public String toString() {
return jsonWriter.writeValueAsString(delegate); return jsonWriter.writeValueAsString(delegate);
@ -92,11 +104,17 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
@Override @Override
public T put(final String key, final T value) { public T put(final String key, final T value) {
if (!Objects.equals(value, delegate.get(key))) {
patched.add(key);
}
return delegate.put(key, value); return delegate.put(key, value);
} }
@Override @Override
public T remove(final Object key) { public T remove(final Object key) {
if (delegate.containsKey(key.toString())) {
patched.add(key.toString());
}
return delegate.remove(key); return delegate.remove(key);
} }
@ -107,20 +125,24 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
@Override @Override
public void clear() { public void clear() {
patched.addAll(delegate.keySet());
delegate.clear(); delegate.clear();
} }
@Override @Override
@NotNull
public Set<String> keySet() { public Set<String> keySet() {
return delegate.keySet(); return delegate.keySet();
} }
@Override @Override
@NotNull
public Collection<T> values() { public Collection<T> values() {
return delegate.values(); return delegate.values();
} }
@Override @Override
@NotNull
public Set<Entry<String, T>> entrySet() { public Set<Entry<String, T>> entrySet() {
return delegate.entrySet(); return delegate.entrySet();
} }

View File

@ -588,7 +588,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
}).returnedValue()).isPresent().get() }).returnedValue()).isPresent().get()
.matches(asset -> { .matches(asset -> {
assertThat(asset.getCaption()).isEqualTo("some patched test-unix-user"); assertThat(asset.getCaption()).isEqualTo("some patched test-unix-user");
assertThat(asset.getConfig().toString()).isEqualTo(""" assertThat(asset.getConfig().toString()).isEqualToIgnoringWhitespace("""
{ {
"password": "$6$Jr5w/Y8zo8pCkqg7$/rePRbvey3R6Sz/02YTlTQcRt5qdBPTj2h5.hz.rB8NfIoND8pFOjeB7orYcPs9JNf3JDxPP2V.6MQlE5BwAY/", "password": "$6$Jr5w/Y8zo8pCkqg7$/rePRbvey3R6Sz/02YTlTQcRt5qdBPTj2h5.hz.rB8NfIoND8pFOjeB7orYcPs9JNf3JDxPP2V.6MQlE5BwAY/",
"shell": "/bin/bash", "shell": "/bin/bash",

View File

@ -444,6 +444,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu
.extracting(HsHostingAssetEntity::toString) .extracting(HsHostingAssetEntity::toString)
.extracting(input -> input.replaceAll("\\s+", " ")) .extracting(input -> input.replaceAll("\\s+", " "))
.extracting(input -> input.replaceAll("\"", "")) .extracting(input -> input.replaceAll("\"", ""))
.extracting(input -> input.replaceAll("\" : ", "\": "))
.containsExactlyInAnyOrder(serverNames); .containsExactlyInAnyOrder(serverNames);
} }
} }

View File

@ -4,13 +4,15 @@ import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.mapper.Array;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import static java.util.Map.entry; import static java.util.Map.ofEntries;
import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM; import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS;
import static net.hostsharing.hsadminng.hs.hosting.asset.TestHsHostingAssetEntities.TEST_MANAGED_SERVER_HOSTING_ASSET; import static net.hostsharing.hsadminng.hs.hosting.asset.TestHsHostingAssetEntities.TEST_MANAGED_SERVER_HOSTING_ASSET;
import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class HsEMailAddressHostingAssetValidatorUnitTest { class HsEMailAddressHostingAssetValidatorUnitTest {
@ -28,11 +30,11 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
return HsHostingAssetEntity.builder() return HsHostingAssetEntity.builder()
.type(EMAIL_ADDRESS) .type(EMAIL_ADDRESS)
.parentAsset(domainMboxSetup) .parentAsset(domainMboxSetup)
.identifier("test@example.org") .identifier("old-local-part@example.org")
.config(Map.ofEntries( .config(new HashMap<>(ofEntries(
entry("local-part", "test"), entry("local-part", "old-local-part"),
entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com"))
)); )));
} }
@Test @Test
@ -64,10 +66,10 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
void rejectsInvalidProperties() { void rejectsInvalidProperties() {
// given // given
final var emailAddressHostingAssetEntity = validEntityBuilder() final var emailAddressHostingAssetEntity = validEntityBuilder()
.config(Map.ofEntries( .config(new HashMap<>(ofEntries(
entry("local-part", "no@allowed"), entry("local-part", "no@allowed"),
entry("sub-domain", "no@allowedeither"), entry("sub-domain", "no@allowedeither"),
entry("target", Array.of("xyz00", "xyz00-abc", "garbage", "office@example.com")))) entry("target", Array.of("xyz00", "xyz00-abc", "garbage", "office@example.com")))))
.build(); .build();
final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType());
@ -76,9 +78,69 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactlyInAnyOrder( assertThat(result).containsExactlyInAnyOrder(
"'EMAIL_ADDRESS:test@example.org.config.local-part' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match", "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match",
"'EMAIL_ADDRESS:test@example.org.config.sub-domain' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match", "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match",
"'EMAIL_ADDRESS:test@example.org.config.target' is expected to match any of [^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$] but 'garbage' does not match any"); "'EMAIL_ADDRESS:old-local-part@example.org.config.target' is expected to match any of [^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$] but 'garbage' does not match any");
}
@Test
void rejectsOverwritingWriteOnceProperties() {
// given
final var emailAddressHostingAssetEntity = validEntityBuilder()
.isLoaded(true)
.build();
final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType());
// when
emailAddressHostingAssetEntity.getConfig().put("local-part", "new-local-part");
emailAddressHostingAssetEntity.getConfig().put("sub-domain", "new-sub-domain");
final var result = validator.validateEntity(emailAddressHostingAssetEntity);
// then
assertThat(result).containsExactlyInAnyOrder(
"'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is write-once but given as 'new-local-part'",
"'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is write-once but given as 'new-sub-domain'");
}
@Test
void rejectsRemovingWriteOnceProperties() {
// given
final var emailAddressHostingAssetEntity = validEntityBuilder()
.config(new HashMap<>(ofEntries(
entry("local-part", "old-local-part"),
entry("sub-domain", "old-sub-domain"),
entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com"))
)))
.isLoaded(true)
.build();
final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType());
// when
emailAddressHostingAssetEntity.getConfig().remove("local-part");
emailAddressHostingAssetEntity.getConfig().remove("sub-domain");
final var result = validator.validateEntity(emailAddressHostingAssetEntity);
// then
assertThat(result).containsExactlyInAnyOrder(
"'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is write-once but got removed",
"'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is write-once but got removed");
}
@Test
void acceptsOverwritingWriteOncePropertiesWithSameValues() {
// given
final var emailAddressHostingAssetEntity = validEntityBuilder()
.isLoaded(true)
.build();
final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType());
// when
emailAddressHostingAssetEntity.getConfig().put("local-part", "old-local-part");
emailAddressHostingAssetEntity.getConfig().remove("sub-domain"); // is not there anyway
final var result = validator.validateEntity(emailAddressHostingAssetEntity);
// then
assertThat(result).isEmpty();
} }
@Test @Test
@ -94,7 +156,7 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactlyInAnyOrder( assertThat(result).containsExactlyInAnyOrder(
"'identifier' expected to match '^\\Qtest@example.org\\E$', but is 'abc00-office'"); "'identifier' expected to match '^\\Qold-local-part@example.org\\E$', but is 'abc00-office'");
} }
@Test @Test
@ -112,8 +174,8 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactlyInAnyOrder( assertThat(result).containsExactlyInAnyOrder(
"'EMAIL_ADDRESS:test@example.org.bookingItem' must be null but is of type MANAGED_SERVER", "'EMAIL_ADDRESS:old-local-part@example.org.bookingItem' must be null but is of type MANAGED_SERVER",
"'EMAIL_ADDRESS:test@example.org.parentAsset' must be of type DOMAIN_MBOX_SETUP but is of type MANAGED_SERVER", "'EMAIL_ADDRESS:old-local-part@example.org.parentAsset' must be of type DOMAIN_MBOX_SETUP but is of type MANAGED_SERVER",
"'EMAIL_ADDRESS:test@example.org.assignedToAsset' must be null but is of type MANAGED_SERVER"); "'EMAIL_ADDRESS:old-local-part@example.org.assignedToAsset' must be null but is of type MANAGED_SERVER");
} }
} }

View File

@ -103,8 +103,8 @@ public class HsHostingAssetRealEntity implements HsHostingAsset {
} }
@Override @Override
public Map<String, Object> directProps() { public PatchableMapWrapper<Object> directProps() {
return config; return getConfig();
} }
@Override @Override

View File

@ -652,14 +652,12 @@ public class ImportHostingAssets extends ImportOfficeData {
void validateHostingAssets(final Map<Integer, HsHostingAssetRealEntity> assets) { void validateHostingAssets(final Map<Integer, HsHostingAssetRealEntity> assets) {
assets.forEach((id, ha) -> { assets.forEach((id, ha) -> {
try { logError(() ->
new HostingAssetEntitySaveProcessor(em, ha) new HostingAssetEntitySaveProcessor(em, ha)
.preprocessEntity() .preprocessEntity()
.validateEntity() .validateEntity()
.prepareForSave(); .prepareForSave()
} catch (final Exception exc) { );
errors.add("validation failed for id:" + id + "( " + ha + "): " + exc.getMessage());
}
}); });
} }

View File

@ -134,6 +134,15 @@ public class ImportOfficeData extends CsvDataImport {
static Map<Integer, HsOfficeCoopSharesTransactionEntity> coopShares = new WriteOnceMap<>(); static Map<Integer, HsOfficeCoopSharesTransactionEntity> coopShares = new WriteOnceMap<>();
static Map<Integer, HsOfficeCoopAssetsTransactionEntity> coopAssets = new WriteOnceMap<>(); static Map<Integer, HsOfficeCoopAssetsTransactionEntity> coopAssets = new WriteOnceMap<>();
@Test
@Order(1)
void verifyInitialDatabase() {
// SQL DELETE for thousands of records takes too long, so we make sure, we only start with initial or test data
final var contactCount = (Integer) em.createNativeQuery("select count(*) from hs_office_contact", Integer.class)
.getSingleResult();
assertThat(contactCount).isLessThan(20);
}
@Test @Test
@Order(1010) @Order(1010)
void importBusinessPartners() { void importBusinessPartners() {

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.validation; package net.hostsharing.hsadminng.hs.validation;
import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator; import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
@ -109,10 +110,10 @@ class PasswordPropertyUnitTest {
} }
@Override @Override
public Map<String, Object> directProps() { public PatchableMapWrapper<Object> directProps() {
return Map.ofEntries( return PatchableMapWrapper.of(Map.ofEntries(
entry(passwordProp.propertyName, "some password") entry(passwordProp.propertyName, "some password")
); ));
} }
@Override @Override

View File

@ -34,7 +34,7 @@ public abstract class PatchUnitTestBase<R, E> {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void willPatchAllProperties() { protected void willPatchAllProperties() {
// given // given
final var givenEntity = newInitialEntity(); final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource(); final var patchResource = newPatchResource();
@ -55,7 +55,7 @@ public abstract class PatchUnitTestBase<R, E> {
@ParameterizedTest @ParameterizedTest
@MethodSource("propertyTestCases") @MethodSource("propertyTestCases")
void willPatchOnlyGivenProperty(final Property<R, Object, E, Object> testCase) { protected void willPatchOnlyGivenProperty(final Property<R, Object, E, Object> testCase) {
// given // given
final var givenEntity = newInitialEntity(); final var givenEntity = newInitialEntity();