add DomainSetup-HostingAssets for new BookingItem via created-event #111

Merged
hsh-michaelhoennig merged 31 commits from add-hsoting-asset-to-booking-item-resource-and-created-event into master 2024-10-08 11:48:35 +02:00
77 changed files with 1558 additions and 273 deletions

View File

@ -0,0 +1,119 @@
# Handling Automatic Creation of Hosting Assets for New Booking Items
**Status:**
- [x] proposed by (Michael Hönnig)
- [ ] accepted by (Participants)
- [ ] rejected by (Participants)
- [ ] superseded by (superseding ADR)
## Context and Problem Statement
When a customer creates a new booking item (e.g., `MANAGED_WEBSPACE`), the system must automatically create the related hosting asset.
This process can sometimes fail or require additional data from the user, e.g. installing a DNS verification key, or a hostmaster, e.g. the target server to use.
The challenge is how to handle this automatic creation process while dealing with missing data, asynchronicity and failures while ensuring system consistency and proper user notification.
### Technical Background
The creation of hosting assets can occur synchronously (in simple cases) or asynchronously (when additional steps like manual verification are needed).
For example, a `DOMAIN_SETUP` hosting asset may require DNS verification from the user, and until this is provided, the related domain cannot be fully set up.
Additionally, not all data needed for creating the hosting asset is stored in the booking item.
It's part of the HTTP request and later stored in the hosting asset, but we also need to store it before the hosting asset can be created asynchronously.
Current system behavior involves returning HTTP 201 upon booking item creation, but the automatic hosting asset creation might fail due to missing information.
The system needs to manage the creation process in a way that ensures valid hosting assets are created and informs the user of any actions required while still returning a 201 HTTP code, not an error code.
## Considered Options
For storing the data needed for the hosting-asset creation:
* STORAGE-1: Store temporary asset data in the `BookingItemEntity`, e.g. a JSON column.
And delete the value of that column, once the hosting assets got successfully created.
* STORAGE-2: Create hosting assets immediately, even if invalid, but mark them as "inactive" until completed and fully validated.
* STORAGE-3: Store the asset data in a kind of event- or job-queue, which get deleted once the hosting-asset got successfully created.
For the user-notification status:
* STATUS-1: Introduce a status field in the booking-items.
* STATUS-2: Store the status in the event-/job-queue entries.
### STORAGE-1: Temporary Data Storage in `BookingItemEntity`
Store asset-related data (e.g., domain name) in a temporary column or JSON field in the `BookingItemEntity` until the hosting assets are successfully created.
Once assets are created, the temporary data is deleted to avoid inconsistencies.
#### Advantages
- Easy to implement.
#### Disadvantages
- Needs either a separate map of properties in the booking-item.
- Or, if stored as a JSON field in the booking-item-resources, these are misused.
- Requires additional cleanup logic to remove stale data.
### STORAGE-2: Inactive Hosting Assets Until Validation
Create the hosting assets immediately upon booking item creation but mark them as "inactive" until all required information (e.g., verification code) is provided and validation is complete.
#### Advantages
- Avoids temporary external data storage for the hosting-assets.
#### Disadvantages
- Validation becomes more complex as some properties need to be validated, others not.
And some properties even need special treatment for new entities, which then becomes vague.
- Inactive assets have to be filtered from operational assets.
- Potential risk of incomplete or inconsistent assets being created, which may require correction.
- Difficult to write tests for all possible combinations of validations.
### STORAGE-3: Event-Based Approach
The hosting asset data required for creation us passed to the API and stored in a `BookingItemCreatedEvent`.
If hosting asset creation cannot happen synchronously, the event is stored and processed asynchronously in batches, retrying failed asset creation as needed.
#### Advantages
- Clean-data-structure (separation of concerns).
- Clear separation between booking item creation and hosting asset creation.
- Only valid assets in the database.
- Can handle complex asynchronous processes (like waiting for external verification) in a clean and structured manner.
- Easier to manage retries and failures in asset creation without complicating the booking item structure.
#### Disadvantages
- At the Spring controller level, the whole JSON is already converted into Java objects,
but for storing the asset data in the even, we need JSON again.
This could is not just a performance-overhead but could also lead to inconsistencies.
### STATUS-1: Store hosting-asset-creation-status in the `BookingItemEntity`
A status field would be added to booking-items to track the creation state of related hosting assets.
The users could check their booking-items for the status of the hosting-asset creation, error messages and further instructions.
#### Advantages
- Easy to implement.
#### Disadvantages
- Adds a field to the booking-item which is makes no sense anymore once the related hosting asset is created.
### Status-2: Store hosting-asset-creation-status in the `BookingItemCreateEvent`
A status field would be added to the booking-item-created event and get updated with the latest messages any time we try to create the hosting-asset.
#### Advantages
- Clean-data-structure (separation of concerns)
#### Disadvantages
- Accessing the status requires querying the event queue.
## Decision Outcome
**Chosen Option: STORAGE-3 with STATUS-2 (Event-Based Approach with `BookingItemCreatedEvent`)**
The event-based approach was selected as the best solution for handling automatic hosting asset creation. This option provides a clear separation between booking item creation and hosting asset creation, ensuring that no invalid or incomplete assets are created. The asynchronous nature of the event system allows for retries and external validation steps (such as user-entered verification codes) without disrupting the overall flow.
By using `BookingItemCreatedEvent` to store the hosting-asset data and the status,
we don't need to misuse other data structures for temporary data
and therefore hava a clean separation of concerns.

View File

@ -17,7 +17,7 @@ public class JsonObjectMapperConfiguration {
public Jackson2ObjectMapperBuilder customObjectMapper() {
return new Jackson2ObjectMapperBuilder()
.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);
}
}

View File

