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}(? validateEntity(final HsBookingItem bookingItem) { + final var violations = new ArrayList(); + 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 fakeResults = new HashMap<>(); + + public static Optional 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 records, NamingException exception) { + + + public static Result fromRecords(final NamingEnumeration recordEnumeration) { + final List 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}(? 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(); + 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 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 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

> 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 describedAsConsumer; private Integer minLength; private Integer maxLength; private boolean undisclosed; @@ -56,10 +66,23 @@ public class StringProperty

> 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

> extends ValidatableProp @Override protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { super.validate(result, propValue, propProvider); - if (minLength != 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

> extends ValidatableProp protected String simpleTypeName() { return "string"; } + + private void validateMinLength(final List result, final String propValue) { + if (minLength != null && propValue.length() 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 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 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}(? @@ -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}(? 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> 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}(? 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}(? 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 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);