application-event for booking-item-created with domain-setup-example (#110)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #110
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-09-27 11:19:01 +02:00
parent 4811c0328c
commit cc2b04472f
8 changed files with 248 additions and 12 deletions

View File

@ -0,0 +1,16 @@
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

@ -12,6 +12,7 @@ import net.hostsharing.hsadminng.mapper.KeyValueMap;
import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -33,6 +34,9 @@ public class HsBookingItemController implements HsBookingItemsApi {
@Autowired @Autowired
private StrictMapper mapper; private StrictMapper mapper;
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Autowired @Autowired
private HsBookingItemRbacRepository bookingItemRepo; private HsBookingItemRbacRepository bookingItemRepo;
@ -63,7 +67,8 @@ public class HsBookingItemController implements HsBookingItemsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var entityToSave = mapper.map(body, HsBookingItemRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); final var entityToSave = mapper.map(body, HsBookingItemRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
final var mapped = new BookingItemEntitySaveProcessor(em, entityToSave) final var saveProcessor = new BookingItemEntitySaveProcessor(em, entityToSave);
final var mapped = saveProcessor
.preprocessEntity() .preprocessEntity()
.validateEntity() .validateEntity()
.prepareForSave() .prepareForSave()
@ -72,6 +77,8 @@ public class HsBookingItemController implements HsBookingItemsApi {
.mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER)) .mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER))
.revampProperties(); .revampProperties();
applicationEventPublisher.publishEvent(new BookingItemCreatedEvent(this, saveProcessor.getEntity()));
final var uri = final var uri =
MvcUriComponentsBuilder.fromController(getClass()) MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/booking/items/{id}") .path("/api/hs/booking/items/{id}")

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.hs.booking.item.validators; package net.hostsharing.hsadminng.hs.booking.item.validators;
import lombok.Getter;
import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.errors.MultiValidationException;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
@ -20,7 +21,11 @@ public class BookingItemEntitySaveProcessor {
private final HsEntityValidator<HsBookingItem> validator; private final HsEntityValidator<HsBookingItem> validator;
private String expectedStep = "preprocessEntity"; private String expectedStep = "preprocessEntity";
private final EntityManager em; private final EntityManager em;
@Getter
private HsBookingItem entity; private HsBookingItem entity;
@Getter
private HsBookingItemResource resource; private HsBookingItemResource resource;
public BookingItemEntitySaveProcessor(final EntityManager em, final HsBookingItem entity) { public BookingItemEntitySaveProcessor(final EntityManager em, final HsBookingItem entity) {

View File

@ -55,6 +55,11 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
} }
private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) { private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
final var userDefinedVerificationCode = propertiesProvider.getDirectValue(VERIFICATION_CODE_PROPERTY_NAME, String.class);
if (userDefinedVerificationCode != null) {
return userDefinedVerificationCode;
}
final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
final var secureRandom = new SecureRandom(); final var secureRandom = new SecureRandom();
final var sb = new StringBuilder(); final var sb = new StringBuilder();

View File

@ -0,0 +1,56 @@
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

@ -10,6 +10,7 @@ components:
- CLOUD_SERVER - CLOUD_SERVER
- MANAGED_SERVER - MANAGED_SERVER
- MANAGED_WEBSPACE - MANAGED_WEBSPACE
- DOMAIN_SETUP
HsBookingItem: HsBookingItem:
type: object type: object

View File

@ -4,8 +4,11 @@ import io.hypersistence.utils.hibernate.type.range.Range;
import io.restassured.RestAssured; import io.restassured.RestAssured;
import io.restassured.http.ContentType; import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; 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.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.ClassOrderer;
@ -50,7 +53,10 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
HsBookingProjectRealRepository realProjectRepo; HsBookingProjectRealRepository realProjectRepo;
@Autowired @Autowired
HsOfficeDebitorRepository debitorRepo; HsBookingDebitorRepository debitorRepo;
@Autowired
HsHostingAssetRealRepository realHostingAssetRepo;
@Autowired @Autowired
JpaAttempt jpaAttempt; JpaAttempt jpaAttempt;
@ -64,7 +70,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
// given // given
context("superuser-alex@hostsharing.net"); context("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream) .flatMap(List::stream)
.findFirst() .findFirst()
@ -132,7 +138,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
void globalAdmin_canAddBookingItem() { void globalAdmin_canAddBookingItem() {
context.define("superuser-alex@hostsharing.net"); context.define("superuser-alex@hostsharing.net");
final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() final var givenProject = debitorRepo.findByDebitorNumber(1000111).stream()
.map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid()))
.flatMap(List::stream) .flatMap(List::stream)
.findFirst() .findFirst()
@ -176,9 +182,135 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
.extract().header("Location"); // @formatter:on .extract().header("Location"); // @formatter:on
// finally, the new bookingItem can be accessed under the generated UUID // finally, the new bookingItem can be accessed under the generated UUID
final var newSubjectUuid = UUID.fromString( assertThat(fetchRealBookingItemFromURI(location)).isNotNull();
location.substring(location.lastIndexOf('/') + 1)); }
assertThat(newSubjectUuid).isNotNull();
@Test
void projectAgent_canAddBookingItemWithHostingAsset() {
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 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"
}
}
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
)
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking",
"validFrom": "{today}",
"validTo": null,
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" }
}
"""
.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(bi -> bi.getDirectValue("domainName", String.class))
.isEqualTo("example.org");
// and the related HostingAsset also got created
assertThat(realHostingAssetRepo.findByIdentifier("example.org")).isNotEmpty()
.map(HsHostingAsset::getBookingItem)
.contains(newBookingItem);
}
@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();
Dns.fakeResultForDomain("example.org", Dns.Result.fromRecords()); // without valid verificationCode
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"
}
}
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
)
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"type": "DOMAIN_SETUP",
"caption": "some new domain-setup booking",
"validFrom": "{today}",
"validTo": null,
"resources": { "domainName": "example.org", "targetUnixUser": "fir01-web" }
}
"""
.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(bi -> bi.getDirectValue("domainName", String.class))
.isEqualTo("example.org");
assertThat(newBookingItem)
.extracting(bi -> bi.getDirectValue("status", String.class))
.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
assertThat(realHostingAssetRepo.findByIdentifier("example.org")).isEmpty();
} }
} }
@ -405,4 +537,13 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
private Map.Entry<String, Object> resource(final String key, final Object value) { private Map.Entry<String, Object> resource(final String key, final Object value) {
return entry(key, value); return entry(key, value);
} }
private HsBookingItemRealEntity fetchRealBookingItemFromURI(final String location) {
final var newBookingItemUuid = UUID.fromString(
location.substring(location.lastIndexOf('/') + 1));
assertThat(newBookingItemUuid).isNotNull();
final var optional = realBookingItemRepo.findByUuid(newBookingItemUuid);
assertThat(optional).isNotEmpty();
return optional.get();
}
} }

View File

@ -14,6 +14,7 @@ import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP;
import static org.apache.commons.lang3.StringUtils.right; import static org.apache.commons.lang3.StringUtils.right;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
class HsDomainSetupBookingItemValidatorUnitTest { class HsDomainSetupBookingItemValidatorUnitTest {
@ -41,10 +42,12 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.build(); .build();
// when // when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); final var thrown = catchThrowable(() -> {
new BookingItemEntitySaveProcessor(em, domainSetupBookingItemEntity).preprocessEntity().validateEntity();
});
// then // then
assertThat(result).isEmpty(); assertThat(thrown).isNull();
} }
@Test @Test
@ -62,10 +65,12 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.build(); .build();
// when // when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); final var thrown = catchThrowable(() -> {
new BookingItemEntitySaveProcessor(em, domainSetupBookingItemEntity).preprocessEntity().validateEntity();
});
// then // then
assertThat(result).isEmpty(); assertThat(thrown).isNull();
} }
@Test @Test