mirror of
https://github.com/VinylDNS/vinyldns
synced 2025-08-30 13:58:15 +00:00
Merge branch 'master' into disallow_ipv4_in_cname
This commit is contained in:
@@ -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)
|
||||
|
@@ -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 =>
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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]
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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."
|
||||
}
|
||||
|
@@ -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 =
|
||||
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
@@ -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'},
|
||||
|
@@ -16,10 +16,12 @@
|
||||
|
||||
<!-- CSS INCLUDE -->
|
||||
<link rel="stylesheet" type="text/css" id="theme" href="/public/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
|
||||
<link rel="stylesheet" type="text/css" href="/public/css/font-awesome.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/public/css/ui.css" />
|
||||
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/theme-overrides.css"/>
|
||||
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/vinyldns.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
|
||||
<!-- EOF CSS INCLUDE -->
|
||||
</head>
|
||||
|
||||
@@ -149,6 +151,7 @@
|
||||
|
||||
<script src="/public/js/moment.min.js"></script>
|
||||
<script src="/public/js/jquery.min.js"></script>
|
||||
<script src="/public/js/jquery-ui.js"></script>
|
||||
<script src="/public/js/bootstrap.min.js"></script>
|
||||
<script src="/public/js/angular.min.js"></script>
|
||||
<script src="/public/js/ui.js"></script>
|
||||
|
@@ -144,6 +144,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="loader" tabindex="-1" role="dialog" >
|
||||
<div class="spinner" ></div>
|
||||
</div>
|
||||
<div class="tab-pane" id="allZones">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
@@ -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',
|
||||
|
@@ -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",
|
||||
|
@@ -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;
|
||||
|
@@ -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"),"<b>$&</b>");
|
||||
return $("<li></li>")
|
||||
.data("ui-autocomplete-item", item.value)
|
||||
.append("<div>" + txt + "</div>")
|
||||
.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
|
||||
|
@@ -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"),"<b>$&</b>");
|
||||
return $("<li></li>")
|
||||
.data("ui-autocomplete-item", item.value)
|
||||
.append("<div>" + recordSet + "</div>")
|
||||
.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) {
|
||||
|
@@ -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() {
|
||||
|
@@ -1 +1 @@
|
||||
version in ThisBuild := "0.14.0"
|
||||
version in ThisBuild := "0.14.1"
|
||||
|
Reference in New Issue
Block a user