HsBookingItemControllerAcceptanceTest passing

This commit is contained in:
Michael Hoennig 2024-09-30 15:21:02 +02:00
parent 7e004d3eed
commit 8ee04ecee3
22 changed files with 386 additions and 117 deletions

View File

@ -17,7 +17,7 @@ public class JsonObjectMapperConfiguration {
public Jackson2ObjectMapperBuilder customObjectMapper() { public Jackson2ObjectMapperBuilder customObjectMapper() {
return new Jackson2ObjectMapperBuilder() return new Jackson2ObjectMapperBuilder()
.modules(new JsonNullableModule(), new JavaTimeModule()) .modules(new JsonNullableModule(), new JavaTimeModule())
.featuresToEnable(JsonParser.Feature.ALLOW_COMMENTS) .featuresToEnable(JsonParser.Feature.ALLOW_COMMENTS, JsonParser.Feature.ALLOW_COMMENTS)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
} }
} }

View File

@ -27,7 +27,7 @@ public final class HashGenerator {
"abcdefghijklmnopqrstuvwxyz" + "abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789/."; "0123456789/.";
private static boolean couldBeHashEnabled; // TODO.impl: remove after legacy data is migrated private static boolean couldBeHashEnabled; // TODO.legacy: remove after legacy data is migrated
public enum Algorithm { public enum Algorithm {
LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"), LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"),

View File

@ -4,13 +4,57 @@ import lombok.Getter;
import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEvent;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Getter @Getter
public class BookingItemCreatedEvent extends ApplicationEvent { public class BookingItemCreatedEvent extends ApplicationEvent {
private final @NotNull HsBookingItem newBookingItem;
public BookingItemCreatedEvent(@NotNull HsBookingItemController source, @NotNull final HsBookingItem newBookingItem) { private static final Map<UUID, BookingItemCreatedEvent> events = new HashMap<>(); // FIXME: use DB table
static BookingItemCreatedEvent of(final HsBookingItemRealEntity bookingItem) {
return events.get(bookingItem.getUuid());
}
@Getter
public static class Status {
private final String message;
private Status(final String message) {
this.message = message;
}
public static Status finished() {
return new Status(null);
}
public static Status failed(final String errorMessage) {
return new Status(errorMessage);
}
boolean isFinished() {
return message == null;
}
}
private final @NotNull UUID bookingItemUuid;
private final @NotNull String assetJson;
private Status status;
public BookingItemCreatedEvent(
@NotNull final HsBookingItemController source,
@NotNull final HsBookingItem newBookingItem,
final String assetJson) {
super(source); super(source);
this.newBookingItem = newBookingItem; this.bookingItemUuid = newBookingItem.getUuid();
this.assetJson = assetJson;
}
public void setStatus(final Status status) {
this.status = status;
events.put(bookingItemUuid, this);
} }
} }

View File

@ -1,5 +1,7 @@
package net.hostsharing.hsadminng.hs.booking.item; package net.hostsharing.hsadminng.hs.booking.item;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource;
@ -40,6 +42,9 @@ public class HsBookingItemController implements HsBookingItemsApi {
@Autowired @Autowired
private HsBookingItemRbacRepository bookingItemRepo; private HsBookingItemRbacRepository bookingItemRepo;
@Autowired
private ObjectMapper jsonMapper;
@Autowired @Autowired
private EntityManagerWrapper em; private EntityManagerWrapper em;
@ -77,7 +82,12 @@ public class HsBookingItemController implements HsBookingItemsApi {
.mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER)) .mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER))
.revampProperties(); .revampProperties();
applicationEventPublisher.publishEvent(new BookingItemCreatedEvent(this, saveProcessor.getEntity())); try {
applicationEventPublisher.publishEvent(new BookingItemCreatedEvent(
this, saveProcessor.getEntity(), jsonMapper.writeValueAsString(body.getAsset())));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
final var uri = final var uri =
MvcUriComponentsBuilder.fromController(getClass()) MvcUriComponentsBuilder.fromController(getClass())

View File

@ -48,7 +48,7 @@ public class BookingItemEntitySaveProcessor {
return this; return this;
} }
// TODO.impl: remove once the migration of legacy data is done // TODO.legacy: remove once the migration of legacy data is done
/// validates the entity itself including its properties, but ignoring some error messages for import of legacy data /// validates the entity itself including its properties, but ignoring some error messages for import of legacy data
public BookingItemEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) { public BookingItemEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
step("validateEntity", "prepareForSave"); step("validateEntity", "prepareForSave");

View File

@ -1,39 +1,30 @@
package net.hostsharing.hsadminng.hs.booking.item.validators; package net.hostsharing.hsadminng.hs.booking.item.validators;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS; import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS;
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator { class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}"; public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
public static final String TARGET_UNIX_USER_PROPERTY_NAME = "targetUnixUser";
public static final String WEBSPACE_NAME_REGEX = "[a-z][a-z0-9]{2}[0-9]{2}"; public static final String WEBSPACE_NAME_REGEX = "[a-z][a-z0-9]{2}[0-9]{2}";
public static final String TARGET_UNIX_USER_NAME_REGEX = "^"+WEBSPACE_NAME_REGEX+"$|^"+WEBSPACE_NAME_REGEX+"-[a-z0-9\\._-]+$"; public static final String TARGET_UNIX_USER_NAME_REGEX = "^"+WEBSPACE_NAME_REGEX+"$|^"+WEBSPACE_NAME_REGEX+"-[a-z0-9\\._-]+$";
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode"; public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
HsDomainSetupBookingItemValidator() { HsDomainSetupBookingItemValidator() {
super( super(
// TODO.spec: feels wrong
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce() stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
.maxLength(253) .maxLength(253)
.matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name") .matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
.notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name") .notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
.required(), .required(),
// TODO.legacy: remove the following property once we give up legacy compatibility
stringProperty(TARGET_UNIX_USER_PROPERTY_NAME).writeOnce()
.maxLength(253)
.matchesRegEx(TARGET_UNIX_USER_NAME_REGEX).describedAs("is not a valid unix-user name")
.writeOnce()
.required(),
stringProperty(VERIFICATION_CODE_PROPERTY_NAME) stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
.minLength(12) .minLength(12)
.maxLength(64) .maxLength(64)
@ -41,19 +32,6 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
); );
} }
@Override
public List<String> validateEntity(final HsBookingItem bookingItem) {
final var violations = new ArrayList<String>();
final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
if (!bookingItem.isLoaded() &&
domainName.matches("hostsharing.(com|net|org|coop|de)")) {
violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
+ "' is a forbidden Hostsharing domain name");
}
violations.addAll(super.validateEntity(bookingItem));
return violations;
}
private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) { private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
final var userDefinedVerificationCode = propertiesProvider.getDirectValue(VERIFICATION_CODE_PROPERTY_NAME, String.class); final var userDefinedVerificationCode = propertiesProvider.getDirectValue(VERIFICATION_CODE_PROPERTY_NAME, String.class);
if (userDefinedVerificationCode != null) { if (userDefinedVerificationCode != null) {

View File

@ -1,14 +1,31 @@
package net.hostsharing.hsadminng.hs.hosting.asset; package net.hostsharing.hsadminng.hs.hosting.asset;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetTypeResource;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEvent; import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEvent;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEvent.Status;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener; import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import jakarta.validation.ValidationException;
import java.net.IDN;
import java.util.Map;
import java.util.UUID;
import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP;
@Component @Component
public class HsBookingItemCreatedListener implements ApplicationListener<BookingItemCreatedEvent> { public class HsBookingItemCreatedListener implements ApplicationListener<BookingItemCreatedEvent> {
@ -16,41 +33,143 @@ public class HsBookingItemCreatedListener implements ApplicationListener<Booking
@Autowired @Autowired
private EntityManagerWrapper emw; private EntityManagerWrapper emw;
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private StandardMapper standardMapper;
@Override @Override
@SneakyThrows
public void onApplicationEvent(final BookingItemCreatedEvent event) { public void onApplicationEvent(final BookingItemCreatedEvent event) {
System.out.println("Received newly created booking item: " + event.getNewBookingItem());
final var newBookingItemRealEntity = final var newBookingItemRealEntity =
emw.getReference(HsBookingItemRealEntity.class, event.getNewBookingItem().getUuid()); emw.getReference(HsBookingItemRealEntity.class, event.getBookingItemUuid());
final var newHostingAsset = switch (newBookingItemRealEntity.getType()) { final var asset = jsonMapper.readValue(event.getAssetJson(), HsHostingAssetAutoInsertResource.class);
final var factory = switch (newBookingItemRealEntity.getType()) {
case PRIVATE_CLOUD -> null; case PRIVATE_CLOUD -> null;
case CLOUD_SERVER -> null; case CLOUD_SERVER -> null;
case MANAGED_SERVER -> null; case MANAGED_SERVER -> null;
case MANAGED_WEBSPACE -> null; case MANAGED_WEBSPACE -> null;
case DOMAIN_SETUP -> createDomainSetupHostingAsset(newBookingItemRealEntity); case DOMAIN_SETUP -> new DomainSetupHostingAssetFactory(newBookingItemRealEntity, asset);
}; };
if (newHostingAsset != null) { if (factory != null) {
try { event.setStatus(factory.performSaveProcess());
new HostingAssetEntitySaveProcessor(emw, newHostingAsset)
.preprocessEntity()
.validateEntity()
.prepareForSave()
.save()
.validateContext();
} catch (final Exception e) {
// TODO.impl: store status in a separate field, maybe enum+message
newBookingItemRealEntity.getResources().put("status", e.getMessage());
}
} }
} }
private HsHostingAsset createDomainSetupHostingAsset(final HsBookingItemRealEntity fromBookingItem) { private <T> T ref(final Class<T> entityClass, final UUID uuid) {
return HsHostingAssetRbacEntity.builder() return uuid != null ? emw.getReference(entityClass, uuid) : null;
.bookingItem(fromBookingItem) }
.type(HsHostingAssetType.DOMAIN_SETUP)
.identifier(fromBookingItem.getDirectValue("domainName", String.class)) @RequiredArgsConstructor
.subHostingAssets(List.of( abstract class HostingAssetFactory {
// TARGET_UNIX_USER_PROPERTY_NAME
)) final HsBookingItemRealEntity fromBookingItem;
.build(); final HsHostingAssetAutoInsertResource asset;
protected HsHostingAsset create() {
return null;
}
Status performSaveProcess() {
final var newHostingAsset = create();
try {
return persist(newHostingAsset);
} catch (final Exception e) {
return Status.failed(e.getMessage());
}
}
protected Status persist(final HsHostingAsset newHostingAsset) {
new HostingAssetEntitySaveProcessor(emw, newHostingAsset)
.preprocessEntity()
.validateEntity()
.prepareForSave()
.save()
.validateContext();
return Status.finished();
}
}
class DomainSetupHostingAssetFactory extends HostingAssetFactory {
public DomainSetupHostingAssetFactory(
final HsBookingItemRealEntity newBookingItemRealEntity,
final HsHostingAssetAutoInsertResource asset) {
super(newBookingItemRealEntity, asset);
}
@Override
protected HsHostingAsset create() {
final String domainName = asset.getIdentifier();
final var domainSetupAsset = createDomainSetupAsset(domainName);
// TODO.legacy: as long as we need to be compatible, we always do all technical domain-setups
final var subHostingAssetResources = asset.getSubHostingAssets();
final var domainHttpSetupAssetResource = subHostingAssetResources.stream()
.filter(ha -> ha.getType() == HsHostingAssetTypeResource.DOMAIN_HTTP_SETUP)
.findFirst().orElseThrow(() -> new ValidationException(
domainName + ": missing target unix user (assignedToHostingAssetUuid) for DOMAIN_HTTP_SETUP "));
final var domainHttpSetupAsset = domainSetupAsset.getSubHostingAssets().stream().filter(sha -> sha.getType() == DOMAIN_HTTP_SETUP).findFirst().orElseThrow();
domainHttpSetupAsset.setParentAsset(domainSetupAsset);
final HsHostingAssetRealEntity assignedToUnixUserAsset =
emw.find(HsHostingAssetRealEntity.class, domainHttpSetupAssetResource.getAssignedToAssetUuid());
domainHttpSetupAsset.setAssignedToAsset(assignedToUnixUserAsset);
if (subHostingAssetResources.stream().noneMatch(ha -> ha.getType() == HsHostingAssetTypeResource.DOMAIN_DNS_SETUP)) {
domainSetupAsset.getSubHostingAssets().add(HsHostingAssetRealEntity.builder()
.type(DOMAIN_DNS_SETUP)
.parentAsset(domainSetupAsset)
.assignedToAsset(assignedToUnixUserAsset.getParentAsset()) // FIXME: why is that needed?
.identifier(domainName + "|DNS")
.config(Map.ofEntries(
// FIXME:
entry("TTL", 21600),
entry("auto-SOA", true)
))
.build());
}
if (subHostingAssetResources.stream().noneMatch(ha -> ha.getType() == HsHostingAssetTypeResource.DOMAIN_MBOX_SETUP)) {
domainSetupAsset.getSubHostingAssets().add(HsHostingAssetRealEntity.builder()
.type(DOMAIN_MBOX_SETUP)
.parentAsset(domainSetupAsset)
.assignedToAsset(assignedToUnixUserAsset.getParentAsset())
.identifier(domainName + "|MBOX")
.caption("HTTP-Setup für " + IDN.toUnicode(domainName))
.build());
}
if (subHostingAssetResources.stream().noneMatch(ha -> ha.getType() == HsHostingAssetTypeResource.DOMAIN_SMTP_SETUP)) {
domainSetupAsset.getSubHostingAssets().add(HsHostingAssetRealEntity.builder()
.type(DOMAIN_SMTP_SETUP)
.parentAsset(domainSetupAsset)
.assignedToAsset(assignedToUnixUserAsset.getParentAsset())
.identifier(domainName + "|SMTP")
.caption("HTTP-Setup für " + IDN.toUnicode(domainName))
.build());
}
return domainSetupAsset;
}
private HsHostingAssetRealEntity createDomainSetupAsset(final String domainName) {
return HsHostingAssetRealEntity.builder()
.bookingItem(fromBookingItem)
.type(HsHostingAssetType.DOMAIN_SETUP)
.identifier(domainName)
.caption(asset.getCaption() != null ? asset.getCaption() : domainName)
.parentAsset(ref(HsHostingAssetRealEntity.class, asset.getParentAssetUuid()))
.alarmContact(ref(HsOfficeContactRealEntity.class, asset.getAlarmContactUuid()))
// FIXME .config(asset.getConfig())
.subHostingAssets(
standardMapper.mapList(asset.getSubHostingAssets(), HsHostingAssetRealEntity.class)
)
.build();
}
@Override
protected Status persist(final HsHostingAsset newHostingAsset) {
final var status = super.persist(newHostingAsset);
newHostingAsset.getSubHostingAssets().forEach(super::persist);
return status;
}
} }
} }

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository; import org.springframework.data.repository.Repository;
import jakarta.validation.constraints.NotNull;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@ -13,6 +14,18 @@ public interface HsHostingAssetRealRepository extends HsHostingAssetRepository<H
List<HsHostingAssetRealEntity> findByIdentifier(String assetIdentifier); List<HsHostingAssetRealEntity> findByIdentifier(String assetIdentifier);
default List<HsHostingAssetRealEntity> findByTypeAndIdentifier(@NotNull HsHostingAssetType type, @NotNull String identifier) {
return findByTypeAndIdentifierImpl(type.name(), identifier);
}
@Query("""
select ha
from HsHostingAssetRealEntity ha
where cast(ha.type as String) = :type
and ha.identifier = :identifier
""")
List<HsHostingAssetRealEntity> findByTypeAndIdentifierImpl(@NotNull String type, @NotNull String identifier);
@Query(value = """ @Query(value = """
select ha.uuid, select ha.uuid,
ha.alarmcontactuuid, ha.alarmcontactuuid,

View File

@ -42,7 +42,7 @@ public class HostingAssetEntitySaveProcessor {
return this; return this;
} }
// TODO.impl: remove once the migration of legacy data is done // TODO.legacy: remove once the migration of legacy data is done
/// validates the entity itself including its properties, but ignoring some error messages for import of legacy data /// validates the entity itself including its properties, but ignoring some error messages for import of legacy data
public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) { public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
step("validateEntity", "prepareForSave"); step("validateEntity", "prepareForSave");

View File

@ -15,7 +15,7 @@ import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanPro
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
// TODO.impl: make package private once we've migrated the legacy data // TODO.legacy: make package private once we've migrated the legacy data
public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator { public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator {
// according to RFC 1035 (section 5) and RFC 1034 // according to RFC 1035 (section 5) and RFC 1034
@ -33,7 +33,7 @@ public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityVal
RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT; RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
public static final String IDENTIFIER_SUFFIX = "|DNS"; public static final String IDENTIFIER_SUFFIX = "|DNS";
private static List<String> zoneFileErrors = null; // TODO.impl: remove once legacy data is migrated private static List<String> zoneFileErrors = null; // TODO.legacy: remove once legacy data is migrated
HsDomainDnsSetupHostingAssetValidator() { HsDomainDnsSetupHostingAssetValidator() {
super( super(

View File

@ -31,7 +31,7 @@ public class PasswordProperty extends StringProperty<PasswordProperty> {
@Override @Override
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) { protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
// TODO.impl: remove after legacy data is migrated // TODO.legacy: remove after legacy data is migrated
if (HashGenerator.using(hashedUsing).couldBeHash(propValue) && propValue.length() > this.maxLength()) { if (HashGenerator.using(hashedUsing).couldBeHash(propValue) && propValue.length() > this.maxLength()) {
// already hashed => do not validate // already hashed => do not validate
return; return;

View File

@ -58,6 +58,17 @@ components:
nullable: false nullable: false
type: type:
$ref: '#/components/schemas/HsBookingItemType' $ref: '#/components/schemas/HsBookingItemType'
identifier:
type: string
minLength: 3
maxLength: 80
nullable: false
description: only used as a default value for automatically created hosting assets, not part of the booking item
assignedToHostingAssetUuid:
type: string
format: uuid
nullable: false
description: only used as a default value for automatically created hosting assets, not part of the booking item
caption: caption:
type: string type: string
minLength: 3 minLength: 3
@ -69,6 +80,8 @@ components:
nullable: true nullable: true
resources: resources:
$ref: '#/components/schemas/BookingResources' $ref: '#/components/schemas/BookingResources'
asset:
$ref: '../hs-hosting/hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetAutoInsert'
required: required:
- caption - caption
- projectUuid - projectUuid

View File

@ -94,7 +94,68 @@ components:
- type - type
- identifier - identifier
- caption - caption
- config additionalProperties: false
HsHostingAssetAutoInsert:
type: object
properties:
parentAssetUuid:
type: string
format: uuid
nullable: true
assignedToAssetUuid:
type: string
format: uuid
type:
$ref: '#/components/schemas/HsHostingAssetType'
identifier:
type: string
minLength: 3
maxLength: 80
nullable: false
caption:
type: string
minLength: 3
maxLength: 80
nullable: false
alarmContactUuid:
type: string
format: uuid
nullable: true
config:
$ref: '#/components/schemas/HsHostingAssetConfiguration'
subHostingAssets:
type: array
items:
$ref: '#/components/schemas/HsHostingAssetSubInsert'
required:
- identifier
additionalProperties: false
HsHostingAssetSubInsert:
type: object
properties:
assignedToAssetUuid:
type: string
format: uuid
type:
$ref: '#/components/schemas/HsHostingAssetType'
identifier:
type: string
minLength: 3
maxLength: 80
nullable: false
caption:
type: string
minLength: 3
maxLength: 80
nullable: false
alarmContactUuid:
type: string
format: uuid
nullable: true
config:
$ref: '#/components/schemas/HsHostingAssetConfiguration'
additionalProperties: false additionalProperties: false
HsHostingAssetConfiguration: HsHostingAssetConfiguration:

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -41,7 +41,7 @@ create table if not exists hs_hosting.asset
config jsonb not null, config jsonb not null,
alarmContactUuid uuid null references hs_office.contact(uuid) initially deferred, alarmContactUuid uuid null references hs_office.contact(uuid) initially deferred,
unique (type, identifier), -- at least as long as we need to be compatible to the legacy system unique (type, identifier), -- TODO.legacy: at least as long as we need to be compatible to the legacy system
constraint hosting_asset_has_booking_item_or_parent_asset constraint hosting_asset_has_booking_item_or_parent_asset
check (bookingItemUuid is not null or parentAssetUuid is not null or type in ('DOMAIN_SETUP', 'IPV4_NUMBER', 'IPV6_NUMBER')) check (bookingItemUuid is not null or parentAssetUuid is not null or type in ('DOMAIN_SETUP', 'IPV4_NUMBER', 'IPV6_NUMBER'))

View File

@ -1,6 +1,6 @@
--liquibase formatted sql --liquibase formatted sql
-- TODO: These changesets are just for the external remote views to simulate the legacy tables. -- TODO.legacy: These changesets are just for the external remote views to simulate the legacy tables.
-- Once we don't need the external remote views anymore, create revert changesets. -- Once we don't need the external remote views anymore, create revert changesets.
-- ============================================================================ -- ============================================================================

View File

@ -5,12 +5,14 @@ import io.restassured.RestAssured;
import io.restassured.http.ContentType; import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealRepository; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns; import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@ -31,6 +33,7 @@ import java.util.UUID;
import static java.util.Map.entry; import static java.util.Map.entry;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.matchesRegex; import static org.hamcrest.Matchers.matchesRegex;
@ -70,11 +73,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
// given // given
context("superuser-alex@hostsharing.net"); context("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream() final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
RestAssured // @formatter:off RestAssured // @formatter:off
.given() .given()
@ -138,11 +137,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
void globalAdmin_canAddBookingItem() { void globalAdmin_canAddBookingItem() {
context.define("superuser-alex@hostsharing.net"); context.define("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream() final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
final var location = RestAssured // @formatter:off final var location = RestAssured // @formatter:off
.given() .given()
@ -189,34 +184,41 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
void projectAgent_canAddBookingItemWithHostingAsset() { void projectAgent_canAddBookingItemWithHostingAsset() {
context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT"); context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream() final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) final var givenUnixUser = realHostingAssetRepo.findByTypeAndIdentifier(UNIX_USER, "fir01-web").stream()
.flatMap(List::stream) .findFirst().orElseThrow();
.findFirst()
.orElseThrow();
Dns.fakeResultForDomain("example.org", Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code")); Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
final var location = RestAssured // @formatter:off final var location = RestAssured // @formatter:off
.given() .given()
.header("current-subject", "superuser-alex@hostsharing.net") .header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON) .contentType(ContentType.JSON)
.body(""" .body("""
{ {
"projectUuid": "{projectUuid}", "projectUuid": "{projectUuid}",
"type": "DOMAIN_SETUP", "type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking", "caption": "Domain-Setup for example.org",
"resources": { "resources": {
"domainName": "example.org", "domainName": "example.org",
"targetUnixUser": "fir01-web", "verificationCode": "just-a-fake-verification-code"
"verificationCode": "just-a-fake-verification-code" },
"asset": { // FIXME: rename to hostingAsset
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserUuid}"
}
]
}
} }
} """
""" .replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{projectUuid}", givenProject.getUuid().toString()) .replace("{unixUserUuid}", givenUnixUser.getUuid().toString())
) )
.port(port) .port(port)
.when() .when()
.post("http://localhost/api/hs/booking/items") .post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat() .then().log().all().assertThat()
@ -225,10 +227,9 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
.body("", lenientlyEquals(""" .body("", lenientlyEquals("""
{ {
"type": "DOMAIN_SETUP", "type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking", "caption": "Domain-Setup for example.org",
"validFrom": "{today}", "validFrom": "{today}",
"validTo": null, "validTo": null
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" }
} }
""" """
.replace("{today}", LocalDate.now().toString()) .replace("{today}", LocalDate.now().toString())
@ -240,24 +241,37 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
// then, the new BookingItem can be accessed under the generated UUID // then, the new BookingItem can be accessed under the generated UUID
final var newBookingItem = fetchRealBookingItemFromURI(location); final var newBookingItem = fetchRealBookingItemFromURI(location);
assertThat(newBookingItem) assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("domainName", String.class)) .extracting(HsBookingItem::getCaption)
.isEqualTo("example.org"); .isEqualTo("Domain-Setup for example.org");
// and the related HostingAsset also got created // and the related HostingAssets are also got created
assertThat(realHostingAssetRepo.findByIdentifier("example.org")).isNotEmpty() final var domainSetupHostingAsset = realHostingAssetRepo.findByIdentifier("example.org");
assertThat(domainSetupHostingAsset).isNotEmpty()
.map(HsHostingAsset::getBookingItem) .map(HsHostingAsset::getBookingItem)
.contains(newBookingItem); .contains(newBookingItem);
assertThat(realHostingAssetRepo.findByIdentifier("example.org|DNS")).isNotEmpty()
.map(HsHostingAsset::getParentAsset)
.isEqualTo(domainSetupHostingAsset);
assertThat(realHostingAssetRepo.findByIdentifier("example.org|HTTP")).isNotEmpty()
.map(HsHostingAsset::getParentAsset)
.isEqualTo(domainSetupHostingAsset);
assertThat(realHostingAssetRepo.findByIdentifier("example.org|MBOX")).isNotEmpty()
.map(HsHostingAsset::getParentAsset)
.isEqualTo(domainSetupHostingAsset);
assertThat(realHostingAssetRepo.findByIdentifier("example.org|SMTP")).isNotEmpty()
.map(HsHostingAsset::getParentAsset)
.isEqualTo(domainSetupHostingAsset);
final var status = BookingItemCreatedEvent.of(newBookingItem);
assertThat(status.getStatus().isFinished());
} }
@Test @Test
void projectAgent_canAddBookingItemEvenIfHostingAssetCreationFails() { void projectAgent_canAddBookingItemEvenIfHostingAssetCreationFails() {
context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT"); context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream() final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) final var givenUnixUser = realHostingAssetRepo.findByIdentifier("fir01-web").stream().findFirst().orElseThrow();
.flatMap(List::stream)
.findFirst()
.orElseThrow();
Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode
@ -272,12 +286,21 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
"caption": "some new domain-setup booking", "caption": "some new domain-setup booking",
"resources": { "resources": {
"domainName": "example.org", "domainName": "example.org",
"targetUnixUser": "fir01-web",
"verificationCode": "just-a-fake-verification-code" "verificationCode": "just-a-fake-verification-code"
},
"asset": { // FIXME: rename to hostingAsset
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserUuid}"
}
]
} }
} }
""" """
.replace("{projectUuid}", givenProject.getUuid().toString()) .replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{unixUserUuid}", givenUnixUser.getUuid().toString())
) )
.port(port) .port(port)
.when() .when()
@ -291,7 +314,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
"caption": "some new domain-setup booking", "caption": "some new domain-setup booking",
"validFrom": "{today}", "validFrom": "{today}",
"validTo": null, "validTo": null,
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" } "resources": { "domainName": "example.org" }
} }
""" """
.replace("{today}", LocalDate.now().toString()) .replace("{today}", LocalDate.now().toString())
@ -305,8 +328,8 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
assertThat(newBookingItem) assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("domainName", String.class)) .extracting(bi -> bi.getDirectValue("domainName", String.class))
.isEqualTo("example.org"); .isEqualTo("example.org");
assertThat(newBookingItem) final var status = BookingItemCreatedEvent.of(newBookingItem);
.extracting(bi -> bi.getDirectValue("status", String.class)) assertThat(status.getStatus().getMessage())
.isEqualTo("[[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=just-a-fake-verification-code' found for domain name 'example.org' (nor in its super-domain)]"); .isEqualTo("[[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=just-a-fake-verification-code' found for domain name 'example.org' (nor in its super-domain)]");
// but the related HostingAsset did not get created // but the related HostingAsset did not get created
@ -314,6 +337,14 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
} }
} }
private @NotNull HsBookingProjectRealEntity findDefaultProjectOfDebitorNumber(final int debitorNumber) {
return debitorRepo.findByDebitorNumber(debitorNumber).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
}
@Nested @Nested
@Order(1) @Order(1)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)

View File

@ -502,7 +502,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
// this happens if a natural person is marked as 'contractual' for itself // this happens if a natural person is marked as 'contractual' for itself
final var idsToRemove = new HashSet<Integer>(); final var idsToRemove = new HashSet<Integer>();
relations.forEach((id, r) -> { relations.forEach((id, r) -> {
if (r.getHolder() == r.getAnchor()) { if (r.getType() == HsOfficeRelationType.REPRESENTATIVE && r.getHolder() == r.getAnchor()) {
idsToRemove.add(id); idsToRemove.add(id);
} }
}); });