@ -27,7 +27,7 @@ public final class HashGenerator {
"abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"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 {
LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"),

View File

@ -0,0 +1,20 @@
package net.hostsharing.hsadminng.hs.booking.item;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import jakarta.validation.constraints.NotNull;
@Getter
public class BookingItemCreatedAppEvent extends ApplicationEvent {
private BookingItemCreatedEventEntity entity;
public BookingItemCreatedAppEvent(
@NotNull final Object source,
@NotNull final HsBookingItemRealEntity newBookingItem,
final String assetJson) {
super(source);
this.entity = new BookingItemCreatedEventEntity(newBookingItem, assetJson);
}
}

View File

@ -1,16 +0,0 @@
package net.hostsharing.hsadminng.hs.booking.item;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import jakarta.validation.constraints.NotNull;
@Getter
public class BookingItemCreatedEvent extends ApplicationEvent {
private final @NotNull HsBookingItem newBookingItem;
public BookingItemCreatedEvent(@NotNull HsBookingItemController source, @NotNull final HsBookingItem newBookingItem) {
super(source);
this.newBookingItem = newBookingItem;
}
}

View File

@ -0,0 +1,55 @@
package net.hostsharing.hsadminng.hs.booking.item;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
@Entity
@Table(schema = "hs_booking", name = "item_created_event")
@SuperBuilder(toBuilder = true)
@Getter
@ToString
@NoArgsConstructor
public class BookingItemCreatedEventEntity implements BaseEntity {
@Id
@Column(name="bookingitemuuid")
private UUID uuid;
@MapsId
@ManyToOne(optional = false)
@JoinColumn(name = "bookingitemuuid", nullable = false)
private HsBookingItemRealEntity bookingItem;
@Version
private int version;
@Column(name = "assetjson")
private String assetJson;
@Setter
@Column(name = "statusmessage")
private String statusMessage;
public BookingItemCreatedEventEntity(
@NotNull final HsBookingItemRealEntity newBookingItem,
final String assetJson) {
this.bookingItem = newBookingItem;
this.assetJson = assetJson;
}
}

View File

@ -0,0 +1,12 @@
package net.hostsharing.hsadminng.hs.booking.item;
import org.springframework.data.repository.Repository;
import java.util.UUID;
public interface BookingItemCreatedEventRepository extends Repository<BookingItemCreatedEventEntity, UUID> {
BookingItemCreatedEventEntity save(HsBookingItemRealEntity current);
BookingItemCreatedEventEntity findByBookingItem(HsBookingItemRealEntity newBookingItem);
}

View File

@ -14,7 +14,7 @@ import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.Type;

View File

@ -1,5 +1,7 @@
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.hs.booking.generated.api.v1.api.HsBookingItemsApi;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource;
@ -23,6 +25,7 @@ import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
@RestController
@ -40,6 +43,9 @@ public class HsBookingItemController implements HsBookingItemsApi {
@Autowired
private HsBookingItemRbacRepository bookingItemRepo;
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private EntityManagerWrapper em;
@ -76,8 +82,7 @@ public class HsBookingItemController implements HsBookingItemsApi {
.validateContext()
.mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER))
.revampProperties();
applicationEventPublisher.publishEvent(new BookingItemCreatedEvent(this, saveProcessor.getEntity()));
publishSavedEvent(saveProcessor, body);
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
@ -137,6 +142,16 @@ public class HsBookingItemController implements HsBookingItemsApi {
return ResponseEntity.ok(mapped);
}
private void publishSavedEvent(final BookingItemEntitySaveProcessor saveProcessor, final HsBookingItemInsertResource body) {
try {
final var bookingItemRealEntity = em.getReference(HsBookingItemRealEntity.class, saveProcessor.getEntity().getUuid());
applicationEventPublisher.publishEvent(new BookingItemCreatedAppEvent(
this, bookingItemRealEntity, jsonMapper.writeValueAsString(body.getHostingAsset())));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
final BiConsumer<HsBookingItem, HsBookingItemResource> ITEM_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setValidFrom(entity.getValidity().lower());
if (entity.getValidity().hasUpperBound()) {
@ -148,6 +163,9 @@ public class HsBookingItemController implements HsBookingItemsApi {
final BiConsumer<HsBookingItemInsertResource, HsBookingItemRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setProject(em.find(HsBookingProjectRealEntity.class, resource.getProjectUuid()));
ofNullable(resource.getParentItemUuid())
.map(parentItemUuid -> em.find(HsBookingItemRealEntity.class, parentItemUuid))
.ifPresent(entity::setParentItem);
entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo()));
entity.putResources(KeyValueMap.from(resource.getResources()));
};

View File

@ -48,7 +48,7 @@ public class BookingItemEntitySaveProcessor {
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
public BookingItemEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
step("validateEntity", "prepareForSave");

View File

@ -13,27 +13,21 @@ import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRA
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
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 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 TARGET_UNIX_USER_NAME_REGEX = "^"+WEBSPACE_NAME_REGEX+"$|^"+WEBSPACE_NAME_REGEX+"-[a-z0-9\\._-]+$";
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
HsDomainSetupBookingItemValidator() {
super(
// TODO.spec: feels wrong
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
.maxLength(253)
.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")
.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)
.minLength(12)
.maxLength(64)

View File

@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -1,56 +0,0 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEvent;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class HsBookingItemCreatedListener implements ApplicationListener<BookingItemCreatedEvent> {
@Autowired
private EntityManagerWrapper emw;
@Override
public void onApplicationEvent(final BookingItemCreatedEvent event) {
System.out.println("Received newly created booking item: " + event.getNewBookingItem());
final var newBookingItemRealEntity =
emw.getReference(HsBookingItemRealEntity.class, event.getNewBookingItem().getUuid());
final var newHostingAsset = switch (newBookingItemRealEntity.getType()) {
case PRIVATE_CLOUD -> null;
case CLOUD_SERVER -> null;
case MANAGED_SERVER -> null;
case MANAGED_WEBSPACE -> null;
case DOMAIN_SETUP -> createDomainSetupHostingAsset(newBookingItemRealEntity);
};
if (newHostingAsset != null) {
try {
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) {
return HsHostingAssetRbacEntity.builder()
.bookingItem(fromBookingItem)
.type(HsHostingAssetType.DOMAIN_SETUP)
.identifier(fromBookingItem.getDirectValue("domainName", String.class))
.subHostingAssets(List.of(
// TARGET_UNIX_USER_PROPERTY_NAME
))
.build();
}
}

View File

@ -14,7 +14,7 @@ import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.Type;
@ -89,10 +89,9 @@ public abstract class HsHostingAsset implements Stringifyable, BaseEntity<HsHost
@JoinColumn(name = "alarmcontactuuid")
private HsOfficeContactRealEntity alarmContact;
@Builder.Default
@OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "parentassetuuid", referencedColumnName = "uuid")
private List<HsHostingAssetRealEntity> subHostingAssets = new ArrayList<>();
private List<HsHostingAssetRealEntity> subHostingAssets;
@Column(name = "identifier")
private String identifier; // e.g. vm1234, xyz00, example.org, xyz00_abc
@ -125,6 +124,13 @@ public abstract class HsHostingAsset implements Stringifyable, BaseEntity<HsHost
PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper;}, config).assign(newConfig);
}
public List<HsHostingAssetRealEntity> getSubHostingAssets() {
if (subHostingAssets == null) {
subHostingAssets = new ArrayList<>();
}
return subHostingAssets;
}
@Override
public PatchableMapWrapper<Object> directProps() {
return getConfig();

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ -13,6 +14,18 @@ public interface HsHostingAssetRealRepository extends HsHostingAssetRepository<H
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 = """
select ha.uuid,
ha.alarmcontactuuid,

View File

@ -0,0 +1,153 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetSubInsertResource;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetTypeResource;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import jakarta.validation.ValidationException;
import java.net.IDN;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
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;
public class DomainSetupHostingAssetFactory extends HostingAssetFactory {
public DomainSetupHostingAssetFactory(
final EntityManagerWrapper emw,
final HsBookingItemRealEntity newBookingItemRealEntity,
final HsHostingAssetAutoInsertResource asset,
final StandardMapper standardMapper) {
super(emw, newBookingItemRealEntity, asset, standardMapper);
}
@Override
protected HsHostingAsset create() {
final var domainSetupAsset = createDomainSetupAsset(getDomainName());
final var subHostingAssets = domainSetupAsset.getSubHostingAssets();
// TODO.legacy: as long as we need to be compatible, we always do all technical domain-setups
final var domainHttpSetupAssetResource = findSubHostingAssetResource(HsHostingAssetTypeResource.DOMAIN_HTTP_SETUP);
final var assignedToUnixUserAssetEntity = domainHttpSetupAssetResource
.map(HsHostingAssetSubInsertResource::getAssignedToAssetUuid)
.map(uuid -> emw.find(HsHostingAssetRealEntity.class, uuid))
.orElseThrow(() -> new ValidationException("DOMAIN_HTTP_SETUP subAsset with assignedToAssetUuid required in compatibility mode"));
subHostingAssets.add(
createDomainSubSetupAssetEntity(
domainSetupAsset,
DOMAIN_HTTP_SETUP,
builder -> builder
.assignedToAsset(assignedToUnixUserAssetEntity)
.identifier(getDomainName() + "|HTTP")
.caption("HTTP-Setup für " + IDN.toUnicode(getDomainName())))
);
// Do not add to subHostingAssets in compatibility mode, in this case, DNS setup works via file system.
// The entity is created just for validation purposes.
createDomainSubSetupAssetEntity(
domainSetupAsset,
DOMAIN_DNS_SETUP,
builder -> builder
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
.identifier(getDomainName() + "|DNS")
.caption("DNS-Setup für " + IDN.toUnicode(getDomainName())));
subHostingAssets.add(
createDomainSubSetupAssetEntity(
domainSetupAsset,
DOMAIN_MBOX_SETUP,
builder -> builder
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
.identifier(getDomainName() + "|MBOX")
.caption("MBOX-Setup für " + IDN.toUnicode(getDomainName())))
);
subHostingAssets.add(
createDomainSubSetupAssetEntity(
domainSetupAsset,
DOMAIN_SMTP_SETUP,
builder -> builder
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
.identifier(getDomainName() + "|SMTP")
.caption("SMTP-Setup für " + IDN.toUnicode(getDomainName())))
);
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)
.alarmContact(ref(HsOfficeContactRealEntity.class, asset.getAlarmContactUuid()))
// the sub-hosting-assets get added later
.build();
}
private HsHostingAssetRealEntity createDomainSubSetupAssetEntity(
final HsHostingAssetRealEntity domainSetupAsset,
final HsHostingAssetType subAssetType,
final Function<HsHostingAssetRealEntity.HsHostingAssetRealEntityBuilder<?, ?>, HsHostingAssetRealEntity.HsHostingAssetRealEntityBuilder<?, ?>> builderTransformer) {
final var resourceType = HsHostingAssetTypeResource.valueOf(subAssetType.name());
final var subAssetResourceOptional = findSubHostingAssetResource(resourceType);
subAssetResourceOptional.ifPresentOrElse(
subAssetResource -> verifyNotOverspecified(subAssetResource),
() -> { throw new ValidationException("sub-asset of type " + resourceType.name() + " required in legacy mode, but missing"); }
);
return builderTransformer.apply(
HsHostingAssetRealEntity.builder()
.type(subAssetType)
.parentAsset(domainSetupAsset))
.build();
}
private Optional<HsHostingAssetSubInsertResource> findSubHostingAssetResource(final HsHostingAssetTypeResource resourceType) {
return getSubHostingAssetResources().stream()
.filter(ha -> ha.getType() == resourceType)
.reduce(Reducer::toSingleElement);
}
// TODO.legacy: while we need to stay compatible, only default values can be used, thus only the type can be specified
private void verifyNotOverspecified(final HsHostingAssetSubInsertResource givenSubAssetResource) {
final var convert = new ToStringConverter().ignoring("assignedToAssetUuid");
final var expectedSubAssetResource = new HsHostingAssetSubInsertResource();
expectedSubAssetResource.setType(givenSubAssetResource.getType());
if ( !convert.from(givenSubAssetResource).equals(convert.from(expectedSubAssetResource)) ) {
throw new ValidationException("sub asset " + givenSubAssetResource.getType() + " is over-specified, in compatibility mode, only default values allowed");
}
}
private String getDomainName() {
return asset.getIdentifier();
}
private List<HsHostingAssetSubInsertResource> getSubHostingAssetResources() {
return asset.getSubHostingAssets();
}
@Override
protected void persist(final HsHostingAsset newHostingAsset) {
super.persist(newHostingAsset);
newHostingAsset.getSubHostingAssets().forEach(super::persist);
}
}

View File

@ -0,0 +1,45 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import lombok.RequiredArgsConstructor;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import java.util.UUID;
@RequiredArgsConstructor
abstract class HostingAssetFactory {
final EntityManagerWrapper emw;
final HsBookingItemRealEntity fromBookingItem;
final HsHostingAssetAutoInsertResource asset;
final StandardMapper standardMapper;
protected abstract HsHostingAsset create();
public String performSaveProcess() {
try {
final var newHostingAsset = create();
persist(newHostingAsset);
return null;
} catch (final Exception e) {
return e.getMessage();
}
}
protected void persist(final HsHostingAsset newHostingAsset) {
new HostingAssetEntitySaveProcessor(emw, newHostingAsset)
.preprocessEntity()
.validateEntity()
.prepareForSave()
.save()
.validateContext();
}
protected <T> T ref(final Class<T> entityClass, final UUID uuid) {
return uuid != null ? emw.getReference(entityClass, uuid) : null;
}
}

View File

@ -0,0 +1,80 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedAppEvent;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class HsBookingItemCreatedListener implements ApplicationListener<BookingItemCreatedAppEvent> {
@Autowired
private EntityManagerWrapper emw;
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private StandardMapper standardMapper;
@Override
@SneakyThrows
public void onApplicationEvent(final BookingItemCreatedAppEvent bookingItemCreatedAppEvent) {
if (containsAssetJson(bookingItemCreatedAppEvent)) {
createRelatedHostingAsset(bookingItemCreatedAppEvent);
}
}
private static boolean containsAssetJson(final BookingItemCreatedAppEvent bookingItemCreatedAppEvent) {
return bookingItemCreatedAppEvent.getEntity().getAssetJson() != null;
}
private void createRelatedHostingAsset(final BookingItemCreatedAppEvent event) throws JsonProcessingException {
final var newBookingItemRealEntity = event.getEntity().getBookingItem();
final var asset = jsonMapper.readValue(event.getEntity().getAssetJson(), HsHostingAssetAutoInsertResource.class);
final var factory = switch (newBookingItemRealEntity.getType()) {
case PRIVATE_CLOUD, CLOUD_SERVER, MANAGED_SERVER ->
forNowNoAutomaticHostingAssetCreationPossible(emw, newBookingItemRealEntity, asset, standardMapper);
case MANAGED_WEBSPACE -> new ManagedWebspaceHostingAssetFactory(emw, newBookingItemRealEntity, asset, standardMapper);
case DOMAIN_SETUP -> new DomainSetupHostingAssetFactory(emw, newBookingItemRealEntity, asset, standardMapper);
};
if (factory != null) {
final var statusMessage = factory.performSaveProcess();
// TODO.impl: once we implement retry, we need to amend this code (persist/merge/delete)
if (statusMessage != null) {
event.getEntity().setStatusMessage(statusMessage);
emw.persist(event.getEntity());
}
}
}
private HostingAssetFactory forNowNoAutomaticHostingAssetCreationPossible(
final EntityManagerWrapper emw,
final HsBookingItemRealEntity fromBookingItem,
final HsHostingAssetAutoInsertResource asset,
final StandardMapper standardMapper
) {
return new HostingAssetFactory(emw, fromBookingItem, asset, standardMapper) {
@Override
protected HsHostingAsset create() {
// TODO.impl: we should validate the asset JSON, but some violations are un-avoidable at that stage
return null;
}
@Override
public String performSaveProcess() {
return "waiting for manual setup of hosting asset for booking item of type " + fromBookingItem.getType();
}
};
}
}

View File

@ -0,0 +1,51 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
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.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import jakarta.validation.ValidationException;
import java.util.Optional;
public class ManagedWebspaceHostingAssetFactory extends HostingAssetFactory {
public ManagedWebspaceHostingAssetFactory(
final EntityManagerWrapper emw,
final HsBookingItemRealEntity newBookingItemRealEntity,
final HsHostingAssetAutoInsertResource asset,
final StandardMapper standardMapper) {
super(emw, newBookingItemRealEntity, asset, standardMapper);
}
@Override
protected HsHostingAsset create() {
if (asset.getType() != HsHostingAssetTypeResource.MANAGED_WEBSPACE) {
throw new ValidationException("requires MANAGED_WEBSPACE hosting asset, but got " +
Optional.of(asset)
.map(HsHostingAssetAutoInsertResource::getType)
.map(Enum::name)
.orElse(null));
}
final var managedWebspaceHostingAsset = standardMapper.map(asset, HsHostingAssetRealEntity.class);
managedWebspaceHostingAsset.setBookingItem(fromBookingItem);
emw.createQuery(
"SELECT asset FROM HsHostingAssetRealEntity asset WHERE asset.bookingItem.uuid=:bookingItemUuid",
HsHostingAssetRealEntity.class)
.setParameter("bookingItemUuid", fromBookingItem.getParentItem().getUuid())
.getResultStream().findFirst()
.ifPresent(managedWebspaceHostingAsset::setParentAsset);
return managedWebspaceHostingAsset;
}
@Override
protected void persist(final HsHostingAsset newManagedWebspaceHostingAsset) {
super.persist(newManagedWebspaceHostingAsset);
}
}

View File

@ -0,0 +1,37 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import static java.util.stream.Collectors.joining;
public class ToStringConverter {
final public Set<String> ignoredFields = new HashSet<>();
public ToStringConverter ignoring(final String fieldName) {
ignoredFields.add(fieldName);
return this;
}
public String from(Object obj) {
StringBuilder result = new StringBuilder();
return "{ " +
Arrays.stream(obj.getClass().getDeclaredFields())
.filter(f -> !ignoredFields.contains(f.getName()))
.map(field -> {
try {
field.setAccessible(true);
return field.getName() + ": " + field.get(obj);
} catch (IllegalAccessException e) {
// ignore inaccessible fields
return null;
}
})
.filter(Objects::nonNull)
.collect(joining(", "))
+ " }";
}
}

View File

@ -42,7 +42,7 @@ public class HostingAssetEntitySaveProcessor {
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
public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
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.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 {
// 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;
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() {
super(

View File

@ -3,7 +3,7 @@ package net.hostsharing.hsadminng.hs.office.bankaccount;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -11,7 +11,7 @@ import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.GenericGenerator;

View File

@ -8,7 +8,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -8,7 +8,7 @@ import lombok.Setter;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -11,7 +11,7 @@ import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.stringify.Stringify;

View File

@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;

View File

@ -13,7 +13,7 @@ import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

View File

@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.office.partner;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.stringify.Stringify;

View File

@ -10,7 +10,7 @@ import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContact;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;

View File

@ -3,7 +3,7 @@ package net.hostsharing.hsadminng.hs.office.person;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.stringify.Stringify;

View File

@ -5,7 +5,7 @@ import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -31,7 +31,7 @@ public class PasswordProperty extends StringProperty<PasswordProperty> {
@Override
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()) {
// already hashed => do not validate
return;

View File

@ -0,0 +1,8 @@
package net.hostsharing.hsadminng.lambda;
public class Reducer {
public static <T> T toSingleElement(T last, T next) {
throw new AssertionError("only a single entity expected");
}
}

View File

@ -1,11 +1,11 @@
package net.hostsharing.hsadminng.mapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.ReflectionUtils;
import jakarta.persistence.EntityManager;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PersistenceContext;
import jakarta.validation.ValidationException;
import java.lang.reflect.Field;
import java.util.List;
@ -21,10 +21,10 @@ import static net.hostsharing.hsadminng.errors.DisplayAs.DisplayName;
*/
abstract class Mapper extends ModelMapper {
@PersistenceContext
EntityManager em;
EntityManagerWrapper em;
Mapper() {
Mapper(@Autowired final EntityManagerWrapper em) {
this.em = em;
getConfiguration().setAmbiguityIgnored(true);
}

View File

@ -1,5 +1,7 @@
package net.hostsharing.hsadminng.mapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
@ -8,7 +10,8 @@ import org.springframework.stereotype.Component;
@Component
public class StandardMapper extends Mapper {
public StandardMapper() {
public StandardMapper(@Autowired final EntityManagerWrapper em) {
super(em);
getConfiguration().setAmbiguityIgnored(true);
}
}

View File

@ -1,5 +1,7 @@
package net.hostsharing.hsadminng.mapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import static org.modelmapper.convention.MatchingStrategies.STRICT;
@ -13,7 +15,8 @@ import static org.modelmapper.convention.MatchingStrategies.STRICT;
@Component
public class StrictMapper extends Mapper {
public StrictMapper() {
public StrictMapper(@Autowired final EntityManagerWrapper em) {
super(em);
getConfiguration().setMatchingStrategy(STRICT);
}
}

View File

@ -1,11 +1,10 @@
package net.hostsharing.hsadminng.rbac.object;
package net.hostsharing.hsadminng.persistence;
import org.hibernate.Hibernate;
import java.util.UUID;
// TODO.impl: this class does not really belong into this package, but there is no right place yet
public interface BaseEntity<T extends BaseEntity<?>> {
UUID getUuid();

View File

@ -1,7 +1,6 @@
package net.hostsharing.hsadminng.persistence;
import net.hostsharing.hsadminng.errors.DisplayAs.DisplayName;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

View File

@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.rbac.generator;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import org.reflections.Reflections;
import org.reflections.scanners.TypeAnnotationsScanner;

View File

@ -5,7 +5,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;

View File

@ -4,7 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.rbac.test.pac.TestPackageEntity;

View File

@ -4,7 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.generator.RbacView;
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
import net.hostsharing.hsadminng.rbac.test.cust.TestCustomerEntity;

View File

@ -56,6 +56,10 @@ components:
type: string
format: uuid
nullable: false
parentItemUuid:
type: string
format: uuid
nullable: false
type:
$ref: '#/components/schemas/HsBookingItemType'
caption:
@ -69,6 +73,8 @@ components:
nullable: true
resources:
$ref: '#/components/schemas/BookingResources'
hostingAsset:
$ref: '../hs-hosting/hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetAutoInsert'
required:
- caption
- projectUuid

View File

@ -94,7 +94,68 @@ components:
- type
- identifier
- 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:
type:
$ref: '#/components/schemas/HsHostingAssetType'
identifier:
type: string
minLength: 3
maxLength: 80
nullable: false
caption:
type: string
minLength: 3
maxLength: 80
nullable: false
assignedToAssetUuid:
type: string
format: uuid
alarmContactUuid:
type: string
format: uuid
nullable: true
config:
$ref: '#/components/schemas/HsHostingAssetConfiguration'
additionalProperties: false
HsHostingAssetConfiguration:

View File

@ -1,6 +1,6 @@
--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.
-- ============================================================================

View File

@ -1,6 +1,6 @@
--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.
-- ============================================================================

View File

@ -1,6 +1,6 @@
--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.
-- ============================================================================

View File

@ -1,6 +1,6 @@
--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.
-- ============================================================================

View File

@ -1,6 +1,6 @@
--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.
-- ============================================================================

View File

@ -31,6 +31,20 @@ create table if not exists hs_booking.item
--//
-- ============================================================================
--changeset michael.hoennig:hs-booking-item-EVENT-TABLE endDelimiter:--//
-- ----------------------------------------------------------------------------
create table if not exists hs_booking.item_created_event
(
bookingItemUuid uuid unique references hs_booking.item (uuid),
version int not null default 0,
assetJson text,
statusMessage text
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-booking-item-MAIN-TABLE-JOURNAL endDelimiter:--//
-- ----------------------------------------------------------------------------

View File

@ -41,7 +41,7 @@ create table if not exists hs_hosting.asset
config jsonb not null,
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
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
-- 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.
-- ============================================================================

View File

@ -43,6 +43,7 @@ public class ArchitectureTest {
"..test.dom",
"..context",
"..hash",
"..lambda",
"..generated..",
"..persistence..",
"..system..",
@ -64,6 +65,7 @@ public class ArchitectureTest {
"..hs.booking.item.validators",
"..hs.hosting.asset",
"..hs.hosting.asset.validators",
"..hs.hosting.asset.factories",
"..errors",
"..mapper",
"..ping",

View File

@ -5,12 +5,15 @@ import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.HsadminNgApplication;
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.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
@ -31,6 +34,8 @@ import java.util.UUID;
import static java.util.Map.entry;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.matchesRegex;
@ -58,6 +63,9 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
@Autowired
HsHostingAssetRealRepository realHostingAssetRepo;
@Autowired
BookingItemCreatedEventRepository bookingItemCreationEventRepo;
@Autowired
JpaAttempt jpaAttempt;
@ -70,11 +78,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
// given
context("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
RestAssured // @formatter:off
.given()
@ -138,11 +142,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
void globalAdmin_canAddBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
final var location = RestAssured // @formatter:off
.given()
@ -186,37 +186,121 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
}
@Test
void projectAgent_canAddBookingItemWithHostingAsset() {
void projectAgent_canAddManagedWebspaceBookingItemWithHostingAsset() {
context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
final var givenManagedServer = realHostingAssetRepo.findByTypeAndIdentifier(MANAGED_SERVER, "vm1011").stream()
.map(HsHostingAsset::getBookingItem)
.findFirst().orElseThrow();
final var location = RestAssured // @formatter:off
.given()
.header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"projectUuid": "{projectUuid}",
"type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking",
"resources": {
"domainName": "example.org",
"targetUnixUser": "fir01-web",
"verificationCode": "just-a-fake-verification-code"
{
"projectUuid": "{projectUuid}",
"parentItemUuid": "{managedServerUuid}",
"type": "MANAGED_WEBSPACE",
"caption": "some managed webspace",
"resources": {
"SSD": 25,
"Traffic": 250
},
"hostingAsset": {
"type": "MANAGED_WEBSPACE",
"identifier": "fir00"
}
}
}
"""
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{managedServerUuid}", givenManagedServer.getUuid().toString())
)
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"type": "MANAGED_WEBSPACE",
"caption": "some managed webspace",
"validFrom": "{today}",
"validTo": null
}
"""
.replace("{today}", LocalDate.now().toString())
.replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString()))
)
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/items/[^/]*"))
.extract().header("Location"); // @formatter:on
// then, the new BookingItem can be accessed under the generated UUID
final var newBookingItem = fetchRealBookingItemFromURI(location);
assertThat(newBookingItem)
.extracting(HsBookingItem::getCaption)
.isEqualTo("some managed webspace");
// and the related HostingAssets are also got created
final var domainSetupHostingAsset = realHostingAssetRepo.findByIdentifier("fir00");
assertThat(domainSetupHostingAsset).isNotEmpty()
.map(HsHostingAsset::getBookingItem)
.contains(newBookingItem);
final var event = bookingItemCreationEventRepo.findByBookingItem(newBookingItem);
assertThat(event).isNull();
}
@Test
void projectAgent_canAddDomainSetupBookingItemWithHostingAsset() {
context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT");
final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
// TODO.impl: "sec01-web" should not work, but does
final var givenUnixUser = realHostingAssetRepo.findByTypeAndIdentifier(UNIX_USER, "fir01-web").stream()
.findFirst().orElseThrow();
Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
final var location = RestAssured // @formatter:off
.given()
.header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"projectUuid": "{projectUuid}",
"type": "DOMAIN_SETUP",
"caption": "Domain-Setup for example.org",
"resources": {
"domainName": "example.org",
"verificationCode": "just-a-fake-verification-code"
},
"hostingAsset": {
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserUuid}"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
}
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{unixUserUuid}", givenUnixUser.getUuid().toString())
)
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat()
@ -225,10 +309,9 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
.body("", lenientlyEquals("""
{
"type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking",
"caption": "Domain-Setup for example.org",
"validFrom": "{today}",
"validTo": null,
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" }
"validTo": null
}
"""
.replace("{today}", LocalDate.now().toString())
@ -240,24 +323,34 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
// then, the new BookingItem can be accessed under the generated UUID
final var newBookingItem = fetchRealBookingItemFromURI(location);
assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("domainName", String.class))
.isEqualTo("example.org");
.extracting(HsBookingItem::getCaption)
.isEqualTo("Domain-Setup for example.org");
// and the related HostingAsset also got created
assertThat(realHostingAssetRepo.findByIdentifier("example.org")).isNotEmpty()
// and the related HostingAssets are also got created
final var domainSetupHostingAsset = realHostingAssetRepo.findByIdentifier("example.org");
assertThat(domainSetupHostingAsset).isNotEmpty()
.map(HsHostingAsset::getBookingItem)
.contains(newBookingItem);
// TODO.legacy: add check for example.org|DNS
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 event = bookingItemCreationEventRepo.findByBookingItem(newBookingItem);
assertThat(event).isNull();
}
@Test
void projectAgent_canAddBookingItemEvenIfHostingAssetCreationFails() {
context.define("superuser-alex@hostsharing.net", "hs_booking.project#D-1000111-D-1000111defaultproject:AGENT");
final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream)
.findFirst()
.orElseThrow();
final var givenProject = findDefaultProjectOfDebitorNumber(1000111);
final var givenUnixUser = realHostingAssetRepo.findByIdentifier("fir01-web").stream().findFirst().orElseThrow();
Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode
@ -272,12 +365,30 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
"caption": "some new domain-setup booking",
"resources": {
"domainName": "example.org",
"targetUnixUser": "fir01-web",
"verificationCode": "just-a-fake-verification-code"
},
"hostingAsset": {
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserUuid}"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
}
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{unixUserUuid}", givenUnixUser.getUuid().toString())
)
.port(port)
.when()
@ -291,7 +402,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
"caption": "some new domain-setup booking",
"validFrom": "{today}",
"validTo": null,
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" }
"resources": { "domainName": "example.org" }
}
"""
.replace("{today}", LocalDate.now().toString())
@ -305,8 +416,8 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("domainName", String.class))
.isEqualTo("example.org");
assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("status", String.class))
final var event = bookingItemCreationEventRepo.findByBookingItem(newBookingItem);
assertThat(event.getStatusMessage())
.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
@ -314,6 +425,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
@Order(1)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@ -534,6 +653,13 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
}).assertSuccessful().returnedValue();
}
@AfterEach
void cleanupEventEntities() {
jpaAttempt.transacted(() -> {
em.createQuery("delete from BookingItemCreatedEventEntity").executeUpdate();
}).assertSuccessful();
}
private Map.Entry<String, Object> resource(final String key, final Object value) {
return entry(key, value);
}

