diff --git a/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala index 9fc771d37..019b41f91 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala @@ -28,6 +28,10 @@ import scala.util.matching.Regex */ object DomainValidations { + val validReverseZoneFQDNRegex: Regex = + """^(?:([0-9a-zA-Z\-\/_]{1,63}|[0-9a-zA-Z\-\/_]{1}[0-9a-zA-Z\-\/_]{0,61}[0-9a-zA-Z\-\/_]{1}|[*.]{2}[0-9a-zA-Z\-\/_]{0,60}[0-9a-zA-Z\-\/_]{1})\.)*$""".r + val validForwardZoneFQDNRegex: Regex = + """^(?:([0-9a-zA-Z_]{1,63}|[0-9a-zA-Z_]{1}[0-9a-zA-Z\-_]{0,61}[0-9a-zA-Z_]{1}|[*.]{2}[0-9a-zA-Z\-_]{0,60}[0-9a-zA-Z_]{1})\.)*$""".r val validFQDNRegex: Regex = """^(?:([0-9a-zA-Z_]{1,63}|[0-9a-zA-Z_]{1}[0-9a-zA-Z\-\/_]{0,61}[0-9a-zA-Z_]{1}|[*.]{2}[0-9a-zA-Z\-\/_]{0,60}[0-9a-zA-Z_]{1})\.)*$""".r val validIpv4Regex: Regex = @@ -68,6 +72,30 @@ object DomainValidations { def validateHostName(name: Fqdn): ValidatedNel[DomainValidationError, Fqdn] = validateHostName(name.fqdn).map(_ => name) + def validateCname(name: Fqdn, isReverse: Boolean): ValidatedNel[DomainValidationError, Fqdn] = + validateCname(name.fqdn, isReverse).map(_ => name) + + def validateCname(name: String, isReverse: Boolean): ValidatedNel[DomainValidationError, String] = { + isReverse match { + case true => + val checkRegex = validReverseZoneFQDNRegex + .findFirstIn(name) + .map(_.validNel) + .getOrElse(InvalidCname(name,isReverse).invalidNel) + val checkLength = validateStringLength(name, Some(HOST_MIN_LENGTH), HOST_MAX_LENGTH) + + checkRegex.combine(checkLength).map(_ => name) + case false => + val checkRegex = validForwardZoneFQDNRegex + .findFirstIn(name) + .map(_.validNel) + .getOrElse(InvalidCname(name,isReverse).invalidNel) + val checkLength = validateStringLength(name, Some(HOST_MIN_LENGTH), HOST_MAX_LENGTH) + + checkRegex.combine(checkLength).map(_ => name) + } + } + def validateHostName(name: String): ValidatedNel[DomainValidationError, String] = { /* Label rules are as follows (from RFC 952; detailed in RFC 1034): @@ -93,6 +121,8 @@ object DomainValidations { checkRegex.combine(checkLength).map(_ => name) } + + def validateIpv4Address(address: String): ValidatedNel[DomainValidationError, String] = validIpv4Regex .findFirstIn(address) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeValidations.scala index a9d1e9b54..4825be88b 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeValidations.scala @@ -211,7 +211,7 @@ class BatchChangeValidations( isApproved: Boolean ): SingleValidation[Unit] = { val validTTL = addChangeInput.ttl.map(validateTTL(_).asUnit).getOrElse(().valid) - val validRecord = validateRecordData(addChangeInput.record) + val validRecord = validateRecordData(addChangeInput.record, addChangeInput) val validInput = validateInputName(addChangeInput, isApproved) validTTL |+| validRecord |+| validInput @@ -222,7 +222,7 @@ class BatchChangeValidations( isApproved: Boolean ): SingleValidation[Unit] = { val validRecord = deleteRRSetChangeInput.record match { - case Some(recordData) => validateRecordData(recordData) + case Some(recordData) => validateRecordData(recordData, deleteRRSetChangeInput) case None => ().validNel } val validInput = validateInputName(deleteRRSetChangeInput, isApproved) @@ -230,11 +230,18 @@ class BatchChangeValidations( validRecord |+| validInput } - def validateRecordData(record: RecordData): SingleValidation[Unit] = + def validateRecordData(record: RecordData,change: ChangeInput): SingleValidation[Unit] = record match { case a: AData => validateIpv4Address(a.address).asUnit case aaaa: AAAAData => validateIpv6Address(aaaa.address).asUnit - case cname: CNAMEData => validateCName(cname.cname).asUnit + case cname: CNAMEData => + /* + To validate the zone is reverse + */ + val isIPv4: Boolean = change.inputName.toLowerCase.endsWith("in-addr.arpa.") + val isIPv6: Boolean = change.inputName.toLowerCase.endsWith("ip6.arpa.") + val isReverse: Boolean = isIPv4 || isIPv6 + validateCname(cname.cname,isReverse).asUnit case ptr: PTRData => validateHostName(ptr.ptrdname).asUnit case txt: TXTData => validateTxtTextLength(txt.text).asUnit case mx: MXData => diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala index 8853c7d96..02cadc115 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala @@ -171,6 +171,8 @@ final case class GroupNotFoundError(msg: String) extends Throwable(msg) final case class GroupAlreadyExistsError(msg: String) extends Throwable(msg) +final case class GroupValidationError(msg: String) extends Throwable(msg) + final case class UserNotFoundError(msg: String) extends Throwable(msg) final case class InvalidGroupError(msg: String) extends Throwable(msg) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala index 50226337d..9345bb4ff 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala @@ -57,6 +57,7 @@ class MembershipService( val adminMembers = inputGroup.adminUserIds val nonAdminMembers = inputGroup.memberIds.diff(adminMembers) for { + _ <- groupValidation(newGroup) _ <- hasMembersAndAdmins(newGroup).toResult _ <- groupWithSameNameDoesNotExist(newGroup.name) _ <- usersExist(newGroup.memberIds) @@ -76,6 +77,7 @@ class MembershipService( for { existingGroup <- getExistingGroup(groupId) newGroup = existingGroup.withUpdates(name, email, description, memberIds, adminUserIds) + _ <- groupValidation(newGroup) _ <- canEditGroup(existingGroup, authPrincipal).toResult addedAdmins = newGroup.adminUserIds.diff(existingGroup.adminUserIds) // new non-admin members ++ admins converted to non-admins @@ -218,7 +220,7 @@ class MembershipService( .map(x => GroupInfo.fromGroup(x, abridged, Some(authPrincipal))) val filtered = allMyGroups - .filter(grp => groupNameFilter.forall(grp.name.contains(_))) + .filter(grp => groupNameFilter.map(_.toLowerCase).forall(grp.name.toLowerCase.contains(_))) .filter(grp => startFrom.forall(grp.id > _)) val nextId = if (filtered.length > maxItems) Some(filtered(maxItems - 1).id) else None @@ -277,6 +279,16 @@ class MembershipService( .orFail(GroupNotFoundError(s"Group with ID $groupId was not found")) .toResult[Group] + // Validate group details. Group name and email cannot be empty + def groupValidation(group: Group): Result[Unit] = { + Option(group) match { + case Some(value) if Option(value.name).forall(_.trim.isEmpty) || Option(value.email).forall(_.trim.isEmpty) => + GroupValidationError(GroupValidationErrorMsg).asLeft + case _ => + ().asRight + } + }.toResult + def groupWithSameNameDoesNotExist(name: String): Result[Unit] = groupRepo .getGroupByName(name) diff --git a/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala index 620c6d79f..8ec1399e1 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala @@ -45,6 +45,7 @@ class MembershipRoute( case GroupNotFoundError(msg) => complete(StatusCodes.NotFound, msg) case NotAuthorizedError(msg) => complete(StatusCodes.Forbidden, msg) case GroupAlreadyExistsError(msg) => complete(StatusCodes.Conflict, msg) + case GroupValidationError(msg) => complete(StatusCodes.BadRequest, msg) case InvalidGroupError(msg) => complete(StatusCodes.BadRequest, msg) case UserNotFoundError(msg) => complete(StatusCodes.NotFound, msg) case InvalidGroupRequestError(msg) => complete(StatusCodes.BadRequest, msg) diff --git a/modules/api/src/test/functional/tests/batch/create_batch_change_test.py b/modules/api/src/test/functional/tests/batch/create_batch_change_test.py index c8d8b5b3a..094e66c9f 100644 --- a/modules/api/src/test/functional/tests/batch/create_batch_change_test.py +++ b/modules/api/src/test/functional/tests/batch/create_batch_change_test.py @@ -1975,7 +1975,7 @@ def test_cname_recordtype_add_checks(shared_zone_test_context): error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', f'Invalid domain name: "bad-ttl-and-invalid-name$.{parent_zone_name}", ' "valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.", - 'Invalid domain name: "also$bad.name.", valid domain names must be letters, numbers, underscores, and hyphens, ' + 'Invalid Cname: "also$bad.name.", valid cnames must be letters, numbers, underscores, and hyphens, ' "joined by dots, and terminated with a dot."]) # zone discovery failure assert_failed_change_in_error_response(response[7], input_name="no.zone.com.", record_type="CNAME", @@ -2138,7 +2138,7 @@ def test_cname_recordtype_update_delete_checks(shared_zone_test_context): error_messages=['Invalid TTL: "20", must be a number between 30 and 2147483647.', 'Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, ' 'underscores, and hyphens, joined by dots, and terminated with a dot.', - 'Invalid domain name: "$another.invalid.cname.", valid domain names must be letters, numbers, ' + 'Invalid Cname: "$another.invalid.cname.", valid cnames must be letters, numbers, ' 'underscores, and hyphens, joined by dots, and terminated with a dot.']) # zone discovery failure diff --git a/modules/api/src/test/scala/vinyldns/api/domain/DomainValidationsSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/DomainValidationsSpec.scala index b101282c5..e447c2772 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/DomainValidationsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/DomainValidationsSpec.scala @@ -19,11 +19,10 @@ package vinyldns.api.domain import cats.scalatest.ValidatedMatchers import org.scalacheck._ import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks -import org.scalatest._ import org.scalatest.propspec.AnyPropSpec import org.scalatest.matchers.should.Matchers import vinyldns.api.ValidationTestImprovements._ -import vinyldns.core.domain.{Fqdn, InvalidDomainName, InvalidLength} +import vinyldns.core.domain.{InvalidDomainName, Fqdn, InvalidCname, InvalidLength} class DomainValidationsSpec extends AnyPropSpec @@ -160,4 +159,52 @@ class DomainValidationsSpec val invalidDesc = "a" * 256 validateStringLength(Some(invalidDesc), None, 255).failWith[InvalidLength] } + + property("Shortest cname should be valid") { + validateCname("a.",true) shouldBe valid + validateCname("a.",false) shouldBe valid + + } + + property("Longest cname should be valid") { + val name = ("a" * 50 + ".") * 5 + validateCname(name,true) shouldBe valid + validateCname(name,false) shouldBe valid + + } + + property("Cnames with underscores should pass property-based testing") { + validateCname("_underscore.domain.name.",true).isValid + validateCname("under_score.domain.name.",true).isValid + validateCname("underscore._domain.name.",true).isValid + validateCname("_underscore.domain.name.",false).isValid + validateCname("under_score.domain.name.",false).isValid + validateCname("underscore._domain.name.",false).isValid + } + + // For wildcard records. '*' can only be in the beginning followed by '.' and domain name + property("Cnames beginning with asterisk should pass property-based testing") { + validateCname("*.domain.name.",true) shouldBe valid + validateCname("aste*risk.domain.name.",true) shouldBe invalid + validateCname("*asterisk.domain.name.",true) shouldBe invalid + validateCname("asterisk*.domain.name.",true) shouldBe invalid + validateCname("asterisk.*domain.name.",true) shouldBe invalid + validateCname("asterisk.domain*.name.",true) shouldBe invalid + validateCname("*.domain.name.",false) shouldBe valid + validateCname("aste*risk.domain.name.",false) shouldBe invalid + validateCname("*asterisk.domain.name.",false) shouldBe invalid + validateCname("asterisk*.domain.name.",false) shouldBe invalid + validateCname("asterisk.*domain.name.",false) shouldBe invalid + validateCname("asterisk.domain*.name.",false) shouldBe invalid + } + property("Cname names with forward slash should pass with reverse zone") { + validateCname("/slash.cname.name.",true).isValid + validateCname("slash./cname.name.",true).isValid + validateCname("slash.cname./name.",true).isValid + } + property("Cname names with forward slash should fail with forward zone") { + validateCname("/slash.cname.name.",false).failWith[InvalidCname] + validateCname("slash./cname.name.",false).failWith[InvalidCname] + validateCname("slash.cname./name.",false).failWith[InvalidCname] + } } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeValidationsSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeValidationsSpec.scala index 8ce5bf987..35f10af94 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeValidationsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeValidationsSpec.scala @@ -712,7 +712,7 @@ class BatchChangeValidationsSpec ) val result = validateAddChangeInput(change, false) - result should haveInvalid[DomainValidationError](InvalidDomainName(s"$invalidCNAMERecordData.")) + result should haveInvalid[DomainValidationError](InvalidCname(s"$invalidCNAMERecordData.",false)) } property("""validateAddChangeInput: should fail with Invalid CNAME @@ -2681,4 +2681,21 @@ class BatchChangeValidationsSpec result(3) shouldBe valid result(4) shouldBe valid } + + property("validateAddChangeInput: should fail for a CNAME addChangeInput with forward slash for forward zone") { + val cnameWithForwardSlash = AddChangeInput("cname.ok.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname/"))) + val result = validateAddChangeInput(cnameWithForwardSlash, false) + result should haveInvalid[DomainValidationError](InvalidCname("cname/.",false)) + } + property("validateAddChangeInput: should succeed for a valid CNAME addChangeInput without forward slash for forward zone") { + val cname = AddChangeInput("cname.ok.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname"))) + val result = validateAddChangeInput(cname, false) + result shouldBe valid + } + property("validateAddChangeInput: should succeed for a valid CNAME addChangeInput with forward slash for reverse zone") { + val cnameWithForwardSlash = AddChangeInput("2.0.192.in-addr.arpa.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname/"))) + val result = validateAddChangeInput(cnameWithForwardSlash, true) + result shouldBe valid + } + } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala index 9276f12b1..44d178c18 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala @@ -114,6 +114,7 @@ class MembershipServiceSpec "create a new group" should { "save the group and add the members when the group is valid" in { doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(groupInfo) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) @@ -141,6 +142,7 @@ class MembershipServiceSpec "save the groupChange in the groupChangeRepo" in { doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(groupInfo) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) @@ -168,7 +170,7 @@ class MembershipServiceSpec adminUserIds = Set(okUserInfo.id, dummyUserInfo.id) ) val expectedMembersAdded = Set(okUserInfo.id, dummyUserInfo.id) - + doReturn(().toResult).when(underTest).groupValidation(info) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(info.name) doReturn(().toResult).when(underTest).usersExist(any[Set[String]]) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) @@ -196,6 +198,7 @@ class MembershipServiceSpec "set the current user as a member" in { val info = groupInfo.copy(memberIds = Set.empty, adminUserIds = Set.empty) doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(info) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(info.name) doReturn(().toResult).when(underTest).usersExist(Set(okAuth.userId)) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) @@ -224,6 +227,7 @@ class MembershipServiceSpec "return an error if users do not exist" in { doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(groupInfo) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name) doReturn(result(UserNotFoundError("fail"))) .when(underTest) @@ -239,6 +243,7 @@ class MembershipServiceSpec "return an error if fail while saving the group" in { doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(groupInfo) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(IO.raiseError(new RuntimeException("fail"))).when(mockGroupRepo).save(any[DB], any[Group]) @@ -253,6 +258,7 @@ class MembershipServiceSpec "return an error if fail while adding the members" in { doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(().toResult).when(underTest).groupValidation(groupInfo) doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) @@ -264,6 +270,20 @@ class MembershipServiceSpec val error = leftResultOf(underTest.createGroup(groupInfo, okAuth).value) error shouldBe a[RuntimeException] } + + "return an error if group name and/or email is empty" in { + doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") + doReturn(result(GroupValidationError("fail"))) + .when(underTest) + .groupValidation(groupInfo.copy(name = "", email = "")) + + val error = leftResultOf(underTest.createGroup(groupInfo.copy(name = "", email = ""), okAuth).value) + error shouldBe a[GroupValidationError] + + verify(mockGroupRepo, never()).save(any[DB], any[Group]) + verify(mockMembershipRepo, never()) + .saveMembers(any[DB], anyString, any[Set[String]], isAdmin = anyBoolean) + } } "update an existing group" should { @@ -388,6 +408,31 @@ class MembershipServiceSpec error shouldBe a[GroupAlreadyExistsError] } + "return an error if group name and/or email is empty" in { + doReturn(IO.pure(Some(existingGroup))) + .when(mockGroupRepo) + .getGroup(existingGroup.id) + doReturn(().toResult).when(underTest).usersExist(any[Set[String]]) + doReturn(result(GroupValidationError("fail"))) + .when(underTest) + .groupValidation(existingGroup.copy(name = "", email = "")) + + val error = leftResultOf( + underTest + .updateGroup( + updatedInfo.id, + name = "", + email = "", + updatedInfo.description, + updatedInfo.memberIds, + updatedInfo.adminUserIds, + okAuth + ) + .value + ) + error shouldBe a[GroupValidationError] + } + "return an error if the group is not found" in { doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(existingGroup.id) @@ -597,6 +642,30 @@ class MembershipServiceSpec ignoreAccess = false ) } + "return only return groups whose name matches the filter, regardless of case" in { + doReturn(IO.pure(listOfDummyGroups.toSet)) + .when(mockGroupRepo) + .getGroups(any[Set[String]]) + val result: ListMyGroupsResponse = rightResultOf( + underTest + .listMyGroups( + groupNameFilter = Some("Name-Dummy01"), + startFrom = None, + maxItems = 100, + listOfDummyGroupsAuth, + false + ) + .value + ) + result shouldBe ListMyGroupsResponse( + groups = listOfDummyGroupInfo.slice(10, 20), + groupNameFilter = Some("Name-Dummy01"), + startFrom = None, + nextId = None, + maxItems = 100, + ignoreAccess = false + ) + } "return only return groups after startFrom" in { doReturn(IO.pure(listOfDummyGroups.toSet)) .when(mockGroupRepo) diff --git a/modules/core/src/main/scala/vinyldns/core/Messages.scala b/modules/core/src/main/scala/vinyldns/core/Messages.scala index b37c9e6eb..0c6929f4f 100644 --- a/modules/core/src/main/scala/vinyldns/core/Messages.scala +++ b/modules/core/src/main/scala/vinyldns/core/Messages.scala @@ -79,4 +79,6 @@ object Messages { val NotAuthorizedErrorMsg = "User \"%s\" is not authorized. Contact %s owner group: %s at %s to make DNS changes." + // Error displayed when group name or email is empty + val GroupValidationErrorMsg = "Group name and email cannot be empty." } diff --git a/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala b/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala index 0590c4225..ad77e2675 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala @@ -52,11 +52,23 @@ final case class InvalidDomainName(param: String) extends DomainValidationError "joined by dots, and terminated with a dot." } -final case class InvalidCName(param: String) extends DomainValidationError { +final case class InvalidIPv4CName(param: String) extends DomainValidationError { def message: String = s"""Invalid Cname: "$param", valid cname should not be IP address""" } +final case class InvalidCname(param: String, isReverseZone: Boolean) extends DomainValidationError { + def message: String = + isReverseZone match { + case true => + s"""Invalid Cname: "$param", valid cnames must be letters, numbers, slashes, underscores, and hyphens, """ + + "joined by dots, and terminated with a dot." + case false => + s"""Invalid Cname: "$param", valid cnames must be letters, numbers, underscores, and hyphens, """ + + "joined by dots, and terminated with a dot." + } +} + final case class InvalidLength(param: String, minLengthInclusive: Int, maxLengthInclusive: Int) extends DomainValidationError { def message: String = diff --git a/modules/core/src/main/scala/vinyldns/core/domain/SingleChangeError.scala b/modules/core/src/main/scala/vinyldns/core/domain/SingleChangeError.scala index 99731d7db..862f4ae7e 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/SingleChangeError.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/SingleChangeError.scala @@ -30,7 +30,7 @@ object DomainValidationErrorType extends Enumeration { type DomainValidationErrorType = Value // NOTE: once defined, an error code type cannot be changed! val ChangeLimitExceeded, BatchChangeIsEmpty, GroupDoesNotExist, NotAMemberOfOwnerGroup, - InvalidDomainName, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber, + InvalidDomainName, InvalidCname, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber, InvalidIpv4Address, InvalidIpv6Address, InvalidIPAddress, InvalidTTL, InvalidMxPreference, InvalidBatchRecordType, ZoneDiscoveryError, RecordAlreadyExists, RecordDoesNotExist, CnameIsNotUniqueError, UserIsNotAuthorized, UserIsNotAuthorizedError, RecordNameNotUniqueInBatch, @@ -46,6 +46,7 @@ object DomainValidationErrorType extends Enumeration { case _: GroupDoesNotExist => GroupDoesNotExist case _: NotAMemberOfOwnerGroup => NotAMemberOfOwnerGroup case _: InvalidDomainName => InvalidDomainName + case _: InvalidCname => InvalidCname case _: InvalidLength => InvalidLength case _: InvalidEmail => InvalidEmail case _: InvalidRecordType => InvalidRecordType diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala index 314209155..369c35443 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala @@ -468,6 +468,27 @@ class MySqlZoneRepositoryIntegrationSpec (f.unsafeRunSync().zones should contain).theSameElementsInOrderAs(expectedZones) } + "support case insensitivity in the zone filter" in { + + val testZones = Seq( + testZone("system-test.", adminGroupId = "foo"), + testZone("system-temp.", adminGroupId = "foo"), + testZone("system-nomatch.", adminGroupId = "bar") + ) + + val expectedZones = Seq(testZones(0), testZones(1)).sortBy(_.name) + + val auth = AuthPrincipal(dummyUser, Seq("foo")) + + val f = + for { + _ <- saveZones(testZones) + retrieved <- repo.listZones(auth, zoneNameFilter = Some("SyStEm*")) + } yield retrieved + + (f.unsafeRunSync().zones should contain).theSameElementsInOrderAs(expectedZones) + } + "support starts with wildcard" in { val testZones = Seq( diff --git a/modules/portal/Gruntfile.js b/modules/portal/Gruntfile.js index 4c4b5c008..7a3123934 100644 --- a/modules/portal/Gruntfile.js +++ b/modules/portal/Gruntfile.js @@ -34,9 +34,11 @@ module.exports = function(grunt) { {expand: true, flatten: true, src: ['node_modules/bootstrap/dist/js/bootstrap.min.js'], dest: 'public/js'}, {expand: true, flatten: true, src: ['node_modules/jquery/dist/jquery.min.js'], dest: 'public/js'}, {expand: true, flatten: true, src: ['node_modules/moment/min/moment.min.js'], dest: 'public/js'}, + {expand: true, flatten: true, src: ['node_modules/jquery-ui-dist/jquery-ui.js'], dest: 'public/js'}, {expand: true, flatten: true, src: ['node_modules/bootstrap/dist/css/bootstrap.min.css'], dest: 'public/css'}, {expand: true, flatten: true, src: ['node_modules/font-awesome/css/font-awesome.min.css'], dest: 'public/css'}, + {expand: true, flatten: true, src: ['node_modules/jquery-ui-dist/jquery-ui.css'], dest: 'public/css'}, // We're picking just the resources we need from the gentelella UI framework and temporarily storing them in mapped/ui/ {expand: true, flatten: true, cwd: 'node_modules/gentelella', dest: 'mapped/ui', src: '**/jquery.{smartWizard,dataTables.min,mousewheel.min}.js'}, diff --git a/modules/portal/app/views/main.scala.html b/modules/portal/app/views/main.scala.html index 99f81d859..ae72212c4 100644 --- a/modules/portal/app/views/main.scala.html +++ b/modules/portal/app/views/main.scala.html @@ -16,10 +16,12 @@ + + @@ -149,6 +151,7 @@ + diff --git a/modules/portal/app/views/zones/zones.scala.html b/modules/portal/app/views/zones/zones.scala.html index 52ab8b759..9c56548df 100644 --- a/modules/portal/app/views/zones/zones.scala.html +++ b/modules/portal/app/views/zones/zones.scala.html @@ -144,6 +144,9 @@ +
diff --git a/modules/portal/karma.conf.js b/modules/portal/karma.conf.js index 6f18b11c1..e2d7dbf1a 100644 --- a/modules/portal/karma.conf.js +++ b/modules/portal/karma.conf.js @@ -15,6 +15,8 @@ module.exports = function(config) { // list of files / patterns to load in the browser files: [ 'js/jquery.min.js', + 'js/jquery-ui-dist.js', + 'js/jquery-ui.js', 'js/bootstrap.min.js', 'js/angular.min.js', 'js/moment.min.js', diff --git a/modules/portal/package.json b/modules/portal/package.json index bf980efe2..0a0c4f319 100644 --- a/modules/portal/package.json +++ b/modules/portal/package.json @@ -24,6 +24,7 @@ "jasmine-core": "^2.99.1", "jasmine-jquery": "2.1.1", "jquery": "^3.6.0", + "jquery-ui-dist": "^1.13.1", "karma": "^6.3.17", "karma-chrome-launcher": "^2.2.0", "karma-jasmine": "^1.0.2", diff --git a/modules/portal/public/css/vinyldns.css b/modules/portal/public/css/vinyldns.css index 96d2a2a67..7deb943a2 100644 --- a/modules/portal/public/css/vinyldns.css +++ b/modules/portal/public/css/vinyldns.css @@ -104,7 +104,7 @@ a.action-link { top: 3px; left: 50%; width: 40%; - z-index: 100; + z-index: 2000; } .dns-connection-form { @@ -492,6 +492,26 @@ input[type="file"] { opacity: 0.75; } +.ui-menu .ui-menu-item div { + background: white; + width: 100%; + font-size: 14px; + color: black; + border: none; + border-left: 1px; +} + +.ui-menu .ui-menu-item div:hover { + background: #f5f5f5; +} + +.ui-autocomplete { + max-height: 200px; + width:150px; + overflow-y: auto; + overflow-x: hidden; +} + #fixed-side-menu { position: fixed; top: 0; diff --git a/modules/portal/public/lib/controllers/controller.groups.js b/modules/portal/public/lib/controllers/controller.groups.js index 998ce1b60..323fe61d7 100644 --- a/modules/portal/public/lib/controllers/controller.groups.js +++ b/modules/portal/public/lib/controllers/controller.groups.js @@ -64,6 +64,44 @@ angular.module('controller.groups', []).controller('GroupsController', function return true; }; + // Autocomplete for group search + $("#group-search-text").autocomplete({ + source: function( request, response ) { + $.ajax({ + url: "/api/groups?maxItems=1500&abridged=true", + dataType: "json", + data: {groupNameFilter: request.term, ignoreAccess: $scope.ignoreAccess}, + success: function(data) { + const search = JSON.parse(JSON.stringify(data)); + response($.map(search.groups, function(group) { + return {value: group.name, label: group.name} + })) + } + }); + }, + minLength: 1, + select: function (event, ui) { + $scope.query = ui.item.value; + $("#group-search-text").val(ui.item.value); + return false; + }, + open: function() { + $(this).removeClass("ui-corner-all").addClass("ui-corner-top"); + }, + close: function() { + $(this).removeClass("ui-corner-top").addClass("ui-corner-all"); + } + }); + + // Autocomplete text-highlight + $.ui.autocomplete.prototype._renderItem = function(ul, item) { + let txt = String(item.label).replace(new RegExp(this.term, "gi"),"$&"); + return $("
  • ") + .data("ui-autocomplete-item", item.value) + .append("
    " + txt + "
    ") + .appendTo(ul); + }; + $scope.createGroup = function (name, email, description) { //prevent user executing service call multiple times //if true prevent, if false allow for execution of rest of code diff --git a/modules/portal/public/lib/recordset/recordsets.controller.js b/modules/portal/public/lib/recordset/recordsets.controller.js index 10ffa646f..42c36355c 100644 --- a/modules/portal/public/lib/recordset/recordsets.controller.js +++ b/modules/portal/public/lib/recordset/recordsets.controller.js @@ -31,15 +31,57 @@ // paging status for recordsets var recordsPaging = pagingService.getNewPagingParams(100); + var recordType = []; + var recordName = []; + + $( "#record-search-text" ).autocomplete({ + source: function( request, response ) { + $.ajax({ + url: "/api/recordsets?maxItems=100", + dataType: "json", + data: "recordNameFilter="+request.term+"%25&nameSort=asc", + success: function( data ) { + const recordSearch = JSON.parse(JSON.stringify(data)); + response($.map(recordSearch.recordSets, function(item) { + return {value: item.fqdn +' | '+ item.type , label: 'name: ' + item.fqdn + ' | type: ' + item.type }}))} + }); + }, + minLength: 2, + select: function (event, ui) { + $scope.query = ui.item.value; + $("#record-search-text").val(ui.item.value); + return false; + }, + open: function() { + $( this ).removeClass( "ui-corner-all" ).addClass( "ui-corner-top" ); + }, + close: function() { + $( this ).removeClass( "ui-corner-top" ).addClass( "ui-corner-all" ); + } + }); + + $.ui.autocomplete.prototype._renderItem = function( ul, item ) { + let recordSet = String(item.label).replace(new RegExp(this.term, "gi"),"$&"); + return $("
  • ") + .data("ui-autocomplete-item", item.value) + .append("
    " + recordSet + "
    ") + .appendTo(ul); }; $scope.refreshRecords = function() { - recordsPaging = pagingService.resetPaging(recordsPaging); + if($scope.query.includes("|")) { + const queryRecord = $scope.query.split('|'); + recordName = queryRecord[0].trim(); + recordType = queryRecord[1].trim(); } + else { recordName = $scope.query; + recordType = $scope.selectedRecordTypes.toString(); } + + recordsPaging = pagingService.resetPaging(recordsPaging); function success(response) { recordsPaging.next = response.data.nextId; updateRecordDisplay(response.data['recordSets']); } return recordsService - .listRecordSetData(recordsPaging.maxItems, undefined, $scope.query, $scope.selectedRecordTypes.toString(), $scope.nameSort, $scope.ownerGroupFilter) + .listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter) .then(success) .catch(function (error) { handleError(error, 'dnsChangesService::getRecordSet-failure'); @@ -79,6 +121,7 @@ } }; + function updateRecordDisplay(records) { var newRecords = []; angular.forEach(records, function(record) { diff --git a/modules/portal/public/lib/services/zones/service.zones.js b/modules/portal/public/lib/services/zones/service.zones.js index 4ac031860..2273421e9 100644 --- a/modules/portal/public/lib/services/zones/service.zones.js +++ b/modules/portal/public/lib/services/zones/service.zones.js @@ -30,7 +30,16 @@ angular.module('service.zones', []) "ignoreAccess": ignoreAccess }; var url = groupsService.urlBuilder("/api/zones", params); - return $http.get(url); + let loader = $("#loader"); + loader.modal({ + backdrop: "static", //remove ability to close modal with click + keyboard: false, //remove option to close with keyboard + show: true //Display loader! + }) + let promis = $http.get(url); + // Hide loader when api gets response + promis.then(()=>loader.modal("hide"), ()=>loader.modal("hide")) + return promis }; this.getBackendIds = function() { diff --git a/version.sbt b/version.sbt index 501eb5530..3c3c14dd9 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.14.0" +version in ThisBuild := "0.14.1"