From a7d586f0f784f2d3ed143fe94409ac9e5cfdeb17 Mon Sep 17 00:00:00 2001 From: Michael Hoennig <michael.hoennig@hostsharing.net> Date: Tue, 10 Sep 2024 13:15:03 +0200 Subject: [PATCH] check-domain-setup-permission (#97) Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/97 Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net> --- .../HsDomainSetupBookingItemValidator.java | 51 ++- .../hs/hosting/asset/validators/Dns.java | 134 +++++++ .../HsDomainSetupHostingAssetValidator.java | 107 +++-- .../hs/validation/StringProperty.java | 84 +++- ...mainSetupBookingItemValidatorUnitTest.java | 155 +++++++ ...sHostingAssetControllerAcceptanceTest.java | 10 + .../hosting/asset/validators/DnsUnitTest.java | 29 ++ ...ttpSetupHostingAssetValidatorUnitTest.java | 8 +- ...ainSetupHostingAssetValidatorUnitTest.java | 378 ++++++++++++++++-- ...lAddressHostingAssetValidatorUnitTest.java | 4 +- ...UnixUserHostingAssetValidatorUnitTest.java | 2 +- .../hs/migration/BaseOfficeDataImport.java | 2 +- .../hs/migration/ImportHostingAssets.java | 4 +- 13 files changed, 884 insertions(+), 84 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java 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 a48ed4a5..3d62b765 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 @@ -1,10 +1,59 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; + +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator { + 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 VERIFICATION_CODE_PROPERTY_NAME = "verificationCode"; + HsDomainSetupBookingItemValidator() { super( - // no properties yet. maybe later, the setup code goes here? + 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(), + stringProperty(VERIFICATION_CODE_PROPERTY_NAME) + .readOnly().initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode) + ); } + + @Override + public List<String> validateEntity(final HsBookingItem bookingItem) { + final var violations = new ArrayList<String>(); + final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); + if (!bookingItem.isLoaded() && + domainName.matches("hostsharing.(com|net|org|coop|de)")) { + violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName + + "' is a forbidden Hostsharing domain name"); + } + violations.addAll(super.validateEntity(bookingItem)); + return violations; + } + + private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) { + final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + final var secureRandom = new SecureRandom(); + final var sb = new StringBuilder(); + for (int i = 0; i < 40; ++i) { + if ( i > 0 && i % 4 == 0 ) { + sb.append("-"); + } + sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length()))); + } + return sb.toString(); + } + } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java new file mode 100644 index 00000000..037b95c0 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java @@ -0,0 +1,134 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.collections4.EnumerationUtils; + +import javax.naming.InvalidNameException; +import javax.naming.NameNotFoundException; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.ServiceUnavailableException; +import javax.naming.directory.Attribute; +import javax.naming.directory.InitialDirContext; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; + +public class Dns { + + public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of( + "[^.]+", // top-level-domains + "(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" + ); + public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS) + .map(Pattern::compile) + .toArray(Pattern[]::new); + + private final static Map<String, Result> fakeResults = new HashMap<>(); + + public static Optional<String> superDomain(final String domainName) { + final var parts = domainName.split("\\.", 2); + if (parts.length == 2) { + return Optional.of(parts[1]); + } + return Optional.empty(); + } + + public static boolean isRegistrarLevelDomain(final String domainName) { + return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN) + .anyMatch(p -> p.matcher(domainName).matches()); + } + + /** + * @param domainName a fully qualified domain name + * @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself + */ + public static boolean isRegistrableDomain(final String domainName) { + return !isRegistrarLevelDomain(domainName) && + superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false); + } + + public static void fakeResultForDomain(final String domainName, final Result fakeResult) { + fakeResults.put(domainName, fakeResult); + } + + public static void resetFakeResults() { + fakeResults.clear(); + } + + public enum Status { + SUCCESS, + NAME_NOT_FOUND, + INVALID_NAME, + SERVICE_UNAVAILABLE, + UNKNOWN_FAILURE + } + + public record Result(Status status, List<String> records, NamingException exception) { + + + public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) { + final List<String> records = recordEnumeration == null + ? emptyList() + : EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList(); + return new Result(Status.SUCCESS, records, null); + } + + public static Result fromRecords(final String... records) { + return new Result(Status.SUCCESS, stream(records).toList(), null); + } + + public static Result fromException(final NamingException exception) { + return switch (exception) { + case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc); + case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc); + case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc); + case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc); + }; + } + } + + private final String domainName; + + public Dns(final String domainName) { + this.domainName = domainName; + } + + public Result fetchRecordsOfType(final String recordType) { + if (fakeResults.containsKey(domainName)) { + return fakeResults.get(domainName); + } + + try { + final var env = new Hashtable<>(); + env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory"); + final Attribute records = new InitialDirContext(env) + .getAttributes(domainName, new String[] { recordType }) + .get(recordType); + return Result.fromRecords(records != null ? records.getAll() : null); + } catch (final NamingException exception) { + return Result.fromException(exception); + } + } + + public static void main(String[] args) { + final var result = new Dns("example.org").fetchRecordsOfType("TXT"); + System.out.println(result); + } + +} 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 8701d2fe..40530ad1 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 @@ -3,55 +3,104 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; 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; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX; class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}"; - - private final Pattern identifierPattern; + public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName"; HsDomainSetupHostingAssetValidator() { - super( DOMAIN_SETUP, + super( + DOMAIN_SETUP, AlarmContact.isOptional(), NO_EXTRA_PROPERTIES); - this.identifierPattern = Pattern.compile(FQDN_REGEX); } @Override public List<String> validateEntity(final HsHostingAsset assetEntity) { - // TODO.impl: for newly created entities, check the permission of setting up a domain - // - // reject, if the domain is any of these: - // hostsharing.com|net|org|coop, // just to be on the safe side - // [^.}+, // top-level-domain - // co.uk, org.uk, gov.uk, ac.uk, sch.uk, - // com.au, net.au, org.au, edu.au, gov.au, asn.au, id.au, - // co.jp, ne.jp, or.jp, ac.jp, go.jp, - // com.cn, net.cn, org.cn, gov.cn, edu.cn, ac.cn, - // com.br, net.br, org.br, gov.br, edu.br, mil.br, art.br, - // co.in, net.in, org.in, gen.in, firm.in, ind.in, - // com.mx, net.mx, org.mx, gob.mx, edu.mx, - // gov.it, edu.it, - // co.nz, net.nz, org.nz, govt.nz, ac.nz, school.nz, geek.nz, kiwi.nz, - // co.kr, ne.kr, or.kr, go.kr, re.kr, pe.kr - // - // allow if - // - user has Admin/Agent-role for all its sub-domains and the direct parent-Domain which are set up at at Hostsharing - // - domain has DNS zone with TXT record approval - // - parent-domain has DNS zone with TXT record approval - // - // TXT-Record check: - // new InitialDirContext().getAttributes("dns:_netblocks.google.com", new String[] { "TXT"}).get("TXT").getAll(); + final var violations = // new ArrayList<String>(); + super.validateEntity(assetEntity); + if (!violations.isEmpty()) { + return violations; + } - return super.validateEntity(assetEntity); + final var domainName = assetEntity.getIdentifier(); + final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT"); + final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + 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)"); + } + 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 + break; + } + + case Dns.Status.INVALID_NAME: + violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'"); + break; + + case Dns.Status.SERVICE_UNAVAILABLE: + case Dns.Status.UNKNOWN_FAILURE: + violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception()); + break; + } + return violations; } @Override protected Pattern identifierPattern(final HsHostingAsset assetEntity) { - return identifierPattern; + if (assetEntity.getBookingItem() != null) { + final var bookingItemDomainName = assetEntity.getBookingItem() + .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); + return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL); + } + final var parentDomainName = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE); + } + + private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) { + return !Dns.isRegistrableDomain(assetEntity.getIdentifier()) + && assetEntity.getParentAsset() == null; + } + + + private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) { + return result.records().stream() + .filter(r -> r.contains(expectedTxtRecordValue)) + .findAny(); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java index f9a27e85..6dc463d6 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java @@ -1,10 +1,12 @@ package net.hostsharing.hsadminng.hs.validation; +import lombok.AccessLevel; import lombok.Setter; import net.hostsharing.hsadminng.mapper.Array; import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,11 +17,19 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp protected static final String[] KEY_ORDER = Array.join( ValidatableProperty.KEY_ORDER_HEAD, - Array.of("matchesRegEx", "minLength", "maxLength", "provided"), + Array.of("matchesRegEx", "matchesRegExDescription", + "notMatchesRegEx", "notMatchesRegExDescription", + "minLength", "maxLength", + "provided"), ValidatableProperty.KEY_ORDER_TAIL, Array.of("undisclosed")); private String[] provided; private Pattern[] matchesRegEx; + private String matchesRegExDescription; + private Pattern[] notMatchesRegEx; + private String notMatchesRegExDescription; + @Setter(AccessLevel.PRIVATE) + private Consumer<String> describedAsConsumer; private Integer minLength; private Integer maxLength; private boolean undisclosed; @@ -56,10 +66,23 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp public P matchesRegEx(final String... regExPattern) { this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new); + this.describedAsConsumer = violationMessage -> matchesRegExDescription = violationMessage; return self(); } - /// predifined values, similar to fixed values in a combobox + public P notMatchesRegEx(final String... regExPattern) { + this.notMatchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new); + this.describedAsConsumer = violationMessage -> notMatchesRegExDescription = violationMessage; + return self(); + } + + public P describedAs(final String violationMessage) { + describedAsConsumer.accept(violationMessage); + describedAsConsumer = null; + return self(); + } + + /// predefined values, similar to fixed values in a combobox public P provided(final String... provided) { this.provided = provided; return self(); @@ -78,16 +101,10 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp @Override protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) { super.validate(result, propValue, propProvider); - if (minLength != null && propValue.length()<minLength) { - result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length()); - } - if (maxLength != null && propValue.length()>maxLength) { - result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length()); - } - if (matchesRegEx != null && - stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) { - result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + " but " + display(propValue) + " does not match" + (matchesRegEx.length>1?" any":"")); - } + validateMinLength(result, propValue); + validateMaxLength(result, propValue); + validateMatchesRegEx(result, propValue); + validateNotMatchesRegEx(result, propValue); } @Override @@ -99,4 +116,47 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp protected String simpleTypeName() { return "string"; } + + private void validateMinLength(final List<String> result, final String propValue) { + if (minLength != null && propValue.length()<minLength) { + result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length()); + } + } + + private void validateMaxLength(final List<String> result, final String propValue) { + if (maxLength != null && propValue.length()>maxLength) { + result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length()); + } + } + + private void validateMatchesRegEx(final List<String> result, final String propValue) { + if (matchesRegEx != null && + stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) { + if (matchesRegExDescription != null) { + result.add(propertyName + "' = " + display(propValue) + " " + matchesRegExDescription); + } else if (matchesRegEx.length>1) { + result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + + " but " + display(propValue) + " does not match any"); + } else { + result.add(propertyName + "' is expected to match " + Arrays.toString(matchesRegEx) + " but " + display( + propValue) + + " does not match"); + } + } + } + + private void validateNotMatchesRegEx(final List<String> result, final String propValue) { + if (notMatchesRegEx != null && + stream(notMatchesRegEx).map(p -> p.matcher(propValue)).anyMatch(Matcher::matches)) { + if (notMatchesRegExDescription != null) { + result.add(propertyName + "' = " + display(propValue) + " " + notMatchesRegExDescription); + } else if (notMatchesRegEx.length>1) { + result.add(propertyName + "' is expected not to match any of " + Arrays.toString(notMatchesRegEx) + + " but " + display(propValue) + " does match at least one"); + } else { + result.add(propertyName + "' is expected not to match " + Arrays.toString(notMatchesRegEx) + + " but " + display(propValue) + " does match"); + } + } + } } 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 new file mode 100644 index 00000000..9fbdac45 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java @@ -0,0 +1,155 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import jakarta.persistence.EntityManager; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; +import static org.apache.commons.lang3.StringUtils.right; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainSetupBookingItemValidatorUnitTest { + + public static final String TOO_LONG_DOMAIN_NAME = "asdfghijklmnopqrstuvwxyz0123456789.".repeat(8) + "example.org"; + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + private EntityManager em; + + @Test + void acceptsRegisterableDomain() { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", "example.org") + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void acceptsMaximumDomainNameLength() { + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253)) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsTooLongTotalName() { + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254)) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.domainName' length is expected to be at max 253 but length of 'dfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.example.org' is 254"); + } + + @ParameterizedTest + @ValueSource(strings = { + "de", "com", "net", "org", "actually-any-top-level-domain", + "co.uk", "org.uk", "gov.uk", "ac.uk", "sch.uk", + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", + "com.br", "net.br", "org.br", "gov.br", "edu.br", "mil.br", "art.br", + "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in", + "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx", + "gov.it", "edu.it", + "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz", + "co.kr", "ne.kr", "or.kr", "go.kr", "re.kr", "pe.kr" + }) + void rejectRegistrarLevelDomain(final String secondLevelRegistrarDomain) { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", secondLevelRegistrarDomain) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).contains( + "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" + + secondLevelRegistrarDomain + + "' is a forbidden registrar-level domain name"); + } + + @ParameterizedTest + @ValueSource(strings = { + "hostsharing.net", "hostsharing.org", "hostsharing.com", "hostsharing.coop", "hostsharing.de" + }) + void rejectHostsharingDomain(final String secondLevelRegistrarDomain) { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", secondLevelRegistrarDomain) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).containsExactly( + "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" + + secondLevelRegistrarDomain + + "' is a forbidden Hostsharing domain name"); + } + + @Test + void containsAllValidations() { + // when + final var validator = HsBookingItemEntityValidatorRegistry.forType(DOMAIN_SETUP); + + // 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=verificationCode, readOnly=true, computed=IN_INIT}"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java index 306337bb..81f3192e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java @@ -9,10 +9,12 @@ import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Order; @@ -64,6 +66,11 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup @Autowired JpaAttempt jpaAttempt; + @AfterEach + void cleanup() { + Dns.resetFakeResults(); + } + @Nested @Order(2) class ListAssets { @@ -249,6 +256,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup void globalAdmin_canAddTopLevelAsset() { context.define("superuser-alex@hostsharing.net"); + Dns.fakeResultForDomain("example.com", new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null)); final var givenProject = realProjectRepo.findByCaption("D-1000111 default project").stream() .findAny().orElseThrow(); final var bookingItem = givenSomeTemporaryBookingItem(() -> @@ -256,6 +264,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .project(givenProject) .type(HsBookingItemType.DOMAIN_SETUP) .caption("some temp domain setup booking item") + .resources(Map.ofEntries( + entry("domainName", "example.com"))) .build() ); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java new file mode 100644 index 00000000..7a60d16c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DnsUnitTest { + + @Test + void isRegistrarLevelDomain() { + assertThat(Dns.isRegistrarLevelDomain("de")).isTrue(); + assertThat(Dns.isRegistrarLevelDomain("example.de")).isFalse(); + + assertThat(Dns.isRegistrarLevelDomain("co.uk")).isTrue(); + assertThat(Dns.isRegistrarLevelDomain("example.co.uk")).isFalse(); + assertThat(Dns.isRegistrarLevelDomain("co.uk.com")).isFalse(); + } + + @Test + void isRegistrableDomain() { + assertThat(Dns.isRegistrableDomain("de")).isFalse(); + assertThat(Dns.isRegistrableDomain("example.de")).isTrue(); + assertThat(Dns.isRegistrableDomain("sub.example.de")).isFalse(); + + assertThat(Dns.isRegistrableDomain("co.uk")).isFalse(); + assertThat(Dns.isRegistrableDomain("example.co.uk")).isTrue(); + assertThat(Dns.isRegistrableDomain("sub.example.co.uk")).isFalse(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java index 4705a99e..91fecdd5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java @@ -156,9 +156,9 @@ class HsDomainHttpSetupHostingAssetValidatorUnitTest { // then assertThat(result).containsExactlyInAnyOrder( "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.htdocsfallback' is expected to be of type Boolean, but is of type String", - "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.fcgi-php-bin' is expected to match any of [^/.*] but 'false' does not match", - "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '' does not match", - "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '@' does not match", - "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but 'example.com' does not match"); + "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.fcgi-php-bin' is expected to match [^/.*] but 'false' does not match", + "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '' does not match", + "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '@' does not match", + "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but 'example.com' does not match"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java index 6f451556..8b96ea76 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java @@ -2,14 +2,26 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import javax.naming.InvalidNameException; +import javax.naming.NameNotFoundException; +import javax.naming.NamingException; +import javax.naming.ServiceUnavailableException; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.function.Function; +import static java.util.Map.entry; +import static java.util.Map.ofEntries; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; @@ -17,62 +29,90 @@ import static org.assertj.core.api.Assertions.assertThat; class HsDomainSetupHostingAssetValidatorUnitTest { - static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder() { + public static final Dns.Result DOMAIN_NOT_REGISTERED = Dns.Result.fromException(new NameNotFoundException( + "domain not registered")); + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder( + final String domainName, + final Function<HsBookingItemRealEntity.HsBookingItemRealEntityBuilder<?, ?>, HsBookingItemRealEntity> buildBookingItem) { + final HsBookingItemRealEntity bookingItem = buildBookingItem.apply( + HsBookingItemRealEntity.builder() + .type(HsBookingItemType.DOMAIN_SETUP) + .resources(new HashMap<>(ofEntries( + entry("domainName", domainName) + )))); + HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP).prepareProperties(null, bookingItem); return HsHostingAssetRbacEntity.builder() .type(DOMAIN_SETUP) - .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.DOMAIN_SETUP).build()) - .identifier("example.org"); + .bookingItem(bookingItem) + .identifier(domainName); } - enum InvalidDomainNameIdentifier { - EMPTY(""), - TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.de"), - DASH_AT_BEGINNING("-example.com"), - DOT_AT_BEGINNING(".example.com"), - DOT_AT_END("example.com."); + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder(final String domainName) { + return validEntityBuilder(domainName, HsBookingItemRealEntity.HsBookingItemRealEntityBuilder::build); + } + + @AfterEach + void cleanup() { + Dns.resetFakeResults(); + } + + //===================================================================================================================== + + enum InvalidSubDomainNameIdentifierForExampleOrg { + IDENTICAL("example.org"), + TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.example.org"), + DASH_AT_BEGINNING("-sub.example.org"), + DOT(".example.org"), + DOT_AT_BEGINNING(".sub.example.org"), + DOUBLE_DOT("sub..example.com."); final String domainName; - InvalidDomainNameIdentifier(final String domainName) { + InvalidSubDomainNameIdentifierForExampleOrg(final String domainName) { this.domainName = domainName; } } @ParameterizedTest - @EnumSource(InvalidDomainNameIdentifier.class) - void rejectsInvalidIdentifier(final InvalidDomainNameIdentifier testCase) { + @EnumSource(InvalidSubDomainNameIdentifierForExampleOrg.class) + void rejectsInvalidIdentifier(final InvalidSubDomainNameIdentifierForExampleOrg testCase) { // given - final var givenEntity = validEntityBuilder().identifier(testCase.domainName).build(); + final var givenEntity = validEntityBuilder(testCase.domainName) + .bookingItem(null) + .parentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier("example.org").build()) + .build(); + // fakeValidDnsVerification(givenEntity); final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); // when final var result = validator.validateEntity(givenEntity); // then - assertThat(result).containsExactly( - "'identifier' expected to match '^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}', but is '"+testCase.domainName+"'" + assertThat(result).contains( + "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))\\.example\\.org', but is '" + testCase.domainName + "'" ); } - - enum ValidDomainNameIdentifier { - SIMPLE("exampe.org"), - MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.de"), - WITH_DASH("example-test.com"), - SUBDOMAIN("test.example.com"); + enum ValidSubDomainNameIdentifier { + SIMPLE("sub.example.org"), + MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.example.org"), + MIN_LENGTH("x.example.org"), + WITH_DASH("example-test.example.org"); final String domainName; - ValidDomainNameIdentifier(final String domainName) { + ValidSubDomainNameIdentifier(final String domainName) { this.domainName = domainName; } } @ParameterizedTest - @EnumSource(ValidDomainNameIdentifier.class) - void acceptsValidIdentifier(final ValidDomainNameIdentifier testCase) { + @EnumSource(ValidSubDomainNameIdentifier.class) + void acceptsValidIdentifier(final ValidSubDomainNameIdentifier testCase) { // given - final var givenEntity = validEntityBuilder().identifier(testCase.domainName).build(); + final var givenEntity = validEntityBuilder(testCase.domainName).identifier(testCase.domainName).build(); + fakeValidDnsVerification(givenEntity); final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); // when @@ -82,6 +122,13 @@ class HsDomainSetupHostingAssetValidatorUnitTest { assertThat(result).isEmpty(); } + private static void fakeValidDnsVerification(final HsHostingAssetRbacEntity givenEntity) { + final var expectedHash = givenEntity.getBookingItem().getDirectValue("verificationCode", String.class); + Dns.fakeResultForDomain( + givenEntity.getIdentifier(), + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); + } + @Test void containsNoProperties() { // when @@ -94,10 +141,48 @@ class HsDomainSetupHostingAssetValidatorUnitTest { @Test void validatesReferencedEntities() { // given - final var domainSetupHostingAssetEntity = validEntityBuilder() + 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()) - .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).contains( + "'DOMAIN_SETUP:example.org.bookingItem' or parentItem must be null but is of type CLOUD_SERVER", + "'DOMAIN_SETUP:example.org.parentAsset' must be null or of type DOMAIN_SETUP but is of type CLOUD_SERVER", + "'DOMAIN_SETUP:example.org.assignedToAsset' must be null but is of type MANAGED_SERVER"); + } + + @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 validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match 'example.org', but is 'not-matching-booking-item-domain-name.org'"); + } + + @ParameterizedTest + @ValueSource(strings = { "not-matching-booking-item-domain-name.org", "indirect.subdomain.example.org" }) + void rejectsDomainNameWhichIsNotADirectSubdomainOfParentAsset(final String newDomainName) { + // given + final var domainSetupHostingAssetEntity = validEntityBuilder(newDomainName) + .bookingItem(null) + .parentAsset(createValidParentDomainSetupAsset("example.org")) .build(); final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); @@ -106,21 +191,248 @@ class HsDomainSetupHostingAssetValidatorUnitTest { // then assertThat(result).containsExactlyInAnyOrder( - "'DOMAIN_SETUP:example.org.bookingItem' or parentItem must be null but is of type CLOUD_SERVER", - "'DOMAIN_SETUP:example.org.parentAsset' must be null or of type DOMAIN_SETUP but is of type CLOUD_SERVER", - "'DOMAIN_SETUP:example.org.assignedToAsset' must be null but is of type MANAGED_SERVER"); + "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))\\.example\\.org', " + + "but is '" + newDomainName + "'"); } @Test - void expectsEitherParentAssetOrBookingItem() { + void rejectsIfNeitherBookingItemNorParentAssetAreSet() { + // given - final var domainSetupHostingAssetEntity = validEntityBuilder().build(); + final var domainSetupHostingAssetEntity = validEntityBuilder("example.org") + .bookingItem(null) + .parentAsset(null) + .build(); + Dns.fakeResultForDomain( + domainSetupHostingAssetEntity.getIdentifier(), + new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null)); final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); // when final var result = validator.validateEntity(domainSetupHostingAssetEntity); // then - assertThat(result).isEmpty(); + assertThat(result).containsExactly("'DOMAIN_SETUP:example.org.bookingItem' must be of type DOMAIN_SETUP but is null"); + } + + enum DnsLookupFailureTestCase { + SERVICE_UNAVAILABLE( + new ServiceUnavailableException("no Internet connection"), + "[DNS] lookup failed for domain name 'example.org': javax.naming.ServiceUnavailableException: no Internet connection"), + NAME_NOT_FOUND( + new NameNotFoundException("domain name not found"), + null), // no + INVALID_NAME( + new InvalidNameException("domain name too long or whatever"), + "[DNS] invalid domain name 'example.org'"), + UNKNOWN_FAILURE( + new NamingException("some other problem"), + "[DNS] lookup failed for domain name 'example.org': javax.naming.NamingException: some other problem"); + + public final NamingException givenException; + public final String expectedErrorMessage; + + DnsLookupFailureTestCase(final NamingException givenException, final String expectedErrorMessage) { + this.givenException = givenException; + this.expectedErrorMessage = expectedErrorMessage; + } + } + + @ParameterizedTest + @EnumSource(DnsLookupFailureTestCase.class) + void handlesDnsLookupFailures(final DnsLookupFailureTestCase testCase) { + + // given + final var domainSetupHostingAssetEntity = validEntityBuilder("example.org").build(); + Dns.fakeResultForDomain( + domainSetupHostingAssetEntity.getIdentifier(), + Dns.Result.fromException(testCase.givenException)); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + if (testCase.expectedErrorMessage != null) { + assertThat(result).containsExactly(testCase.expectedErrorMessage); + } else { + assertThat(result).isEmpty(); + } + } + + //===================================================================================================================== + + @Test + void allowSetupOfAvailableRegistrableDomain() { + domainSetupFor("example.com").notRegistered() + .isAccepted(); + } + + @Test + void allowSetupOfAvailableRegistrable2ndLevelDomain() { + domainSetupFor("example.co.uk").notRegistered() + .isAccepted(); + } + + @Test + void rejectSetupOfRegisteredRegistrable2ndLevelDomainWithoutVerification() { + domainSetupFor("example.co.uk").registered() + .isRejectedWithCauseMissingVerificationIn("example.co.uk"); + } + + @Test + void allowSetupOfRegisteredRegistrable2ndLevelDomainWithVerification() { + domainSetupFor("example.co.uk").registeredWithVerification() + .isAccepted(); + } + + @Test + void rejectSetupOfExistingRegistrableDomainWithoutValidDnsVerification() { + domainSetupFor("example.com").registered() + .isRejectedWithCauseMissingVerificationIn("example.com"); + } + + @Test + void allowSetupOfExistingRegistrableDomainWithValidDnsVerification() { + domainSetupFor("example.org").registeredWithVerification() + .isAccepted(); + } + + @Test + void allowSetupOfUnregisteredSubdomainWithValidDnsVerificationInSuperDomain() { + domainSetupFor("sub.example.org").notRegistered().withVerificationIn("example.org") + .isAccepted(); + } + + @Test + void rejectSetupOfExistingRegistrableDomainWithInvalidDnsVerification() { + domainSetupFor("example.com").registeredWithInvalidVerification() + .isRejectedWithCauseMissingVerificationIn("example.com"); + } + + @Test + void acceptSetupOfRegisteredSubdomainWithInvalidDnsVerificationButValidDnsVerificationInSuperDomain() { + domainSetupFor("sub.example.com").registeredWithInvalidVerification().withVerificationIn("example.com") + .isAccepted(); + } + + @Test + void rejectSetupOfUnregisteredSubdomainWithoutParentAssetAndWithoutDnsVerificationInSuperDomain() { + domainSetupFor("sub.example.org").notRegistered() + .isRejectedWithCauseMissingVerificationIn("example.org"); + } + + @Test + void acceptSetupOfUnregisteredSubdomainWithParentAssetEvenWithoutDnsVerificationInSuperDomain() { + domainSetupWithParentAssetFor("sub.example.org").notRegistered() + .isAccepted(); + } + + @Test + void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() { + domainSetupFor("sub.example.org").registered() + .withVerificationIn("example.org") + .isAccepted(); + } + + @Test + void rejectSetupOfExistingSubdomainWithoutDnsVerification() { + domainSetupFor("sub.example.org").registered() + .isRejectedWithCauseMissingVerificationIn("sub.example.org"); + } + + //==================================================================================================================== + + private static HsHostingAssetRealEntity createValidParentDomainSetupAsset(final String parentDomainName) { + final var bookingItem = HsBookingItemRealEntity.builder() + .type(HsBookingItemType.DOMAIN_SETUP) + .resources(ofEntries( + entry("domainName", parentDomainName) + )) + .build(); + final var parentAsset = HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .bookingItem(bookingItem) + .identifier(parentDomainName).build(); + return parentAsset; + } + + 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) { + domainAsset = validEntityBuilder(domainName) + .bookingItem(null) + .parentAsset(parentAsset) + .build(); + expectedHash = null; + } + + DomainSetupBuilder notRegistered() { + Dns.fakeResultForDomain(domainAsset.getIdentifier(), DOMAIN_NOT_REGISTERED); + return this; + } + + DomainSetupBuilder registered() { + Dns.fakeResultForDomain( + domainAsset.getIdentifier(), + Dns.Result.fromRecords()); + return this; + } + + DomainSetupBuilder registeredWithInvalidVerification() { + Dns.fakeResultForDomain( + domainAsset.getIdentifier(), + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=SOME-DEFINITELY-WRONG-HASH")); + return this; + } + + DomainSetupBuilder registeredWithVerification() { + withVerificationIn(domainAsset.getIdentifier()); + return this; + } + + DomainSetupBuilder withVerificationIn(final String domainName) { + assertThat(expectedHash).as("no expectedHash available").isNotNull(); + Dns.fakeResultForDomain( + domainName, + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); + return this; + } + + void isRejectedWithCauseMissingVerificationIn(final String domainName) { + assertThat(expectedHash).as("no expectedHash available").isNotNull(); + assertThat(validate()).containsAnyOf( + "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash + + "' found for domain name '" + domainName + "' (nor in its super-domain)", + "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash + + "' found for domain name '" + domainName + "'"); + } + + void isAccepted() { + assertThat(validate()).isEmpty(); + } + + private List<String> validate() { + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SETUP); + return validator.validateEntity(domainAsset); + } + } + + private DomainSetupBuilder domainSetupFor(final String domainName) { + return new DomainSetupBuilder(domainName); + } + + private DomainSetupBuilder domainSetupWithParentAssetFor(final String domainName) { + return new DomainSetupBuilder( + HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(Dns.superDomain(domainName).orElseThrow()).build(), + domainName); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java index a06d3c5b..88adb55b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java @@ -89,8 +89,8 @@ class HsEMailAddressHostingAssetValidatorUnitTest { // then assertThat(result).containsExactlyInAnyOrder( - "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match", - "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match", + "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match", + "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match", "'EMAIL_ADDRESS:old-local-part@example.org.config.target' is expected to match any of [^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$, ^/dev/null$] but 'garbage' does not match any"); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java index 04768707..95a950db 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java @@ -141,7 +141,7 @@ class HsUnixUserHostingAssetValidatorUnitTest { "'UNIX_USER:abc00-temp.config.HDD hard quota' is expected to be at most 0 but is 100", "'UNIX_USER:abc00-temp.config.HDD soft quota' is expected to be at most 100 but is 200", "'UNIX_USER:abc00-temp.config.homedir' is readonly but given as '/is/read-only'", - "'UNIX_USER:abc00-temp.config.totpKey' is expected to match any of [^0x([0-9A-Fa-f]{2})+$] but provided value does not match", + "'UNIX_USER:abc00-temp.config.totpKey' is expected to match [^0x([0-9A-Fa-f]{2})+$] but provided value does not match", "'UNIX_USER:abc00-temp.config.password' length is expected to be at min 8 but length of provided value is 5", "'UNIX_USER:abc00-temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters" ); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java index 9cb774d2..f00d57dd 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java @@ -69,7 +69,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport { 512167, // 11139, partner without contractual contact 512170, // 11142, partner without contractual contact 511725, // 10764, partner without contractual contact - // 512171, // 11143, partner without partner contact -- exc + // 512171, // 11143, partner without partner contact -- exception -1 ); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java index e96e7c6e..2f34ecee 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java @@ -1352,7 +1352,7 @@ public class ImportHostingAssets extends BaseOfficeDataImport { } private void importDatabaseUsers(final String[] header, final List<String[]> records) { - HashGenerator.enableChouldBeHash(true); + HashGenerator.enableCouldBeHash(true); final var columns = new Columns(header); records.stream() .map(this::trimAll) @@ -1552,6 +1552,8 @@ public class ImportHostingAssets extends BaseOfficeDataImport { .caption("BI " + domainSetup.getIdentifier()) .project((HsBookingProjectRealEntity) relatedProject) //.validity(toPostgresDateRange(created, cancelled)) + .resources(Map.ofEntries( + entry("domainName", domainSetup.getIdentifier()))) .build(); domainSetup.setBookingItem(bookingItem); bookingItems.put(nextAvailableBookingItemId(), bookingItem);