View File

@ -36,8 +36,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.org"),
entry("targetUnixUser", "xyz00")
entry("domainName", "example.org")
))
.build();
@ -59,7 +58,6 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.org"),
entry("targetUnixUser", "xyz00"),
entry("verificationCode", "1234-5678-9100")
))
.build();
@ -80,8 +78,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253)),
entry("targetUnixUser", "xyz00")
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253))
))
.build();
@ -99,8 +96,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254)),
entry("targetUnixUser", "xyz00")
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254))
))
.build();
@ -118,8 +114,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.com"),
entry("targetUnixUser", "xyz00-test")
entry("domainName", "example.com")
))
.build();
@ -130,25 +125,6 @@ class HsDomainSetupBookingItemValidatorUnitTest {
assertThat(result).isEmpty();
}
@Test
void rejectsInvalidUnixUser() {
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.com"),
entry("targetUnixUser", "xyz00test")
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.targetUnixUser' = 'xyz00test' is not a valid unix-user name");
}
@ParameterizedTest
@ValueSource(strings = {
"de", "com", "net", "org", "actually-any-top-level-domain",
@ -196,8 +172,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", secondLevelRegistrarDomain),
entry("targetUnixUser", "xyz00")
entry("domainName", secondLevelRegistrarDomain)
))
.build();
@ -219,7 +194,6 @@ class HsDomainSetupBookingItemValidatorUnitTest {
// then
assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder(
"{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}], matchesRegExDescription=is not a (non-top-level) fully qualified domain name, notMatchesRegEx=[[^.]+, (co|org|gov|ac|sch)\\.uk, (com|net|org|edu|gov|asn|id)\\.au, (co|ne|or|ac|go)\\.jp, (com|net|org|gov|edu|ac)\\.cn, (com|net|org|gov|edu|mil|art)\\.br, (co|net|org|gen|firm|ind)\\.in, (com|net|org|gob|edu)\\.mx, (gov|edu)\\.it, (co|net|org|govt|ac|school|geek|kiwi)\\.nz, (co|ne|or|go|re|pe)\\.kr], notMatchesRegExDescription=is a forbidden registrar-level domain name, maxLength=253, required=true, writeOnce=true}",
"{type=string, propertyName=targetUnixUser, matchesRegEx=[^[a-z][a-z0-9]{2}[0-9]{2}$|^[a-z][a-z0-9]{2}[0-9]{2}-[a-z0-9\\._-]+$], matchesRegExDescription=is not a valid unix-user name, maxLength=253, required=true, writeOnce=true}",
"{type=string, propertyName=verificationCode, minLength=12, maxLength=64, computed=IN_INIT}");
}
}

