From 860df4c69fd7710112379ce7ac3a99a690440484 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 12 Sep 2024 10:52:44 +0200 Subject: [PATCH] user-definable verificationCode and more business-level-validation-tests (#100) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/100 Reviewed-by: Marc Sandlus --- .../HsDomainSetupBookingItemValidator.java | 5 +- .../HsDomainSetupHostingAssetValidator.java | 88 +++++++---- .../hs/validation/ValidatableProperty.java | 2 +- ...mainSetupBookingItemValidatorUnitTest.java | 24 ++- ...ainSetupHostingAssetValidatorUnitTest.java | 147 +++++++++++++++--- 5 files changed, 206 insertions(+), 60 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java index 3d62b765..c9fd731a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java @@ -25,8 +25,9 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator { .notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name") .required(), stringProperty(VERIFICATION_CODE_PROPERTY_NAME) - .readOnly().initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode) - + .minLength(12) + .maxLength(64) + .initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode) ); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java index 20fcbf69..a4ad06a4 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java @@ -2,9 +2,9 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.function.Supplier; import java.util.regex.Pattern; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; @@ -13,7 +13,6 @@ import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttp class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { - public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(? getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + final var dnsResult = new Dns(assetEntity.getIdentifier()).fetchRecordsOfType("TXT"); switch (dnsResult.status()) { - case Dns.Status.SUCCESS: { - final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get(); - final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue) - .or(() -> superDomain(domainName) - .flatMap(superDomainName -> findTxtRecord( - new Dns(superDomainName).fetchRecordsOfType("TXT"), - expectedTxtRecordValue)) - ); - if (verificationFound.isEmpty()) { - violations.add( - "[DNS] no TXT record '" + expectedTxtRecordValue + - "' found for domain name '" + domainName + "' (nor in its super-domain)"); - } + case Dns.Status.SUCCESS: + violations.addAll(handleDomainNameFound(assetEntity, dnsResult)); break; - } - case Dns.Status.NAME_NOT_FOUND: { - if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) { - final var superDomain = superDomain(domainName); - final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get(); - final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord( - new Dns(superDomainName).fetchRecordsOfType("TXT"), - expectedTxtRecordValue)); - if (verificationFoundInSuperDomain.isEmpty()) { - violations.add( - "[DNS] no TXT record '" + expectedTxtRecordValue + - "' found for domain name '" + superDomain.orElseThrow() + "'"); - } - } - // otherwise no DNS verification to be able to setup DNS for domains to register + case Dns.Status.NAME_NOT_FOUND: + violations.addAll(handleDomainNameNotFoundError(assetEntity, dnsResult)); break; - } case Dns.Status.INVALID_NAME: + // should not happen because we validate the domain name at booking item level violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'"); break; @@ -83,6 +56,10 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { return violations; } + private static String verificationCode(final HsHostingAsset assetEntity) { + return assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + } + @Override protected Pattern identifierPattern(final HsHostingAsset assetEntity) { if (assetEntity.getBookingItem() != null) { @@ -94,6 +71,49 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE); } + private static List handleDomainNameFound(final HsHostingAsset assetEntity, final Dns.Result dnsResult) { + final var violations = new ArrayList(); + final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity); + final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue) + .or(() -> superDomain(assetEntity.getIdentifier()) + .flatMap(superDomainName -> findTxtRecord( + new Dns(superDomainName).fetchRecordsOfType("TXT"), + expectedTxtRecordValue)) + ); + if (verificationFound.isEmpty()) { + violations.add( + "[DNS] no TXT record '" + expectedTxtRecordValue + + "' found for domain name '" + assetEntity.getIdentifier() + "' (nor in its super-domain)"); + } + return violations; + } + + private static List handleDomainNameNotFoundError(final HsHostingAsset assetEntity, final Dns.Result dnsResult) { + final var violations = new ArrayList(); + if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) { + final var superDomain = superDomain(assetEntity.getIdentifier()); + final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity); + final var verificationFoundInSuperDomain = superDomain.map(superDomainName -> + { + final Dns.Result superDomainDnsResult = new Dns(superDomainName).fetchRecordsOfType("TXT"); + if (superDomainDnsResult.status() != Dns.Status.SUCCESS) { + violations.add("[DNS] lookup failed for domain name '" + superDomainName + "': " + dnsResult.exception()); + } + return superDomainDnsResult; + } + ) + .flatMap(records -> findTxtRecord(records, expectedTxtRecordValue)); + if (verificationFoundInSuperDomain.isEmpty()) { + violations.add( + "[DNS] no TXT record '" + expectedTxtRecordValue + + "' found for domain name '" + superDomain.orElseThrow() + "'"); + } + } else { + // otherwise no DNS verification to be able to setup DNS for domains to register + } + return violations; + } + private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) { return !Dns.isRegistrableDomain(assetEntity.getIdentifier()) && assetEntity.getParentAsset() == null; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java index d0966a5e..fb51e7fe 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java @@ -266,7 +266,7 @@ public abstract class ValidatableProperty

, T private boolean isSpecPotentiallyComplete() { return required == null && requiresAtLeastOneOf == null && requiresAtMaxOneOf == null && !readOnly && !writeOnly - && defaultValue == null; + && defaultValue == null && computedBy == null; } @SuppressWarnings("unchecked") diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java index 9fbdac45..60356401 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java @@ -28,7 +28,7 @@ class HsDomainSetupBookingItemValidatorUnitTest { private EntityManager em; @Test - void acceptsRegisterableDomain() { + void acceptsRegisterableDomainWithGeneratedVerificationCode() { // given final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() .type(DOMAIN_SETUP) @@ -46,6 +46,26 @@ class HsDomainSetupBookingItemValidatorUnitTest { assertThat(result).isEmpty(); } + @Test + void acceptsRegisterableDomainWithExplicitVerificationCode() { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", "example.org"), + entry("verificationCode", "1234-5678-9100") + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + @Test void acceptsMaximumDomainNameLength() { final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() @@ -150,6 +170,6 @@ class HsDomainSetupBookingItemValidatorUnitTest { // then assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( "{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(? validEntityBuilder( final String domainName, final Function, HsBookingItemRealEntity> buildBookingItem) { - final HsBookingItemRealEntity bookingItem = buildBookingItem.apply( + final var project = HsBookingProjectRealEntity.builder().build(); + final var bookingItem = buildBookingItem.apply( HsBookingItemRealEntity.builder() + .project(project) .type(HsBookingItemType.DOMAIN_SETUP) .resources(new HashMap<>(ofEntries( entry("domainName", domainName) @@ -90,7 +93,8 @@ class HsDomainSetupHostingAssetValidatorUnitTest { // then assertThat(result).contains( - "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(? bib.type(HsBookingItemType.CLOUD_SERVER).build()) + final var domainSetupHostingAssetEntity = validEntityBuilder( + "example.org", + bib -> bib.type(HsBookingItemType.CLOUD_SERVER).build()) .parentAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_SERVER).build()) .build(); @@ -161,11 +166,12 @@ class HsDomainSetupHostingAssetValidatorUnitTest { @Test void rejectsDomainNameNotMatchingBookingItemDomainName() { // given - final var domainSetupHostingAssetEntity = validEntityBuilder("not-matching-booking-item-domain-name.org", - bib -> bib.resources(new HashMap<>(ofEntries( - entry("domainName", "example.org") - ))).build() - ).build(); + final var domainSetupHostingAssetEntity = validEntityBuilder( + "not-matching-booking-item-domain-name.org", + bib -> bib.resources(new HashMap<>(ofEntries( + entry("domainName", "example.org") + ))).build() + ).build(); final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); // when @@ -262,6 +268,24 @@ class HsDomainSetupHostingAssetValidatorUnitTest { //===================================================================================================================== + @Test + void rejectsSetupOfRegistrar1stLevelDomain() { + domainSetupFor("org").notRegistered() + .isRejectedWithCauseForbidden("registrar-level domain name"); + } + + @Test + void rejectsSetupOfRegistrar2ndLevelDomain() { + domainSetupFor("co.uk").notRegistered() + .isRejectedWithCauseForbidden("registrar-level domain name"); + } + + @Test + void rejectsSetupOfHostsharingDomain() { + domainSetupFor("hostsharing.net").notRegistered() + .isRejectedWithCauseForbidden("Hostsharing domain name"); + } + @Test void allowSetupOfAvailableRegistrableDomain() { domainSetupFor("example.com").notRegistered() @@ -298,6 +322,18 @@ class HsDomainSetupHostingAssetValidatorUnitTest { .isAccepted(); } + @Test + void allowSetupOfUnregisteredSubdomainIfSuperDomainParentAssetIsSpecified() { + domainSetupFor("sub.example.org").notRegistered().withParentAsset("example.org") + .isAccepted(); + } + + @Test + void rejectSetupOfUnregisteredSubdomainIfWrongParentAssetIsSpecified() { + domainSetupFor("sub.example.org").notRegistered().withParentAsset("example.net") + .isRejectedDueToInvalidIdentifier(); + } + @Test void allowSetupOfUnregisteredSubdomainWithValidDnsVerificationInSuperDomain() { domainSetupFor("sub.example.org").notRegistered().withVerificationIn("example.org") @@ -322,6 +358,12 @@ class HsDomainSetupHostingAssetValidatorUnitTest { .isRejectedWithCauseMissingVerificationIn("example.org"); } + @Test + void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() { + domainSetupFor("sub.sub.example.org").notRegistered() + .isRejectedWithCauseDomainNameNotFound("sub.example.org"); + } + @Test void acceptSetupOfUnregisteredSubdomainWithParentAssetEvenWithoutDnsVerificationInSuperDomain() { domainSetupWithParentAssetFor("sub.example.org").notRegistered() @@ -341,6 +383,20 @@ class HsDomainSetupHostingAssetValidatorUnitTest { .isRejectedWithCauseMissingVerificationIn("sub.example.org"); } + @Test + void allowSetupOfRegistrableDomainWithUserDefinedVerificationCode() { + domainSetupFor("example.edu.it").notRegistered().withUserDefinedVerificationCode("ABCD-EFGH-IJKL-MNOP") + .withVerificationIn("example.edu.it") + .isAccepted(); + } + + @Test + void rejectSetupOfRegistrableDomainWithInvalidUserDefinedVerificationCode() { + domainSetupFor("example.edu.it").notRegistered().withUserDefinedVerificationCode("ABCD-EFGH-IJKL-MNOP") + .withVerificationIn("example.edu.it", "SOME-OTHER-CODE") + .isRejectedWithCauseMissingVerificationIn("example.edu.it"); + } + //==================================================================================================================== private static HsHostingAssetRealEntity createValidParentDomainSetupAsset(final String parentDomainName) { @@ -360,11 +416,9 @@ class HsDomainSetupHostingAssetValidatorUnitTest { class DomainSetupBuilder { private final HsHostingAssetRbacEntity domainAsset; - private final String expectedHash; public DomainSetupBuilder(final String domainName) { domainAsset = validEntityBuilder(domainName).build(); - expectedHash = domainAsset.getBookingItem().getDirectValue("verificationCode", String.class); } public DomainSetupBuilder(final HsHostingAssetRealEntity parentAsset, final String domainName) { @@ -372,7 +426,6 @@ class HsDomainSetupHostingAssetValidatorUnitTest { .bookingItem(null) .parentAsset(parentAsset) .build(); - expectedHash = null; } DomainSetupBuilder notRegistered() { @@ -399,30 +452,79 @@ class HsDomainSetupHostingAssetValidatorUnitTest { return this; } - DomainSetupBuilder withVerificationIn(final String domainName) { - assertThat(expectedHash).as("no expectedHash available").isNotNull(); + DomainSetupBuilder withUserDefinedVerificationCode(final String verificationCode) { + domainAsset.getBookingItem().getResources().put("verificationCode", verificationCode); + return this; + } + + DomainSetupBuilder withVerificationIn(final String domainName, final String verificationCode) { + assertThat(verificationCode).as("explicit verificationCode must not be null").isNotNull(); Dns.fakeResultForDomain( domainName, - Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + verificationCode)); + return this; + } + + DomainSetupBuilder withVerificationIn(final String domainName) { + assertThat(expectedVerificationCode()).as("no expectedHash available").isNotNull(); + Dns.fakeResultForDomain( + domainName, + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedVerificationCode())); return this; } void isRejectedWithCauseMissingVerificationIn(final String domainName) { - assertThat(expectedHash).as("no expectedHash available").isNotNull(); + assertThat(expectedVerificationCode()).as("no expectedHash available").isNotNull(); assertThat(validate()).containsAnyOf( - "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash + "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedVerificationCode() + "' found for domain name '" + domainName + "' (nor in its super-domain)", - "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash + "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedVerificationCode() + "' found for domain name '" + domainName + "'"); } + void isRejectedWithCauseForbidden(final String type) { + assertThat(validate()).contains( + "'D-???????:null:null.resources.domainName' = '" + domainAsset.getIdentifier() + "' is a forbidden " + type + ); + } + + void isRejectedDueToInvalidIdentifier() { + assertThat(validate()).contains( + "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(? validate() { - final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SETUP); - return validator.validateEntity(domainAsset); + if ( domainAsset.getBookingItem() != null ) { + final var biValidation = HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP) + .validateEntity(domainAsset.getBookingItem()); + if (!biValidation.isEmpty()) { + return biValidation; + } + } + + return HostingAssetEntityValidatorRegistry.forType(DOMAIN_SETUP) + .validateEntity(domainAsset); + } + + DomainSetupBuilder withParentAsset(final String parentAssetDomainName) { + domainAsset.setBookingItem(null); + domainAsset.setParentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(parentAssetDomainName).build()); + return this; } } @@ -432,7 +534,10 @@ class HsDomainSetupHostingAssetValidatorUnitTest { private DomainSetupBuilder domainSetupWithParentAssetFor(final String domainName) { return new DomainSetupBuilder( - HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(Dns.superDomain(domainName).orElseThrow()).build(), + HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .identifier(Dns.superDomain(domainName).orElseThrow()) + .build(), domainName); } }