View File

@ -157,6 +157,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
)
);
final var givenParentAsset = givenParentAsset(MANAGED_SERVER, "vm1011");
final var expectedUnixUserId = nextUnixUserId();
final var location = RestAssured // @formatter:off
.given()
@ -184,10 +185,12 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
"identifier": "fir10",
"caption": "some separate ManagedWebspace HA",
"config": {
"groupid": 1000000
"groupid": {lastUnixUserId}
}
}
"""))
"""
.replace("{lastUnixUserId}", expectedUnixUserId.toString())
))
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*"))
.extract().header("Location"); // @formatter:on
@ -205,9 +208,11 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
.isEqualTo("""
HsHostingAsset(UNIX_USER, fir10, fir10 webspace user, MANAGED_WEBSPACE:fir10, {
"password" : null,
"userid" : 1000000
"userid" : {lastUnixUserId}
})
""".trim());
"""
.replace("{lastUnixUserId}", expectedUnixUserId.toString())
.trim());
}
@Test
@ -777,4 +782,11 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
}).returnedValue();
}
private Integer nextUnixUserId() {
final Object result = em.createNativeQuery("SELECT nextval('hs_hosting.asset_unixuser_system_id_seq')", Integer.class)
.getSingleResult();
return (Integer) result + 1;
}
}

View File

@ -0,0 +1,259 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedAppEvent;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEventEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapperFake;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import java.util.UUID;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER;
import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry;
import static org.assertj.core.api.Assertions.assertThat;
// Tests the DomainSetupHostingAssetFactory through a HsBookingItemCreatedListener instance.
@ExtendWith(MockitoExtension.class)
class DomainSetupHostingAssetFactoryUnitTest {
private final HsHostingAssetRealEntity managedWebspaceHostingAsset = HsHostingAssetRealEntity.builder()
.uuid(UUID.randomUUID())
.type(MANAGED_WEBSPACE)
.identifier("one00")
.build();
private final HsHostingAssetRealEntity unixUserHostingAsset = HsHostingAssetRealEntity.builder()
.uuid(UUID.randomUUID())
.type(UNIX_USER)
.identifier("one00-web")
.parentAsset(managedWebspaceHostingAsset)
.build();
private final HsHostingAssetRealEntity anotherManagedWebspaceHostingAsset = HsHostingAssetRealEntity.builder()
.uuid(UUID.randomUUID())
.type(MANAGED_WEBSPACE)
.identifier("two00")
.build();
private EntityManagerWrapperFake emwFake = new EntityManagerWrapperFake();
@Spy
private EntityManagerWrapper emw = emwFake;
@Spy
private ObjectMapper jsonMapper = new JsonObjectMapperConfiguration().customObjectMapper().build();
@Spy
private StandardMapper standardMapper = new StandardMapper(emw);
@InjectMocks
private HsBookingItemCreatedListener listener;
@BeforeEach
void initMocks() {
emwFake.persist(managedWebspaceHostingAsset);
emwFake.persist(unixUserHostingAsset);
}
@Test
void doesNotPersistEventEntityWithoutValidationErrors() {
// given
final var givenBookingItem = createBookingItemFromResources(
entry("domainName", "example.org"),
entry("verificationCode", "just-a-fake-verification-code")
);
final var givenAssetJson = """
{
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserHostingAssetUuid}"
},
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
"""
.replace("{unixUserHostingAssetUuid}", unixUserHostingAsset.getUuid().toString());
Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertThat(emwFake.stream(BookingItemCreatedEventEntity.class))
.as("the event should not have been persisted, but got persisted")
.isEmpty();
}
@Test
void persistsEventEntityIfDomainSetupVerificationFails() {
// given
final var givenBookingItem = createBookingItemFromResources(
entry("domainName", "example.org")
);
final var givenAssetJson = """
{
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserHostingAssetUuid}"
},
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
"""
.replace("{unixUserHostingAssetUuid}", unixUserHostingAsset.getUuid().toString());
Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertEventStatus(givenBookingItem, givenAssetJson,
"[[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=null' found for domain name 'example.org' (nor in its super-domain)]");
}
@Test
void persistsEventEntityIfDomainDnsSetupIsSupplied() {
// given
final var givenBookingItem = createBookingItemFromResources(
entry("domainName", "example.org"),
entry("verificationCode", "just-a-fake-verification-code")
);
final var givenAssetJson = """
{
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserHostingAssetUuid}"
},
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
"""
.replace("{unixUserHostingAssetUuid}", unixUserHostingAsset.getUuid().toString())
.replace("{managedWebspaceHostingAssetUuid}", managedWebspaceHostingAsset.getUuid().toString());
Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertEventStatus(givenBookingItem, givenAssetJson,
"domain DNS setup not allowed for legacy compatibility");
}
@Test
void persistsEventEntityIfSuppliedDomainUnixUserAndSmtpSetupWebspaceDontMatch() {
// given
final var givenBookingItem = createBookingItemFromResources(
entry("domainName", "example.org"),
entry("verificationCode", "just-a-fake-verification-code")
);
final var givenAssetJson = """
{
"identifier": "example.org", // also as default for all subAssets
"subHostingAssets": [
{
"type": "DOMAIN_HTTP_SETUP",
"assignedToAssetUuid": "{unixUserHostingAssetUuid}"
},
{
"type": "DOMAIN_DNS_SETUP"
},
{
"type": "DOMAIN_MBOX_SETUP"
},
{
"type": "DOMAIN_SMTP_SETUP"
}
]
}
"""
.replace("{unixUserHostingAssetUuid}", unixUserHostingAsset.getUuid().toString())
.replace("{managedWebspaceHostingAssetUuid}", anotherManagedWebspaceHostingAsset.getUuid().toString());
Dns.fakeResultForDomain("example.org",
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=just-a-fake-verification-code"));
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertEventStatus(givenBookingItem, givenAssetJson,
"domain DNS setup not allowed for legacy compatibility");
}
@SafeVarargs
private static HsBookingItemRealEntity createBookingItemFromResources(final Map.Entry<String, String>... givenResources) {
return HsBookingItemRealEntity.builder()
.type(HsBookingItemType.DOMAIN_SETUP)
.resources(Map.ofEntries(givenResources))
.build();
}
private void assertEventStatus(
final HsBookingItemRealEntity givenBookingItem,
final String givenAssetJson,
final String expectedErrorMessage) {
emwFake.stream(BookingItemCreatedEventEntity.class)
.reduce(Reducer::toSingleElement)
.map(eventEntity -> {
assertThat(eventEntity.getBookingItem()).isSameAs(givenBookingItem);
assertThat(eventEntity.getAssetJson()).isEqualTo(givenAssetJson);
assertThat(eventEntity.getStatusMessage()).isEqualTo(expectedErrorMessage);
return true;
});
}
}

View File

@ -0,0 +1,98 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedAppEvent;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEventEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapperFake;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class HsBookingItemCreatedListenerUnitTest {
final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder()
.debitorNumber(12345)
.defaultPrefix("xyz")
.build();
private EntityManagerWrapperFake emwFake = new EntityManagerWrapperFake();
@Spy
private EntityManagerWrapper emw = emwFake;
@Spy
private ObjectMapper jsonMapper = new JsonObjectMapperConfiguration().customObjectMapper().build();
@Spy
private StandardMapper standardMapper = new StandardMapper(emw);
@InjectMocks
private HsBookingItemCreatedListener listener;
@ParameterizedTest
@MethodSource("bookingItemTypesWithoutAutomaticAssetCreation")
void persistsEventEntityIfBookingItemTypeDoesNotSupportAutomaticHostingAssetCreation(final HsBookingItemType bookingItemType) {
// given
final var givenBookingItem = createBookingItemFromResources(bookingItemType);
final var givenAssetJson = """
{
// anything should be rejected
}
""";
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertEventStatus(givenBookingItem, givenAssetJson,
"waiting for manual setup of hosting asset for booking item of type " + bookingItemType);
}
static List<HsBookingItemType> bookingItemTypesWithoutAutomaticAssetCreation() {
return Arrays.stream(HsBookingItemType.values())
.filter(v -> v != MANAGED_WEBSPACE && v != DOMAIN_SETUP)
.toList();
}
private static HsBookingItemRealEntity createBookingItemFromResources(
final HsBookingItemType bookingItemType
) {
return HsBookingItemRealEntity.builder()
.type(bookingItemType)
.build();
}
private void assertEventStatus(
final HsBookingItemRealEntity givenBookingItem,
final String givenAssetJson,
final String expectedErrorMessage) {
emwFake.stream(BookingItemCreatedEventEntity.class)
.reduce(Reducer::toSingleElement)
.map(eventEntity -> {
assertThat(eventEntity.getBookingItem()).isSameAs(givenBookingItem);
assertThat(eventEntity.getAssetJson()).isEqualTo(givenAssetJson);
assertThat(eventEntity.getStatusMessage()).isEqualTo(expectedErrorMessage);
return true;
});
}
}

View File

@ -0,0 +1,137 @@
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedAppEvent;
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedEventEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContact;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapperFake;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import java.util.UUID;
import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry;
import static org.assertj.core.api.Assertions.assertThat;
// Tests the DomainSetupHostingAssetFactory through a HsBookingItemCreatedListener instance.
@ExtendWith(MockitoExtension.class)
class ManagedWebspaceHostingAssetFactoryUnitTest {
final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder()
.debitorNumber(12345)
.defaultPrefix("xyz")
.build();
final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder()
.debitor(debitor)
.caption("Test-Project")
.build();
final HsOfficeContact alarmContact = HsOfficeContactRealEntity.builder()
.uuid(UUID.randomUUID())
.caption("Alarm Contact xyz")
.build();
private EntityManagerWrapperFake emwFake = new EntityManagerWrapperFake();
@Spy
private EntityManagerWrapper emw = emwFake;
@Spy
private ObjectMapper jsonMapper = new JsonObjectMapperConfiguration().customObjectMapper().build();
@Spy
private StandardMapper standardMapper = new StandardMapper(emw);
@InjectMocks
private HsBookingItemCreatedListener listener;
@BeforeEach
void initMocks() {
emwFake.persist(alarmContact);
}
@Test
void doesNotPersistAnyEntityWithoutHostingAssetWithoutValidationErrors() {
// given
final var givenBookingItem = HsBookingItemRealEntity.builder()
.type(HsBookingItemType.MANAGED_WEBSPACE)
.project(project)
.caption("Test Managed-Webspace")
.resources(Map.ofEntries(
Map.entry("RAM", 25),
Map.entry("Traffic", 250)
))
.build();
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, null)
);
// then
assertThat(emwFake.stream(BookingItemCreatedEventEntity.class).findAny().isEmpty())
.as("the event should not have been persisted, but got persisted").isTrue();
assertThat(emwFake.stream(HsHostingAssetRealEntity.class).findAny().isEmpty())
.as("the hosting asset should not have been persisted, but got persisted").isTrue();
}
@Test
void persistsEventEntityIfDomainSetupVerificationFails() {
// given
final var givenBookingItem = createBookingItemFromResources(
entry("domainName", "example.org")
);
final var givenAssetJson = """
{
"identifier": "xyz00"
}
""";
Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode
// when
listener.onApplicationEvent(
new BookingItemCreatedAppEvent(this, givenBookingItem, givenAssetJson)
);
// then
assertEventStatus(givenBookingItem, givenAssetJson,
"requires MANAGED_WEBSPACE hosting asset, but got null");
}
@SafeVarargs
private static HsBookingItemRealEntity createBookingItemFromResources(final Map.Entry<String, String>... givenResources) {
return HsBookingItemRealEntity.builder()
.type(HsBookingItemType.MANAGED_WEBSPACE)
.resources(Map.ofEntries(givenResources))
.build();
}
private void assertEventStatus(
final HsBookingItemRealEntity givenBookingItem,
final String givenAssetJson,
final String expectedErrorMessage) {
emwFake.stream(BookingItemCreatedEventEntity.class)
.reduce(Reducer::toSingleElement)
.map(eventEntity -> {
assertThat(eventEntity.getBookingItem()).isSameAs(givenBookingItem);
assertThat(eventEntity.getAssetJson()).isEqualTo(givenAssetJson);
assertThat(eventEntity.getStatusMessage()).isEqualTo(expectedErrorMessage);
return true;
});
}
}

View File

@ -42,8 +42,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
.project(project)
.type(HsBookingItemType.DOMAIN_SETUP)
.resources(new HashMap<>(ofEntries(
entry("domainName", domainName),
entry("targetUnixUser", "xyz00")
entry("domainName", domainName)
))));
HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP).prepareProperties(null, bookingItem);
return HsHostingAssetRbacEntity.builder()

View File

@ -6,7 +6,7 @@ import com.opencsv.CSVReaderBuilder;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

View File

@ -1680,18 +1680,13 @@ public class ImportHostingAssets extends BaseOfficeDataImport {
final var relatedProject = domainSetup.getSubHostingAssets().stream()
.map(ha -> ha.getAssignedToAsset() != null ? ha.getAssignedToAsset().getRelatedProject() : null)
.findAny().orElseThrow();
final var targetUnixUser = domainSetup.getSubHostingAssets().stream()
.filter(subAsset -> subAsset.getType() == DOMAIN_HTTP_SETUP)
.map(domainHttpSetup -> domainHttpSetup.getAssignedToAsset().getIdentifier())
.findAny().orElse(null);
final var bookingItem = HsBookingItemRealEntity.builder()
.type(HsBookingItemType.DOMAIN_SETUP)
.caption("BI " + domainSetup.getIdentifier())
.project((HsBookingProjectRealEntity) relatedProject)
//.validity(toPostgresDateRange(created, cancelled))
.resources(Map.ofEntries(
entry("domainName", domainSetup.getIdentifier()),
entry("targetUnixUser", targetUnixUser)
entry("domainName", domainSetup.getIdentifier())
))
.build();
domainSetup.setBookingItem(bookingItem);

View File

@ -4,12 +4,11 @@ import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.junit.jupiter.api.BeforeEach;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
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.mock.mockito.MockBean;
@ -18,15 +17,10 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.SynchronizationType;
import java.util.Map;
import java.util.UUID;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
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;
@ -47,19 +41,8 @@ public class HsOfficeMembershipControllerRestTest {
@MockBean
HsOfficeMembershipRepository membershipRepo;
@Mock
EntityManager em;
@MockBean
EntityManagerFactory emf;
@BeforeEach
void init() {
when(emf.createEntityManager()).thenReturn(em);
when(emf.createEntityManager(any(Map.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em);
}
EntityManagerWrapper em;
@Nested
class AddMembership {

View File

@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipStatusResource;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance;
@ -12,7 +13,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.UUID;
import java.util.stream.Stream;
@ -38,9 +38,9 @@ class HsOfficeMembershipEntityPatcherUnitTest extends PatchUnitTestBase<
private static final Boolean PATCHED_MEMBERSHIP_FEE_BILLABLE = false;
@Mock
private EntityManager em;
private EntityManagerWrapper em;
private StandardMapper mapper = new StandardMapper();
private StandardMapper mapper = new StandardMapper(em);
@BeforeEach
void initMocks() {

View File

@ -6,6 +6,7 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -18,7 +19,6 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.SynchronizationType;
@ -57,7 +57,7 @@ class HsOfficePartnerControllerRestTest {
HsOfficeRelationRealRepository relationRepo;
@MockBean
EntityManager em;
EntityManagerWrapper em;
@MockBean
EntityManagerFactory emf;

View File

@ -0,0 +1,94 @@
package net.hostsharing.hsadminng.persistence;
import lombok.SneakyThrows;
import jakarta.persistence.Id;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
public class EntityManagerWrapperFake extends EntityManagerWrapper {
private Map<Class<?>, Map<Object, Object>> entityClasses = new HashMap<>();
@Override
public boolean contains(final Object entity) {
final var id = getEntityId(entity);
return find(entity.getClass(), id) != null;
}
@Override
public <T> T getReference(final Class<T> entityClass, final Object primaryKey) {
return find(entityClass, primaryKey);
}
@Override
public <T> T find(final Class<T> entityClass, final Object primaryKey) {
if (entityClasses.containsKey(entityClass)) {
final var entities = entityClasses.get(entityClass);
//noinspection unchecked
return entities.keySet().stream()
.filter(key -> key.equals(primaryKey))
.map(key -> (T) entities.get(key))
.findAny()
.orElse(null);
}
return null;
}
@Override
public void persist(final Object entity) {
if (!entityClasses.containsKey(entity.getClass())) {
entityClasses.put(entity.getClass(), new HashMap<>());
}
final var id = getEntityId(entity).orElseGet(() -> setEntityId(entity, UUID.randomUUID()));
entityClasses.get(entity.getClass()).put(id, entity);
}
@Override
public void flush() {
}
public Stream<Object> stream() {
return entityClasses.values().stream().flatMap(entitiesPerClass -> entitiesPerClass.values().stream());
}
public <T> Stream<T> stream(final Class<T> entityClass) {
if (entityClasses.containsKey(entityClass)) {
//noinspection unchecked
return (Stream<T>) entityClasses.get(entityClass).values().stream();
}
return Stream.empty();
}
@SneakyThrows
private static Optional<Object> getEntityId(final Object entity) {
for (Class<?> currentClass = entity.getClass(); currentClass != null; currentClass = currentClass.getSuperclass()) {
for (Field field : currentClass.getDeclaredFields()) {
if (field.isAnnotationPresent(Id.class)) {
field.setAccessible(true);
return Optional.ofNullable(field.get(entity));
}
}
}
throw new IllegalArgumentException("No @Id field found in entity class: " + entity.getClass().getName());
}
@SneakyThrows
private static Object setEntityId(final Object entity, final Object id) {
for (Class<?> currentClass = entity.getClass(); currentClass != null; currentClass = currentClass.getSuperclass()) {
for (Field field : currentClass.getDeclaredFields()) {
if (field.isAnnotationPresent(Id.class)) {
field.setAccessible(true);
field.set(entity, id);
return id;
}
}
}
throw new IllegalArgumentException("No @Id field found in entity class: " + entity.getClass().getName());
}
}

View File

@ -1,6 +1,5 @@
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;
@ -53,9 +52,9 @@ class EntityManagerWrapperUnitTest {
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 == String[].class) return new String[]{"dummy"};
if (type == Class.class) return String.class;
if (type == Class[].class) return Array.of(String.class);
if (type == Class[].class) return new Class[0];
return mock(type);
}
}

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.rbac.context;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.mapper.Array;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -17,7 +18,7 @@ import jakarta.servlet.http.HttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ComponentScan(basePackageClasses = { Context.class, JpaAttempt.class, StandardMapper.class })
@ComponentScan(basePackageClasses = { Context.class, JpaAttempt.class, EntityManagerWrapper.class, StandardMapper.class })
@DirtiesContext
class ContextIntegrationTests {

View File

@ -2,10 +2,10 @@ package net.hostsharing.hsadminng.rbac.role;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.mock.mockito.MockBean;
@ -15,7 +15,6 @@ import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.SynchronizationType;
import java.util.Map;
@ -43,8 +42,8 @@ class RbacRoleControllerRestTest {
@MockBean
RbacRoleRepository rbacRoleRepository;
@Mock
EntityManager em;
@MockBean
EntityManagerWrapper em;
@MockBean
EntityManagerFactory emf;

View File

@ -2,10 +2,9 @@ package net.hostsharing.hsadminng.rbac.subject;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.junit.jupiter.api.BeforeEach;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.Test;
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.mock.mockito.MockBean;
@ -15,18 +14,12 @@ import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.SynchronizationType;
import java.util.Map;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
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;
@ -44,19 +37,9 @@ class RbacSubjectControllerRestTest {
@MockBean
RbacSubjectRepository rbacSubjectRepository;
@Mock
EntityManager em;
@MockBean
EntityManagerFactory emf;
EntityManagerWrapper em;
@BeforeEach
void init() {
when(emf.createEntityManager()).thenReturn(em);
when(emf.createEntityManager(any(Map.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em);
}
@Test
void createSubjectUsesGivenUuid() throws Exception {

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.rbac.test;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantEntity;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantRepository;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantsDiagramService;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.rbac.test;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import java.util.List;

View File

@ -3,13 +3,13 @@ package net.hostsharing.hsadminng.rbac.test;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayAs;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import jakarta.persistence.EntityManager;
import jakarta.persistence.ManyToOne;
import jakarta.validation.ValidationException;
import java.util.List;
@ -24,7 +24,7 @@ import static org.mockito.Mockito.when;
class MapperUnitTest {
@Mock
EntityManager em;
EntityManagerWrapper em;
@InjectMocks
StandardMapper mapper;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.rbac.test;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;