diff --git a/AUTHORS.md b/AUTHORS.md index 6e42640fd..2e713ac03 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -36,14 +36,17 @@ in any way, but do not see your name here, please open a PR to add yourself (in - Joshulyne Park - Nathan Pierce - Michael Pilquist +- Aravindh Raju - Sriram Ramakrishnan - Khalid Reid - Timo Schmid - Trent Schmidt +- Nick Spadaccino - Ghafar Shah - Rebecca Star - Jess Stodola - Juan Valencia +- Jayaraj Velkumar - Anastasia Vishnyakova - Jim Wakemen - Fei Wan diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a37c18ae5..146bc58b9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -10,6 +10,7 @@ * [Portal](#portal) * [Documentation](#documentation) - [Running VinylDNS Locally](#running-vinyldns-locally) + * [Support for M1 Macs](#support-for-m1-macs) * [Starting the API Server](#starting-the-api-server) * [Starting the Portal](#starting-the-portal) - [Testing](#testing) @@ -161,6 +162,61 @@ settings for the microsite are also configured in `build.sbt` of the project roo VinylDNS can be started in the background by running the [quickstart instructions](README.md#quickstart) located in the README. However, VinylDNS can also be run in the foreground. +### Support for M1 Macs + +If you are using a Mac running macOS with one of the new Apple M1 chips, you will need to update some dependencies to +newer versions before attempting to run VinylDNS locally. To verify whether your computer has one of these chips, +go to About This Mac in the Apple menu in the top-left corner of your screen. If next to Chip you see Apple M1, +or any later chip such as the Apple M1 Pro or Apple M1 Max, then you will need to apply these changes to the code. + +#### build.sbt + +Update protoc from version 2.6.1: + +```shell +PB.targets in Compile := Seq(PB.gens.java("2.6.1") -> (sourceManaged in Compile).value), +PB.protocVersion := "-v261" +``` + +to version 3.21.7: + +```shell +PB.targets in Compile := Seq(PB.gens.java("3.21.7") -> (sourceManaged in Compile).value), +PB.protocVersion := "-v3.21.7" +``` + +#### project/build.properties + +Update `sbt.version=1.4.0` to `sbt.version=1.7.2` + +#### project/Dependencies.scala + +Update protobuf from version 2.6.1: + +```shell +"com.google.protobuf" % "protobuf-java" % "2.6.1", +``` + +to version 3.21.7: + +```shell +"com.google.protobuf" % "protobuf-java" % "3.21.7", +``` + +#### project/plugins.sbt + +Update the sbt-protoc plugin from version 0.99.18: + +```shell +addSbtPlugin("com.thesamet" % "sbt-protoc" % "0.99.18") +``` + +to version 1.0.6: + +```shell +addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.6") +``` + ### Starting the API Server Before starting the API service, you can start the dependencies for local development: @@ -267,7 +323,7 @@ Additionally, you can pass `--interactive` to `make run` or `make run-local` to From there you can run tests with the `/functional_test/run.sh` command. This allows for finer-grained control over the test execution process as well as easier inspection of logs. -You can run a specific test by name by running `make run -- -k `. Any arguments after +You can run a specific test by name by running `make build` and `make run -- -k `. Any arguments after `make run --` will be passed to the test runner [`test/api/functional/run.sh`](test/api/functional/run.sh). Finally, you can execute `make run-deps-bg` to all of the dependencies for the functional test, but not run the tests. diff --git a/modules/api/src/it/resources/application.conf b/modules/api/src/it/resources/application.conf index a64eddac2..dd3b7006e 100644 --- a/modules/api/src/it/resources/application.conf +++ b/modules/api/src/it/resources/application.conf @@ -163,6 +163,28 @@ vinyldns { "ns1.parent.com4." ] + # approved zones, individual users, users in groups, record types and no.of.dots that are allowed for dotted hosts + dotted-hosts = { + # for local testing + allowed-settings = [ + { + zone = "*mmy." + user-list = ["testuser"] + group-list = ["dummy-group"] + record-types = ["AAAA"] + dots-limit = 3 + }, + { + # for wildcard zones. Settings will be applied to all matching zones + zone = "parent.com." + user-list = ["professor", "testuser"] + group-list = ["testing-group"] + record-types = ["A", "CNAME"] + dots-limit = 3 + } + ] + } + # Note: This MUST match the Portal or strange errors will ensue, NoOpCrypto should not be used for production crypto { type = "vinyldns.core.crypto.NoOpCrypto" diff --git a/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala index cc5579b4a..38660f4c1 100644 --- a/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala +++ b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala @@ -33,7 +33,6 @@ import vinyldns.api.domain.access.AccessValidations import vinyldns.api.domain.zone._ import vinyldns.api.engine.TestMessageQueue import vinyldns.mysql.TransactionProvider -import vinyldns.core.TestMembershipData._ import vinyldns.core.TestZoneData.testConnection import vinyldns.core.domain.{Fqdn, HighValueDomainError} import vinyldns.core.domain.auth.AuthPrincipal @@ -65,14 +64,24 @@ class RecordSetServiceIntegrationSpec private var testRecordSetService: RecordSetServiceAlgebra = _ private val user = User("live-test-user", "key", "secret") + private val testUser = User("testuser", "key", "secret") private val user2 = User("shared-record-test-user", "key-shared", "secret-shared") private val group = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id)) + private val dummyGroup = Group(s"dummy-group", "test@test.com", adminUserIds = Set(testUser.id)) private val group2 = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id, user2.id)) private val sharedGroup = Group(s"test-shared-group", "test@test.com", adminUserIds = Set(user.id, user2.id)) private val auth = AuthPrincipal(user, Seq(group.id, sharedGroup.id)) private val auth2 = AuthPrincipal(user2, Seq(sharedGroup.id, group2.id)) + val dummyAuth: AuthPrincipal = AuthPrincipal(testUser, Seq(dummyGroup.id)) + private val dummyZone = Zone( + s"dummy.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection, + adminGroupId = dummyGroup.id + ) private val zone = Zone( s"live-zone-test.", "test@test.com", @@ -101,6 +110,16 @@ class RecordSetServiceIntegrationSpec None, List(AAAAData("fd69:27cc:fe91::60")) ) + private val dottedTestRecord = RecordSet( + dummyZone.id, + "test.dotted", + AAAA, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AAAAData("fd69:27cc:fe91::60")) + ) private val subTestRecordA = RecordSet( zone.id, "a-record", @@ -255,8 +274,8 @@ class RecordSetServiceIntegrationSpec groupRepo.save(db, group) } - List(group, group2, sharedGroup).traverse(g => saveGroupData(groupRepo, g).void).unsafeRunSync() - List(zone, zoneTestNameConflicts, zoneTestAddRecords, sharedZone) + List(group, group2, sharedGroup, dummyGroup).traverse(g => saveGroupData(groupRepo, g).void).unsafeRunSync() + List(zone, dummyZone, zoneTestNameConflicts, zoneTestAddRecords, sharedZone) .traverse( z => zoneRepo.save(z) ) @@ -274,6 +293,7 @@ class RecordSetServiceIntegrationSpec val zoneRecords = List( apexTestRecordA, apexTestRecordAAAA, + dottedTestRecord, subTestRecordA, subTestRecordAAAA, subTestRecordNS, @@ -301,6 +321,7 @@ class RecordSetServiceIntegrationSpec mockBackendResolver, false, vinyldnsConfig.highValueDomainConfig, + vinyldnsConfig.dottedHostsConfig, vinyldnsConfig.serverConfig.approvedNameServers, useRecordSetCache = true ) @@ -339,6 +360,93 @@ class RecordSetServiceIntegrationSpec .name shouldBe "zone-test-add-records." } + "create dotted record fails if it doesn't satisfy dotted hosts config" in { + val newRecord = RecordSet( + zoneTestAddRecords.id, + "test.dot", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1")) + ) + val result = + testRecordSetService + .addRecordSet(newRecord, auth) + .value + .unsafeRunSync() + leftValue(result) shouldBe a[InvalidRequest] + } + + "create dotted record succeeds if it satisfies all dotted hosts config" in { + val newRecord = RecordSet( + dummyZone.id, + "testing.dotted", + AAAA, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AAAAData("fd69:27cc:fe91::60")) + ) + // succeeds as zone, user and record type is allowed as defined in application.conf + val result = + testRecordSetService + .addRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + rightValue(result) + .asInstanceOf[RecordSetChange] + .recordSet + .name shouldBe "testing.dotted" + } + + "fail creating dotted record if it satisfies all dotted hosts config except dots-limit for the zone" in { + val newRecord = RecordSet( + dummyZone.id, + "test.dotted.more.dots.than.allowed", + AAAA, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AAAAData("fd69:27cc:fe91::60")) + ) + + // The number of dots allowed in the record name for this zone as defined in the config is 3. + // Creating with 4 dots results in an error + val result = + testRecordSetService + .addRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + leftValue(result) shouldBe a[InvalidRequest] + } + + "update dotted record succeeds if it satisfies all dotted hosts config" in { + val newRecord = dottedTestRecord.copy(ttl = 37000) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "test.dotted" + change.recordSet.ttl shouldBe 37000 + } + + "update dotted record name fails as updating a record name is not allowed" in { + val newRecord = dottedTestRecord.copy(name = "trial.dotted") + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + // We get an "InvalidRequest: Cannot update RecordSet's name." + leftValue(result) shouldBe a[InvalidRequest] + } + "update apex A record and add trailing dot" in { val newRecord = apexTestRecordA.copy(ttl = 200) val result = testRecordSetService @@ -550,6 +658,15 @@ class RecordSetServiceIntegrationSpec Some(group2.id) } + "delete dotted host record successfully for user in record owner group" in { + val result = testRecordSetService + .deleteRecordSet(dottedTestRecord.id, dottedTestRecord.zoneId, dummyAuth) + .value + .unsafeRunSync() + + result should be(right) + } + "fail deleting for user not in record owner group in shared zone" in { val result = leftResultOf( testRecordSetService diff --git a/modules/api/src/main/resources/application.conf b/modules/api/src/main/resources/application.conf index a8aa7175c..7407c07e4 100644 --- a/modules/api/src/main/resources/application.conf +++ b/modules/api/src/main/resources/application.conf @@ -165,6 +165,19 @@ vinyldns { "ns1.parent.com4." ] + # approved zones, individual users, users in groups, record types and no.of.dots that are allowed for dotted hosts + dotted-hosts = { + allowed-settings = [ + { + zone = "zonenamehere." + user-list = [] + group-list = [] + record-types = [] + dots-limit = 0 + } + ] + } + # Note: This MUST match the Portal or strange errors will ensue, NoOpCrypto should not be used for production crypto { type = "vinyldns.core.crypto.NoOpCrypto" diff --git a/modules/api/src/main/resources/reference.conf b/modules/api/src/main/resources/reference.conf index b82cb74d6..4d1db46a1 100644 --- a/modules/api/src/main/resources/reference.conf +++ b/modules/api/src/main/resources/reference.conf @@ -90,6 +90,29 @@ vinyldns { "ns1.parent.com." ] + # approved zones, individual users, users in groups, record types and no.of.dots that are allowed for dotted hosts + dotted-hosts = { + # for local testing + allowed-settings = [ + { + # for wildcard zones. Settings will be applied to all matching zones + zone = "*ent.com*." + user-list = ["ok"] + group-list = ["dummy-group"] + record-types = ["CNAME"] + dots-limit = 3 + }, + { + # for wildcard zones. Settings will be applied to all matching zones + zone = "dummy*." + user-list = ["sharedZoneUser"] + group-list = ["history-group1"] + record-types = ["A"] + dots-limit = 3 + } + ] + } + # color should be green or blue, used in order to do blue/green deployment color = "green" @@ -111,7 +134,7 @@ vinyldns { batchchange-routing-max-items-limit = 100 membership-routing-default-max-items = 100 membership-routing-max-items-limit = 1000 - membership-routing-max-groups-list-limit = 1500 + membership-routing-max-groups-list-limit = 3000 recordset-routing-default-max-items= 100 zone-routing-default-max-items = 100 zone-routing-max-items-limit = 100 diff --git a/modules/api/src/main/scala/vinyldns/api/Boot.scala b/modules/api/src/main/scala/vinyldns/api/Boot.scala index 24db9b737..ae4077f49 100644 --- a/modules/api/src/main/scala/vinyldns/api/Boot.scala +++ b/modules/api/src/main/scala/vinyldns/api/Boot.scala @@ -139,6 +139,7 @@ object Boot extends App { backendResolver, vinyldnsConfig.serverConfig.validateRecordLookupAgainstDnsBackend, vinyldnsConfig.highValueDomainConfig, + vinyldnsConfig.dottedHostsConfig, vinyldnsConfig.serverConfig.approvedNameServers, vinyldnsConfig.serverConfig.useRecordSetCache ) diff --git a/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala b/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala index 96e09b8ba..b89d00f04 100644 --- a/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala +++ b/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala @@ -279,7 +279,7 @@ trait DnsConversions { def fromSPFRecord(r: DNS.SPFRecord, zoneName: DNS.Name, zoneId: String): RecordSet = fromDnsRecord(r, zoneName, zoneId) { data => - List(SPFData(data.getStrings.asScala.mkString(","))) + List(SPFData(data.getStrings.asScala.mkString)) } def fromSRVRecord(r: DNS.SRVRecord, zoneName: DNS.Name, zoneId: String): RecordSet = @@ -394,7 +394,8 @@ trait DnsConversions { new DNS.SSHFPRecord(recordName, DNS.DClass.IN, ttl, algorithm, typ, Hex.decodeHex(fingerprint.toCharArray())) case SPFData(text) => - new DNS.SPFRecord(recordName, DNS.DClass.IN, ttl, text) + val texts = text.grouped(255).toList + new DNS.SPFRecord(recordName, DNS.DClass.IN, ttl, texts.asJava) case TXTData(text) => val texts = text.grouped(255).toList diff --git a/modules/api/src/main/scala/vinyldns/api/config/DottedHostsConfig.scala b/modules/api/src/main/scala/vinyldns/api/config/DottedHostsConfig.scala new file mode 100644 index 000000000..a5d1e546d --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/config/DottedHostsConfig.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.config + +import pureconfig.ConfigReader +import pureconfig.generic.auto._ + +final case class ZoneAuthConfigs(zone: String, userList: List[String], groupList: List[String], recordTypes: List[String], dotsLimit: Int) +final case class DottedHostsConfig(zoneAuthConfigs: List[ZoneAuthConfigs]) + +object DottedHostsConfig { + implicit val configReader: ConfigReader[DottedHostsConfig] = + ConfigReader.forProduct1[DottedHostsConfig, List[ZoneAuthConfigs]]( + "allowed-settings", + )(zoneAuthConfigs => + DottedHostsConfig(zoneAuthConfigs)) +} diff --git a/modules/api/src/main/scala/vinyldns/api/config/VinylDNSConfig.scala b/modules/api/src/main/scala/vinyldns/api/config/VinylDNSConfig.scala index abb75c4be..dd41bb3a0 100644 --- a/modules/api/src/main/scala/vinyldns/api/config/VinylDNSConfig.scala +++ b/modules/api/src/main/scala/vinyldns/api/config/VinylDNSConfig.scala @@ -47,6 +47,7 @@ final case class VinylDNSConfig( notifierConfigs: List[NotifierConfig], dataStoreConfigs: List[DataStoreConfig], backendConfigs: BackendConfigs, + dottedHostsConfig: DottedHostsConfig, configuredDnsConnections: ConfiguredDnsConnections, apiMetricSettings: APIMetricsSettings, crypto: CryptoAlgebra, @@ -85,6 +86,7 @@ object VinylDNSConfig { serverConfig <- loadIO[ServerConfig](config, "vinyldns") batchChangeConfig <- loadIO[BatchChangeConfig](config, "vinyldns") backendConfigs <- loadIO[BackendConfigs](config, "vinyldns.backend") + dottedHostsConfig <- loadIO[DottedHostsConfig](config, "vinyldns.dotted-hosts") httpConfig <- loadIO[HttpConfig](config, "vinyldns.rest") hvdConfig <- loadIO[HighValueDomainConfig](config, "vinyldns.high-value-domains") scheduledChangesConfig <- loadIO[ScheduledChangesConfig](config, "vinyldns") @@ -110,6 +112,7 @@ object VinylDNSConfig { notifierConfigs, dataStoreConfigs, backendConfigs, + dottedHostsConfig, connections, metricSettings, crypto, 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 459285267..5a12eb74b 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala @@ -27,6 +27,10 @@ import scala.util.matching.Regex Object to house common domain validations */ 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 = @@ -60,6 +64,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): @@ -85,6 +113,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/BatchChangeConverter.scala b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala index ac8921c20..e4fd32b22 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala @@ -28,12 +28,15 @@ import vinyldns.api.domain.record.RecordSetChangeGenerator import vinyldns.core.domain.record._ import vinyldns.core.domain.zone.Zone import vinyldns.core.domain.batch._ -import vinyldns.core.domain.record.RecordType.RecordType +import vinyldns.core.domain.record.RecordType.{RecordType, UNKNOWN} import vinyldns.core.queue.MessageQueue class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: MessageQueue) extends BatchChangeConverterAlgebra { + private val notExistCompletedMessage: String = "This record does not exist." + + "No further action is required." + private val failedMessage: String = "Error queueing RecordSetChange for processing" private val logger = LoggerFactory.getLogger(classOf[BatchChangeConverter]) def sendBatchForProcessing( @@ -68,15 +71,20 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: recordSetChanges: List[RecordSetChange] ): BatchResult[Unit] = { val convertedIds = recordSetChanges.flatMap(_.singleBatchChangeIds).toSet - singleChanges.find(ch => !convertedIds.contains(ch.id)) match { - case Some(change) => BatchConversionError(change).toLeftBatchResult - case None => + // Each single change has a corresponding recordset id + // If they're not equal, then there's a delete request for a record that doesn't exist. So we allow this to process + case Some(_) if singleChanges.map(_.id).length != recordSetChanges.map(_.id).length && !singleChanges.map(_.typ).contains(UNKNOWN) => logger.info(s"Successfully converted SingleChanges [${singleChanges .map(_.id)}] to RecordSetChanges [${recordSetChanges.map(_.id)}]") ().toRightBatchResult + case Some(change) => BatchConversionError(change).toLeftBatchResult + case None => + logger.info(s"Successfully converted SingleChanges [${singleChanges + .map(_.id)}] to RecordSetChanges [${recordSetChanges.map(_.id)}]") + ().toRightBatchResult + } } - } def putChangesOnQueue( recordSetChanges: List[RecordSetChange], @@ -105,7 +113,6 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: val idsMap = recordSetChanges.flatMap { rsChange => rsChange.singleBatchChangeIds.map(batchId => (batchId, rsChange.id)) }.toMap - val withStatus = batchChange.changes.map { change => idsMap .get(change.id) @@ -114,19 +121,26 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: change } .getOrElse { - // failure here means there was a message queue issue for this change - change.withFailureMessage("Error queueing RecordSetChange for processing") + // Match and check if it's a delete change for a record that doesn't exists. + change match { + case _: SingleDeleteRRSetChange if change.recordSetId.isEmpty => + // Mark as Complete since we don't want to throw it as an error + change.withDoesNotExistMessage(notExistCompletedMessage) + case _ => + // Failure here means there was a message queue issue for this change + change.withFailureMessage(failedMessage) + } } } - batchChange.copy(changes = withStatus) } def storeQueuingFailures(batchChange: BatchChange): BatchResult[Unit] = { - val failedChanges = batchChange.changes.collect { - case change if change.status == SingleChangeStatus.Failed => change + // Update if Single change is Failed or if a record that does not exist is deleted + val failedAndNotExistsChanges = batchChange.changes.collect { + case change if change.status == SingleChangeStatus.Failed || change.systemMessage.contains(notExistCompletedMessage) => change } - batchChangeRepo.updateSingleChanges(failedChanges).as(()) + batchChangeRepo.updateSingleChanges(failedAndNotExistsChanges).as(()) }.toBatchResult def createRecordSetChangesForBatch( diff --git a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeService.scala b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeService.scala index 01fe34e36..a6746d640 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeService.scala @@ -33,19 +33,8 @@ import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.batch.BatchChangeApprovalStatus.BatchChangeApprovalStatus import vinyldns.core.domain.batch._ import vinyldns.core.domain.batch.BatchChangeApprovalStatus._ -import vinyldns.core.domain.{ - CnameAtZoneApexError, - SingleChangeError, - UserIsNotAuthorizedError, - ZoneDiscoveryError -} -import vinyldns.core.domain.membership.{ - Group, - GroupRepository, - ListUsersResults, - User, - UserRepository -} +import vinyldns.core.domain.{CnameAtZoneApexError, SingleChangeError, UserIsNotAuthorizedError, ZoneDiscoveryError} +import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository} import vinyldns.core.domain.record.RecordType._ import vinyldns.core.domain.record.RecordSetRepository import vinyldns.core.domain.zone.ZoneRepository 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 859e132ab..d9e0bce97 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 @@ -19,15 +19,9 @@ package vinyldns.api.domain.batch import java.net.InetAddress import java.time.Instant import java.time.temporal.ChronoUnit - import cats.data._ import cats.implicits._ -import vinyldns.api.config.{ - BatchChangeConfig, - HighValueDomainConfig, - ManualReviewConfig, - ScheduledChangesConfig -} +import vinyldns.api.config.{BatchChangeConfig, HighValueDomainConfig, ManualReviewConfig, ScheduledChangesConfig} import vinyldns.api.domain.DomainValidations._ import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.core.domain.auth.AuthPrincipal @@ -36,7 +30,7 @@ import vinyldns.api.domain.batch.BatchTransformations._ import vinyldns.api.domain.zone.ZoneRecordValidations import vinyldns.core.domain.record._ import vinyldns.core.domain._ -import vinyldns.core.domain.batch.{BatchChange, BatchChangeApprovalStatus, OwnerType, RecordKey} +import vinyldns.core.domain.batch.{BatchChange, BatchChangeApprovalStatus, OwnerType, RecordKey, RecordKeyData} import vinyldns.core.domain.membership.Group trait BatchChangeValidationsAlgebra { @@ -53,10 +47,10 @@ trait BatchChangeValidationsAlgebra { ): ValidatedBatch[ChangeInput] def validateChangesWithContext( - groupedChanges: ChangeForValidationMap, - auth: AuthPrincipal, - isApproved: Boolean, - batchOwnerGroupId: Option[String] + groupedChanges: ChangeForValidationMap, + auth: AuthPrincipal, + isApproved: Boolean, + batchOwnerGroupId: Option[String] ): ValidatedBatch[ChangeForValidation] def canGetBatchChange( @@ -213,7 +207,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 @@ -224,7 +218,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) @@ -232,11 +226,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 => validateHostName(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 => @@ -274,17 +275,17 @@ class BatchChangeValidations( /* context validations */ def validateChangesWithContext( - groupedChanges: ChangeForValidationMap, - auth: AuthPrincipal, - isApproved: Boolean, - batchOwnerGroupId: Option[String] + groupedChanges: ChangeForValidationMap, + auth: AuthPrincipal, + isApproved: Boolean, + batchOwnerGroupId: Option[String] ): ValidatedBatch[ChangeForValidation] = - // Updates are a combination of an add and delete for a record with the same name and type in a zone. + // Updates are a combination of an add and delete for a record with the same name and type in a zone. groupedChanges.changes.mapValid { case add: AddChangeForValidation - if groupedChanges - .getLogicalChangeType(add.recordKey) - .contains(LogicalChangeType.Add) => + if groupedChanges + .getLogicalChangeType(add.recordKey) + .contains(LogicalChangeType.Add) => validateAddWithContext(add, groupedChanges, auth, isApproved, batchOwnerGroupId) case addUpdate: AddChangeForValidation => validateAddUpdateWithContext(addUpdate, groupedChanges, auth, isApproved, batchOwnerGroupId) @@ -349,7 +350,7 @@ class BatchChangeValidations( userCanDeleteRecordSet(change, auth, rs.ownerGroupId, rs.records) |+| zoneDoesNotRequireManualReview(change, isApproved) |+| ensureRecordExists(change, groupedChanges) - case None => RecordDoesNotExist(change.inputChange.inputName).invalidNel + case None => RecordDoesNotExist(change.inputChange.inputName).validNel } validations.map(_ => change) } @@ -403,18 +404,18 @@ class BatchChangeValidations( zoneDoesNotRequireManualReview(change, isApproved) |+| ensureRecordExists(change, groupedChanges) case None => - RecordDoesNotExist(change.inputChange.inputName).invalidNel + RecordDoesNotExist(change.inputChange.inputName).validNel } validations.map(_ => change) } def validateAddWithContext( - change: AddChangeForValidation, - groupedChanges: ChangeForValidationMap, - auth: AuthPrincipal, - isApproved: Boolean, - ownerGroupId: Option[String] + change: AddChangeForValidation, + groupedChanges: ChangeForValidationMap, + auth: AuthPrincipal, + isApproved: Boolean, + ownerGroupId: Option[String] ): SingleValidation[ChangeForValidation] = { val typedValidations = change.inputChange.typ match { case A | AAAA | MX => @@ -437,11 +438,12 @@ class BatchChangeValidations( change.recordName, change.inputChange.inputName, change.inputChange.typ, - groupedChanges + change.inputChange.record, + groupedChanges, + isApproved ) |+| ownerGroupProvidedIfNeeded(change, None, ownerGroupId) |+| zoneDoesNotRequireManualReview(change, isApproved) - validations.map(_ => change) } @@ -479,11 +481,16 @@ class BatchChangeValidations( recordName: String, inputName: String, typ: RecordType, - groupedChanges: ChangeForValidationMap - ): SingleValidation[Unit] = - groupedChanges.getExistingRecordSet(RecordKey(zoneId, recordName, typ)) match { - case Some(_) => RecordAlreadyExists(inputName).invalidNel - case None => ().validNel + recordData: RecordData, + groupedChanges: ChangeForValidationMap, + isApproved: Boolean + ): SingleValidation[Unit] = { + val record = groupedChanges.getExistingRecordSetData(RecordKeyData(zoneId, recordName, typ, recordData)) + if(record.isDefined) { + record.get.records.contains(recordData) match { + case true => ().validNel + case false => RecordAlreadyExists(inputName, recordData, isApproved).invalidNel} + } else ().validNel } def noIncompatibleRecordExists( diff --git a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchTransformations.scala b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchTransformations.scala index a0345954a..5afa5da02 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchTransformations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchTransformations.scala @@ -68,6 +68,9 @@ object BatchTransformations { def get(recordKey: RecordKey): Option[RecordSet] = get(recordKey.zoneId, recordKey.recordName, recordKey.recordType) + def get(recordKeyData: RecordKeyData): Option[RecordSet] = + get(recordKeyData.zoneId, recordKeyData.recordName, recordKeyData.recordType) + def getRecordSetMatch(zoneId: String, name: String): List[RecordSet] = recordSetMap.getOrElse((zoneId, name.toLowerCase), List()) } @@ -171,6 +174,9 @@ object BatchTransformations { def getExistingRecordSet(recordKey: RecordKey): Option[RecordSet] = existingRecordSets.get(recordKey) + def getExistingRecordSetData(recordKeyData: RecordKeyData): Option[RecordSet] = + existingRecordSets.get(recordKeyData) + def getProposedAdds(recordKey: RecordKey): Set[RecordData] = innerMap.get(recordKey).map(_.proposedAdds).toSet.flatten 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 7fd261586..5f7951085 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 @@ -60,7 +60,9 @@ final case class GroupChangeInfo( userId: String, oldGroup: Option[GroupInfo] = None, id: String = UUID.randomUUID().toString, - created: Instant = Instant.now.truncatedTo(ChronoUnit.MILLIS) + created: Instant = Instant.now.truncatedTo(ChronoUnit.MILLIS), + userName: String, + groupChangeMessage: String ) object GroupChangeInfo { @@ -70,7 +72,9 @@ object GroupChangeInfo { userId = groupChange.userId, oldGroup = groupChange.oldGroup.map(GroupInfo.apply), id = groupChange.id, - created = groupChange.created + created = groupChange.created, + userName = groupChange.userName.getOrElse("unknown user"), + groupChangeMessage = groupChange.groupChangeMessage.getOrElse("") ) } @@ -172,6 +176,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..08e7cc240 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 @@ -214,12 +216,18 @@ class MembershipService( ): ListMyGroupsResponse = { val allMyGroups = allGroups .filter(_.status == GroupStatus.Active) - .sortBy(_.id) + .sortBy(_.name.toLowerCase) .map(x => GroupInfo.fromGroup(x, abridged, Some(authPrincipal))) - val filtered = allMyGroups - .filter(grp => groupNameFilter.forall(grp.name.contains(_))) - .filter(grp => startFrom.forall(grp.id > _)) + val filtered = if(startFrom.isDefined){ + val prevPageGroup = allMyGroups.filter(_.id == startFrom.get).head.name + allMyGroups + .filter(grp => groupNameFilter.map(_.toLowerCase).forall(grp.name.toLowerCase.contains(_))) + .filter(grp => grp.name.toLowerCase > prevPageGroup.toLowerCase) + } else { + allMyGroups + .filter(grp => groupNameFilter.map(_.toLowerCase).forall(grp.name.toLowerCase.contains(_))) + } val nextId = if (filtered.length > maxItems) Some(filtered(maxItems - 1).id) else None val groups = filtered.take(maxItems) @@ -227,6 +235,23 @@ class MembershipService( ListMyGroupsResponse(groups, groupNameFilter, startFrom, nextId, maxItems, ignoreAccess) } + def getGroupChange( + groupChangeId: String, + authPrincipal: AuthPrincipal + ): Result[GroupChangeInfo] = + for { + result <- groupChangeRepo + .getGroupChange(groupChangeId) + .toResult[Option[GroupChange]] + _ <- isGroupChangePresent(result).toResult + _ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult + groupChangeMessage <- determineGroupDifference(Seq(result.get)) + groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } + userIds = Seq(result.get).map(_.userId).toSet + users <- getUsers(userIds).map(_.users) + userMap = users.map(u => (u.id, u.userName)).toMap + } yield groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))).head + def getGroupActivity( groupId: String, startFrom: Option[String], @@ -238,13 +263,65 @@ class MembershipService( result <- groupChangeRepo .getGroupChanges(groupId, startFrom, maxItems) .toResult[ListGroupChangesResults] + groupChangeMessage <- determineGroupDifference(result.changes) + groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } + userIds = result.changes.map(_.userId).toSet + users <- getUsers(userIds).map(_.users) + userMap = users.map(u => (u.id, u.userName)).toMap } yield ListGroupChangesResponse( - result.changes.map(GroupChangeInfo.apply), + groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))), startFrom, result.lastEvaluatedTimeStamp, maxItems ) + def determineGroupDifference(groupChange: Seq[GroupChange]): Result[Seq[String]] = { + var groupChangeMessage: Seq[String] = Seq.empty[String] + + for (change <- groupChange) { + val sb = new StringBuilder + if (change.oldGroup.isDefined) { + if (change.oldGroup.get.name != change.newGroup.name) { + sb.append(s"Group name changed to '${change.newGroup.name}'. ") + } + if (change.oldGroup.get.email != change.newGroup.email) { + sb.append(s"Group email changed to '${change.newGroup.email}'. ") + } + if (change.oldGroup.get.description != change.newGroup.description) { + sb.append(s"Group description changed to '${change.newGroup.description.get}'. ") + } + val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds) + if (adminAddDifference.nonEmpty) { + sb.append(s"Group admin/s with userId/s (${adminAddDifference.mkString(",")}) added. ") + } + val adminRemoveDifference = change.oldGroup.get.adminUserIds.diff(change.newGroup.adminUserIds) + if (adminRemoveDifference.nonEmpty) { + sb.append(s"Group admin/s with userId/s (${adminRemoveDifference.mkString(",")}) removed. ") + } + val memberAddDifference = change.newGroup.memberIds.diff(change.oldGroup.get.memberIds) + if (memberAddDifference.nonEmpty) { + sb.append(s"Group member/s with userId/s (${memberAddDifference.mkString(",")}) added. ") + } + val memberRemoveDifference = change.oldGroup.get.memberIds.diff(change.newGroup.memberIds) + if (memberRemoveDifference.nonEmpty) { + sb.append(s"Group member/s with userId/s (${memberRemoveDifference.mkString(",")}) removed. ") + } + groupChangeMessage = groupChangeMessage :+ sb.toString().trim + } + // It'll be in else statement if the group was created or deleted + else { + if (change.changeType == GroupChangeType.Create) { + sb.append("Group Created.") + } + else if (change.changeType == GroupChangeType.Delete){ + sb.append("Group Deleted.") + } + groupChangeMessage = groupChangeMessage :+ sb.toString() + } + } + groupChangeMessage + }.toResult + /** * Retrieves the requested User from the given userIdentifier, which can be a userId or username * @param userIdentifier The userId or username @@ -277,6 +354,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/domain/membership/MembershipServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala index 5fb2f737b..c71d62c2a 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala @@ -39,6 +39,8 @@ trait MembershipServiceAlgebra { def getGroup(id: String, authPrincipal: AuthPrincipal): Result[Group] + def getGroupChange(id: String, authPrincipal: AuthPrincipal): Result[GroupChangeInfo] + def listMyGroups( groupNameFilter: Option[String], startFrom: Option[String], diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipValidations.scala index cccd1d5e2..898caff91 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipValidations.scala @@ -19,7 +19,7 @@ package vinyldns.api.domain.membership import vinyldns.api.Interfaces.ensuring import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.api.domain.zone.NotAuthorizedError -import vinyldns.core.domain.membership.Group +import vinyldns.core.domain.membership.{Group, GroupChange} object MembershipValidations { @@ -44,4 +44,9 @@ object MembershipValidations { ensuring(NotAuthorizedError("Not authorized")) { authPrincipal.isGroupMember(groupId) || authPrincipal.isSystemAdmin || canViewGroupDetails } + + def isGroupChangePresent(groupChange: Option[GroupChange]): Either[Throwable, Unit] = + ensuring(InvalidGroupRequestError("Invalid Group Change ID")) { + groupChange.isDefined + } } diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala index cc4f18331..67f3a0ac1 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala @@ -28,7 +28,7 @@ import vinyldns.core.queue.MessageQueue import cats.data._ import cats.effect.IO import org.xbill.DNS.ReverseMap -import vinyldns.api.config.HighValueDomainConfig +import vinyldns.api.config.{ZoneAuthConfigs, DottedHostsConfig, HighValueDomainConfig} import vinyldns.api.domain.DomainValidations.{validateIpv4Address, validateIpv6Address} import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.core.domain.record.NameSort.NameSort @@ -46,6 +46,7 @@ object RecordSetService { backendResolver: BackendResolver, validateRecordLookupAgainstDnsBackend: Boolean, highValueDomainConfig: HighValueDomainConfig, + dottedHostsConfig: DottedHostsConfig, approvedNameServers: List[Regex], useRecordSetCache: Boolean ): RecordSetService = @@ -61,6 +62,7 @@ object RecordSetService { backendResolver, validateRecordLookupAgainstDnsBackend, highValueDomainConfig, + dottedHostsConfig, approvedNameServers, useRecordSetCache ) @@ -78,6 +80,7 @@ class RecordSetService( backendResolver: BackendResolver, validateRecordLookupAgainstDnsBackend: Boolean, highValueDomainConfig: HighValueDomainConfig, + dottedHostsConfig: DottedHostsConfig, approvedNameServers: List[Regex], useRecordSetCache: Boolean ) extends RecordSetServiceAlgebra { @@ -88,6 +91,7 @@ class RecordSetService( def addRecordSet(recordSet: RecordSet, auth: AuthPrincipal): Result[ZoneCommandResult] = for { zone <- getZone(recordSet.zoneId) + authZones = dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) change <- RecordSetChangeGenerator.forAdd(recordSet, zone, Some(auth)).toResult // because changes happen to the RS in forAdd itself, converting 1st and validating on that rsForValidations = change.recordSet @@ -107,13 +111,27 @@ class RecordSetService( ownerGroup <- getGroupIfProvided(rsForValidations.ownerGroupId) _ <- canUseOwnerGroup(rsForValidations.ownerGroupId, ownerGroup, auth).toResult _ <- noCnameWithNewName(rsForValidations, existingRecordsWithName, zone).toResult + allowedZoneList <- getAllowedZones(authZones).toResult[Set[String]] + isInAllowedUsers = checkIfInAllowedUsers(zone, dottedHostsConfig, auth) + isUserInAllowedGroups <- checkIfInAllowedGroups(zone, dottedHostsConfig, auth).toResult[Boolean] + isAllowedUser = isInAllowedUsers || isUserInAllowedGroups + isRecordTypeAllowed = checkIfInAllowedRecordType(zone, dottedHostsConfig, rsForValidations) + isRecordTypeAndUserAllowed = isAllowedUser && isRecordTypeAllowed + allowedDotsLimit = getAllowedDotsLimit(zone, dottedHostsConfig) + recordFqdnDoesNotAlreadyExist <- recordFQDNDoesNotExist(rsForValidations, zone).toResult[Boolean] _ <- typeSpecificValidations( rsForValidations, existingRecordsWithName, zone, None, - approvedNameServers + approvedNameServers, + recordFqdnDoesNotAlreadyExist, + allowedZoneList, + isRecordTypeAndUserAllowed, + allowedDotsLimit ).toResult + _ <- if(allowedZoneList.contains(zone.name)) checkAllowedDots(allowedDotsLimit, rsForValidations, zone).toResult else ().toResult + _ <- if(allowedZoneList.contains(zone.name)) isNotApexEndsWithDot(rsForValidations, zone).toResult else ().toResult _ <- messageQueue.send(change).toResult[Unit] } yield change @@ -143,13 +161,27 @@ class RecordSetService( validateRecordLookupAgainstDnsBackend ) _ <- noCnameWithNewName(rsForValidations, existingRecordsWithName, zone).toResult + authZones = dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + allowedZoneList <- getAllowedZones(authZones).toResult[Set[String]] + isInAllowedUsers = checkIfInAllowedUsers(zone, dottedHostsConfig, auth) + isUserInAllowedGroups <- checkIfInAllowedGroups(zone, dottedHostsConfig, auth).toResult[Boolean] + isAllowedUser = isInAllowedUsers || isUserInAllowedGroups + isRecordTypeAllowed = checkIfInAllowedRecordType(zone, dottedHostsConfig, rsForValidations) + isRecordTypeAndUserAllowed = isAllowedUser && isRecordTypeAllowed + allowedDotsLimit = getAllowedDotsLimit(zone, dottedHostsConfig) _ <- typeSpecificValidations( rsForValidations, existingRecordsWithName, zone, Some(existing), - approvedNameServers + approvedNameServers, + true, + allowedZoneList, + isRecordTypeAndUserAllowed, + allowedDotsLimit ).toResult + _ <- if(existing.name == rsForValidations.name) ().toResult else if(allowedZoneList.contains(zone.name)) checkAllowedDots(allowedDotsLimit, rsForValidations, zone).toResult else ().toResult + _ <- if(allowedZoneList.contains(zone.name)) isNotApexEndsWithDot(rsForValidations, zone).toResult else ().toResult _ <- messageQueue.send(change).toResult[Unit] } yield change @@ -169,6 +201,178 @@ class RecordSetService( _ <- messageQueue.send(change).toResult[Unit] } yield change + // For dotted hosts. Check if a record that may conflict with dotted host exist or not + def recordFQDNDoesNotExist(newRecordSet: RecordSet, zone: Zone): IO[Boolean] = { + // Use fqdn for searching through `recordset` mysql table to see if it already exist + val newRecordFqdn = if(newRecordSet.name != zone.name) newRecordSet.name + "." + zone.name else newRecordSet.name + + for { + record <- recordSetRepository.getRecordSetsByFQDNs(Set(newRecordFqdn)) + isRecordAlreadyExist = doesRecordWithSameTypeExist(record, newRecordSet) + doesNotExist = if(isRecordAlreadyExist) false else true + } yield doesNotExist + } + + // Check if a record with same type already exist in 'recordset' mysql table + def doesRecordWithSameTypeExist(oldRecord: List[RecordSet], newRecord: RecordSet): Boolean = { + if(oldRecord.nonEmpty) { + val typeExists = oldRecord.map(x => x.typ == newRecord.typ) + if (typeExists.contains(true)) true else false + } + else { + false + } + } + + // Get zones that are allowed to create dotted hosts using the zones present in dotted hosts config + def getAllowedZones(zones: List[String]): IO[Set[String]] = { + if(zones.isEmpty){ + val noZones: IO[Set[String]] = IO(Set.empty) + noZones + } + else { + // Wildcard zones needs to be passed to a separate method + val wildcardZones = zones.filter(_.contains("*")).map(_.replace("*", "%")) + // Zones without wildcard character are passed to a separate function + val namedZones = zones.filter(zone => !zone.contains("*")) + for{ + namedZoneResult <- zoneRepository.getZonesByNames(namedZones.toSet) + wildcardZoneResult <- zoneRepository.getZonesByFilters(wildcardZones.toSet) + zoneResult = namedZoneResult ++ wildcardZoneResult // Combine the zones + } yield zoneResult.map(x => x.name) + } + } + + // Check if user is allowed to create dotted hosts using the users present in dotted hosts config + def getAllowedDotsLimit(zone: Zone, config: DottedHostsConfig): Int = { + val configZones = config.zoneAuthConfigs.map(x => x.zone) + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + val dottedZoneConfig = configZones.filter(_.contains("*")).map(_.replace("*", "[A-Za-z0-9.]*")) + val isContainWildcardZone = dottedZoneConfig.exists(x => zoneName.matches(x)) + val isContainNormalZone = configZones.contains(zoneName) + if(isContainNormalZone){ + config.zoneAuthConfigs.filter(x => x.zone == zoneName).head.dotsLimit + } + else if(isContainWildcardZone){ + config.zoneAuthConfigs.filter(x => zoneName.matches(x.zone.replace("*", "[A-Za-z0-9.]*"))).head.dotsLimit + } + else { + 0 + } + } + + // Check if user is allowed to create dotted hosts using the users present in dotted hosts config + def checkIfInAllowedUsers(zone: Zone, config: DottedHostsConfig, auth: AuthPrincipal): Boolean = { + val configZones = config.zoneAuthConfigs.map(x => x.zone) + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + val dottedZoneConfig = configZones.filter(_.contains("*")).map(_.replace("*", "[A-Za-z0-9.]*")) + val isContainWildcardZone = dottedZoneConfig.exists(x => zoneName.matches(x)) + val isContainNormalZone = configZones.contains(zoneName) + if(isContainNormalZone){ + val users = config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone == zoneName) x.userList else List.empty + } + if(users.contains(auth.signedInUser.userName)){ + true + } + else { + false + } + } + else if(isContainWildcardZone){ + val users = config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone.contains("*")) { + val wildcardZone = x.zone.replace("*", "[A-Za-z0-9.]*") + if (zoneName.matches(wildcardZone)) x.userList else List.empty + } else List.empty + } + if(users.contains(auth.signedInUser.userName)){ + true + } + else { + false + } + } + else { + false + } + } + + // Check if user is allowed to create dotted hosts using the record types present in dotted hosts config + def checkIfInAllowedRecordType(zone: Zone, config: DottedHostsConfig, rs: RecordSet): Boolean = { + val configZones = config.zoneAuthConfigs.map(x => x.zone) + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + val dottedZoneConfig = configZones.filter(_.contains("*")).map(_.replace("*", "[A-Za-z0-9.]*")) + val isContainWildcardZone = dottedZoneConfig.exists(x => zoneName.matches(x)) + val isContainNormalZone = configZones.contains(zoneName) + if(isContainNormalZone){ + val rType = config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone == zoneName) x.recordTypes else List.empty + } + if(rType.contains(rs.typ.toString)){ + true + } + else { + false + } + } + else if(isContainWildcardZone){ + val rType = config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone.contains("*")) { + val wildcardZone = x.zone.replace("*", "[A-Za-z0-9.]*") + if (zoneName.matches(wildcardZone)) x.recordTypes else List.empty + } else List.empty + } + if(rType.contains(rs.typ.toString)){ + true + } + else { + false + } + } + else { + false + } + } + + // Check if user is allowed to create dotted hosts using the groups present in dotted hosts config + def checkIfInAllowedGroups(zone: Zone, config: DottedHostsConfig, auth: AuthPrincipal): IO[Boolean] = { + val configZones = config.zoneAuthConfigs.map(x => x.zone) + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + val dottedZoneConfig = configZones.filter(_.contains("*")).map(_.replace("*", "[A-Za-z0-9.]*")) + val isContainWildcardZone = dottedZoneConfig.exists(x => zoneName.matches(x)) + val isContainNormalZone = configZones.contains(zoneName) + val groups = if(isContainNormalZone){ + config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone == zoneName) x.groupList else List.empty + } + } + else if(isContainWildcardZone){ + config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone.contains("*")) { + val wildcardZone = x.zone.replace("*", "[A-Za-z0-9.]*") + if (zoneName.matches(wildcardZone)) x.groupList else List.empty + } else List.empty + } + } + else { + List.empty + } + for{ + groupsInConfig <- groupRepository.getGroupsByName(groups.toSet) + members = groupsInConfig.flatMap(x => x.memberIds) + usersList <- if(members.isEmpty) IO(Seq.empty) else userRepository.getUsers(members, None, None).map(x => x.users) + users = if(usersList.isEmpty) Seq.empty else usersList.map(x => x.userName) + isPresent = users.contains(auth.signedInUser.userName) + } yield isPresent + } + def getRecordSet( recordSetId: String, authPrincipal: AuthPrincipal diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala index 0bff5984a..cbbd35b60 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala @@ -26,7 +26,7 @@ import vinyldns.core.domain.record.RecordType._ import vinyldns.api.domain.zone._ import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.membership.Group -import vinyldns.core.domain.record.{RecordType, RecordSet} +import vinyldns.core.domain.record.{RecordSet, RecordType} import vinyldns.core.domain.zone.Zone import vinyldns.core.Messages._ @@ -90,6 +90,69 @@ object RecordSetValidations { !existingRecordsWithName.exists(rs => rs.id != newRecordSet.id && rs.typ == newRecordSet.typ) ) + // Check whether the record has dot or not + def checkForDot( + newRecordSet: RecordSet, + zone: Zone, + existingRecordSet: Option[RecordSet] = None, + recordFqdnDoesNotExist: Boolean, + dottedHostZoneConfig: Set[String], + isRecordTypeAndUserAllowed: Boolean, + allowedDotsLimit: Int = 0 + ): Either[Throwable, Unit] = { + + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + // Check if the zone of the recordset is present in dotted hosts config list + val isDomainAllowed = dottedHostZoneConfig.contains(zoneName) + + // Check if record set contains dot and if it is in zone which is allowed to have dotted records from dotted hosts config + if(allowedDotsLimit != 0 && newRecordSet.name.contains(".") && isDomainAllowed && newRecordSet.name != zone.name) { + if(!isRecordTypeAndUserAllowed){ + isUserAndRecordTypeAuthorized(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, isRecordTypeAndUserAllowed) + } + else { + isDotted(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, isRecordTypeAndUserAllowed) + } + } + else { + isNotDotted(newRecordSet, zone, existingRecordSet) + } + } + + // For dotted host. Check if a record is already present which conflicts with the new dotted record. If so, throw an error + def isDotted( + newRecordSet: RecordSet, + zone: Zone, + existingRecordSet: Option[RecordSet] = None, + recordFqdnDoesNotExist: Boolean, + isRecordTypeAndUserAllowed: Boolean + ): Either[Throwable, Unit] = + ensuring( + InvalidRequest( + s"Record with fqdn '${newRecordSet.name}.${zone.name}' cannot be created. " + + s"Please check if a record with the same FQDN and type already exist and make the change there." + ) + )( + (newRecordSet.name != zone.name || existingRecordSet.exists(_.name == newRecordSet.name)) && recordFqdnDoesNotExist && isRecordTypeAndUserAllowed + ) + + // For dotted host. Check if the user is authorized and the record type is allowed. If not, throw an error + def isUserAndRecordTypeAuthorized( + newRecordSet: RecordSet, + zone: Zone, + existingRecordSet: Option[RecordSet] = None, + recordFqdnDoesNotExist: Boolean, + isRecordTypeAndUserAllowed: Boolean + ): Either[Throwable, Unit] = + ensuring( + InvalidRequest( + s"Record type is not allowed or the user is not authorized to create a dotted host in the zone '${zone.name}'" + ) + )( + (newRecordSet.name != zone.name || existingRecordSet.exists(_.name == newRecordSet.name)) && recordFqdnDoesNotExist && isRecordTypeAndUserAllowed + ) + + // Check if the recordset contains dot but is not in the allowed zones to create dotted records. If so, throw an error def isNotDotted( newRecordSet: RecordSet, zone: Zone, @@ -110,16 +173,20 @@ object RecordSetValidations { existingRecordsWithName: List[RecordSet], zone: Zone, existingRecordSet: Option[RecordSet], - approvedNameServers: List[Regex] + approvedNameServers: List[Regex], + recordFqdnDoesNotExist: Boolean, + dottedHostZoneConfig: Set[String], + isRecordTypeAndUserAllowed: Boolean, + allowedDotsLimit: Int = 0 ): Either[Throwable, Unit] = newRecordSet.typ match { - case CNAME => cnameValidations(newRecordSet, existingRecordsWithName, zone, existingRecordSet) - case NS => nsValidations(newRecordSet, zone, existingRecordSet, approvedNameServers) - case SOA => soaValidations(newRecordSet, zone) + case CNAME => cnameValidations(newRecordSet, existingRecordsWithName, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) + case NS => nsValidations(newRecordSet, zone, existingRecordSet, approvedNameServers, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) + case SOA => soaValidations(newRecordSet, zone, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) case PTR => ptrValidations(newRecordSet, zone) case SRV | TXT | NAPTR => ().asRight // SRV, TXT and NAPTR do not go through dotted host check - case DS => dsValidations(newRecordSet, existingRecordsWithName, zone) - case _ => isNotDotted(newRecordSet, zone, existingRecordSet) + case DS => dsValidations(newRecordSet, existingRecordsWithName, zone, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) + case _ => checkForDot(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) } def typeSpecificDeleteValidations(recordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = @@ -140,7 +207,11 @@ object RecordSetValidations { newRecordSet: RecordSet, existingRecordsWithName: List[RecordSet], zone: Zone, - existingRecordSet: Option[RecordSet] = None + existingRecordSet: Option[RecordSet] = None, + recordFqdnDoesNotExist: Boolean, + dottedHostZoneConfig: Set[String], + isRecordTypeAndUserAllowed: Boolean, + allowedDotsLimit: Int = 0 ): Either[Throwable, Unit] = { // cannot create a cname record if a record with the same exists val noRecordWithName = { @@ -173,7 +244,7 @@ object RecordSetValidations { ) _ <- noRecordWithName _ <- RDataWithConsecutiveDots - _ <- isNotDotted(newRecordSet, zone, existingRecordSet) + _ <- checkForDot(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) } yield () } @@ -181,7 +252,11 @@ object RecordSetValidations { def dsValidations( newRecordSet: RecordSet, existingRecordsWithName: List[RecordSet], - zone: Zone + zone: Zone, + recordFqdnDoesNotExist: Boolean, + dottedHostZoneConfig: Set[String], + isRecordTypeAndUserAllowed: Boolean, + allowedDotsLimit: Int = 0 ): Either[Throwable, Unit] = { // see https://tools.ietf.org/html/rfc4035#section-2.4 val nsChecks = existingRecordsWithName.find(_.typ == NS) match { @@ -194,7 +269,7 @@ object RecordSetValidations { } for { - _ <- isNotDotted(newRecordSet, zone) + _ <- checkForDot(newRecordSet, zone, None, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) _ <- isNotOrigin( newRecordSet, zone, @@ -208,10 +283,14 @@ object RecordSetValidations { newRecordSet: RecordSet, zone: Zone, oldRecordSet: Option[RecordSet], - approvedNameServers: List[Regex] + approvedNameServers: List[Regex], + recordFqdnDoesNotExist: Boolean, + dottedHostZoneConfig: Set[String], + isRecordTypeAndUserAllowed: Boolean, + allowedDotsLimit: Int = 0 ): Either[Throwable, Unit] = { // TODO kept consistency with old validation. Not sure why NS could be dotted in reverse specifically - val isNotDottedHost = if (!zone.isReverse) isNotDotted(newRecordSet, zone) else ().asRight + val isNotDottedHost = if (!zone.isReverse) checkForDot(newRecordSet, zone, None, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) else ().asRight for { _ <- isNotDottedHost @@ -233,9 +312,9 @@ object RecordSetValidations { } yield () } - def soaValidations(newRecordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = + def soaValidations(newRecordSet: RecordSet, zone: Zone, recordFqdnDoesNotExist: Boolean, dottedHostZoneConfig: Set[String], isRecordTypeAndUserAllowed: Boolean, allowedDotsLimit: Int = 0): Either[Throwable, Unit] = // TODO kept consistency with old validation. in theory if SOA always == zone name, no special case is needed here - if (!zone.isReverse) isNotDotted(newRecordSet, zone) else ().asRight + if (!zone.isReverse) checkForDot(newRecordSet, zone, None, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit) else ().asRight def ptrValidations(newRecordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = // TODO we don't check for PTR as dotted...not sure why @@ -278,6 +357,29 @@ object RecordSetValidations { .leftMap(errors => InvalidRequest(errors.toList.map(_.message).mkString(", "))) } + def checkAllowedDots(allowedDotsLimit: Int, recordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = { + ensuring( + InvalidRequest( + s"RecordSet with name ${recordSet.name} has more dots than that is allowed in config for this zone " + + s"which is, 'dots-limit = $allowedDotsLimit'." + ) + )( + recordSet.name.count(_ == '.') <= allowedDotsLimit || (recordSet.name.count(_ == '.') == 1 && + recordSet.name.takeRight(1) == ".") || recordSet.name == zone.name || + (recordSet.typ.toString == "PTR" || recordSet.typ.toString == "SRV" || recordSet.typ.toString == "TXT" || recordSet.typ.toString == "NAPTR") + ) + } + + def isNotApexEndsWithDot(recordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = { + ensuring( + InvalidRequest( + "RecordSet name cannot end with a dot, unless it's an apex record." + ) + )( + recordSet.name.endsWith(zone.name) || !recordSet.name.endsWith(".") + ) + } + def canUseOwnerGroup( ownerGroupId: Option[String], group: Option[Group], diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala index 74d7b3c4a..3b8d66a36 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala @@ -16,6 +16,7 @@ package vinyldns.api.domain.zone +import cats.effect.IO import cats.implicits._ import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.api.Interfaces @@ -142,33 +143,59 @@ class ZoneService( accessLevel = getZoneAccess(auth, zone) } yield ZoneInfo(zone, aclInfo, groupName, accessLevel) + // List zones. Uses zone name as default while using search to list zones or by admin group name if selected. def listZones( authPrincipal: AuthPrincipal, nameFilter: Option[String] = None, startFrom: Option[String] = None, maxItems: Int = 100, + searchByAdminGroup: Boolean = false, ignoreAccess: Boolean = false ): Result[ListZonesResponse] = { - for { - listZonesResult <- zoneRepository.listZones( - authPrincipal, - nameFilter, - startFrom, - maxItems, - ignoreAccess + if(!searchByAdminGroup || nameFilter.isEmpty){ + for { + listZonesResult <- zoneRepository.listZones( + authPrincipal, + nameFilter, + startFrom, + maxItems, + ignoreAccess + ) + zones = listZonesResult.zones + groupIds = zones.map(_.adminGroupId).toSet + groups <- groupRepository.getGroups(groupIds) + zoneSummaryInfos = zoneSummaryInfoMapping(zones, authPrincipal, groups) + } yield ListZonesResponse( + zoneSummaryInfos, + listZonesResult.zonesFilter, + listZonesResult.startFrom, + listZonesResult.nextId, + listZonesResult.maxItems, + listZonesResult.ignoreAccess ) - zones = listZonesResult.zones - groupIds = zones.map(_.adminGroupId).toSet - groups <- groupRepository.getGroups(groupIds) - zoneSummaryInfos = zoneSummaryInfoMapping(zones, authPrincipal, groups) - } yield ListZonesResponse( - zoneSummaryInfos, - listZonesResult.zonesFilter, - listZonesResult.startFrom, - listZonesResult.nextId, - listZonesResult.maxItems, - listZonesResult.ignoreAccess - ) + } + else { + for { + groupIds <- getGroupsIdsByName(nameFilter.get) + listZonesResult <- zoneRepository.listZonesByAdminGroupIds( + authPrincipal, + startFrom, + maxItems, + groupIds, + ignoreAccess + ) + zones = listZonesResult.zones + groups <- groupRepository.getGroups(groupIds) + zoneSummaryInfos = zoneSummaryInfoMapping(zones, authPrincipal, groups) + } yield ListZonesResponse( + zoneSummaryInfos, + nameFilter, + listZonesResult.startFrom, + listZonesResult.nextId, + listZonesResult.maxItems, + listZonesResult.ignoreAccess + ) + } }.toResult def zoneSummaryInfoMapping( @@ -242,6 +269,10 @@ class ZoneService( } yield zoneChange } + def getGroupsIdsByName(groupName: String): IO[Set[String]] = { + groupRepository.getGroupsByName(groupName).map(x => x.map(_.id)) + } + def getBackendIds(): Result[List[String]] = backendResolver.ids.toList.toResult diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala index 01457a64b..cbcec8f92 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala @@ -42,6 +42,7 @@ trait ZoneServiceAlgebra { nameFilter: Option[String], startFrom: Option[String], maxItems: Int, + searchByAdminGroup: Boolean, ignoreAccess: Boolean ): Result[ListZonesResponse] diff --git a/modules/api/src/main/scala/vinyldns/api/engine/RecordSetChangeHandler.scala b/modules/api/src/main/scala/vinyldns/api/engine/RecordSetChangeHandler.scala index 94344689c..553e7a06d 100644 --- a/modules/api/src/main/scala/vinyldns/api/engine/RecordSetChangeHandler.scala +++ b/modules/api/src/main/scala/vinyldns/api/engine/RecordSetChangeHandler.scala @@ -106,7 +106,7 @@ object RecordSetChangeHandler extends TransactionProvider { ): List[SingleChange] = recordSetChange.status match { case RecordSetChangeStatus.Complete => - singleChanges.map(_.complete(recordSetChange.id, recordSetChange.recordSet.id)) + singleChanges.map(_.complete(recordSetChange.systemMessage, recordSetChange.id, recordSetChange.recordSet.id)) case RecordSetChangeStatus.Failed => singleChanges.map(_.withProcessingError(recordSetChange.systemMessage, recordSetChange.id)) case _ => singleChanges @@ -157,6 +157,15 @@ object RecordSetChangeHandler extends TransactionProvider { def isDnsMatch(dnsResult: List[RecordSet], recordSet: RecordSet, zoneName: String): Boolean = dnsResult.exists(matches(_, recordSet, zoneName)) + def isRecordExist(existingRecords: List[RecordSet], change: RecordSetChange): Boolean = { + var isExists : Boolean = false + existingRecords.foreach(recordData=> + for (record<-change.recordSet.records) + if (recordData.records.contains(record)) isExists= true + else isExists= false ) + isExists + } + // Determine processing status by comparing request against disposition of DNS backend def getProcessingStatus( change: RecordSetChange, @@ -165,13 +174,13 @@ object RecordSetChangeHandler extends TransactionProvider { change.changeType match { case RecordSetChangeType.Create => if (existingRecords.isEmpty) ReadyToApply(change) - else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name)) - AlreadyApplied(change) - else Failure(change, "Incompatible record already exists in DNS.") + else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name) || isRecordExist(existingRecords,change)) + AlreadyApplied(change) //Record exists in DNS + else Failure(change, "Incompatible record in DNS.") case RecordSetChangeType.Update => if (isDnsMatch(existingRecords, change.recordSet, change.zone.name)) - AlreadyApplied(change) + AlreadyApplied(change) else { // record must not exist in the DNS backend, or be synced if it exists val canApply = existingRecords.isEmpty || @@ -390,7 +399,7 @@ object RecordSetChangeHandler extends TransactionProvider { case Failure(_, message) => Completed( change.failed( - s"Failed validating update to DNS for change ${change.id}:${change.recordSet.name}: " + message + s"""Failed validating update to DNS for change "${change.id}": "${change.recordSet.name}": """ + message ) ) case Retry(_) => Retrying(change) @@ -430,7 +439,7 @@ object RecordSetChangeHandler extends TransactionProvider { case Failure(_, message) => Completed( change.failed( - s"Failed verifying update to DNS for change ${change.id}:${change.recordSet.name}: $message" + s"""Failed verifying update to DNS for change "${change.id}":"${change.recordSet.name}": $message""" ) ) case _ => Retrying(change) diff --git a/modules/api/src/main/scala/vinyldns/api/route/MembershipJsonProtocol.scala b/modules/api/src/main/scala/vinyldns/api/route/MembershipJsonProtocol.scala index a6e385f54..86c498b5d 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/MembershipJsonProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/MembershipJsonProtocol.scala @@ -133,7 +133,9 @@ trait MembershipJsonProtocol extends JsonValidation { (js \ "userId").required[String]("Missing userId"), (js \ "oldGroup").optional[GroupInfo], (js \ "id").default[String](UUID.randomUUID().toString), - (js \ "created").default[Instant](Instant.now.truncatedTo(ChronoUnit.MILLIS)) + (js \ "created").default[Instant](Instant.now.truncatedTo(ChronoUnit.MILLIS)), + (js \ "userName").required[String]("Missing userName"), + (js \ "groupChangeMessage").required[String]("Missing groupChangeMessage"), ).mapN(GroupChangeInfo.apply) } 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..8b4b59509 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) @@ -79,7 +80,7 @@ class MembershipRoute( } ~ (get & monitor("Endpoint.listMyGroups")) { parameters( - "startFrom".?, + "startFrom".as[String].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "groupNameFilter".?, "ignoreAccess".as[Boolean].?(false), @@ -178,6 +179,13 @@ class MembershipRoute( } } } ~ + path("groups" / "change" / Segment) { groupChangeId => + (get & monitor("Endpoint.groupSingleChange")) { + authenticateAndExecute(membershipService.getGroupChange(groupChangeId, _)) { groupChange => + complete(StatusCodes.OK, groupChange) + } + } + } ~ path("users" / Segment / "lock") { id => (put & monitor("Endpoint.lockUser")) { authenticateAndExecute(membershipService.updateUserLockStatus(id, LockStatus.Locked, _)) { diff --git a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala index fe9365839..440f346c3 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala @@ -78,12 +78,14 @@ class ZoneRoute( "nameFilter".?, "startFrom".as[String].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), + "searchByAdminGroup".as[Boolean].?(false), "ignoreAccess".as[Boolean].?(false) ) { ( nameFilter: Option[String], startFrom: Option[String], maxItems: Int, + searchByAdminGroup: Boolean, ignoreAccess: Boolean ) => { @@ -94,7 +96,7 @@ class ZoneRoute( ) { authenticateAndExecute( zoneService - .listZones(_, nameFilter, startFrom, maxItems, ignoreAccess) + .listZones(_, nameFilter, startFrom, maxItems, searchByAdminGroup, ignoreAccess) ) { result => complete(StatusCodes.OK, result) } 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 3a56caf63..341567e4a 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 @@ -1481,8 +1481,8 @@ def test_a_recordtype_add_checks(shared_zone_test_context): # context validations: conflicting recordsets, unauthorized error assert_failed_change_in_error_response(response[7], input_name=existing_a_fqdn, record_data="1.2.3.4", - error_messages=[f"Record \"{existing_a_fqdn}\" Already Exists: " - f"cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + error_messages=[f"RecordName \"{existing_a_fqdn}\" already exists. Your request will be manually reviewed. " + f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add."]) assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_data="1.2.3.4", error_messages=[f'CNAME Conflict: CNAME record names must be unique. ' @@ -1542,6 +1542,7 @@ def test_a_recordtype_update_delete_checks(shared_zone_test_context): get_change_A_AAAA_json(rs_delete_fqdn, change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_update_fqdn, change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_update_fqdn, ttl=300), + get_change_A_AAAA_json(f"non-existent.{ok_zone_name}", change_type="DeleteRecordSet"), # input validations failures get_change_A_AAAA_json("$invalid.host.name.", change_type="DeleteRecordSet"), @@ -1555,7 +1556,6 @@ def test_a_recordtype_update_delete_checks(shared_zone_test_context): get_change_A_AAAA_json("zone.discovery.error.", change_type="DeleteRecordSet"), # context validation failures: record does not exist, not authorized - get_change_A_AAAA_json(f"non-existent.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_update_dummy_fqdn, change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_update_dummy_fqdn, ttl=300), @@ -1592,43 +1592,40 @@ def test_a_recordtype_update_delete_checks(shared_zone_test_context): assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[2], input_name=rs_update_fqdn, ttl=300) + assert_successful_change_in_error_response(response[3], input_name=f"non-existent.{ok_zone_name}", change_type="DeleteRecordSet") # input validations failures - assert_failed_change_in_error_response(response[3], input_name="$invalid.host.name.", + assert_failed_change_in_error_response(response[4], input_name="$invalid.host.name.", change_type="DeleteRecordSet", error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, ' 'numbers, underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[4], input_name="reverse.zone.in-addr.arpa.", + assert_failed_change_in_error_response(response[5], input_name="reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet", error_messages=['Invalid Record Type In Reverse Zone: record with name "reverse.zone.in-addr.arpa." and type "A" ' 'is not allowed in a reverse zone.']) - assert_failed_change_in_error_response(response[5], input_name="$another.invalid.host.name.", ttl=300, + assert_failed_change_in_error_response(response[6], input_name="$another.invalid.host.name.", ttl=300, error_messages=['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.']) - assert_failed_change_in_error_response(response[6], input_name="$another.invalid.host.name.", + assert_failed_change_in_error_response(response[7], input_name="$another.invalid.host.name.", change_type="DeleteRecordSet", error_messages=['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.']) - assert_failed_change_in_error_response(response[7], input_name="another.reverse.zone.in-addr.arpa.", ttl=10, + assert_failed_change_in_error_response(response[8], input_name="another.reverse.zone.in-addr.arpa.", ttl=10, error_messages=['Invalid Record Type In Reverse Zone: record with name "another.reverse.zone.in-addr.arpa." ' 'and type "A" is not allowed in a reverse zone.', 'Invalid TTL: "10", must be a number between 30 and 2147483647.']) - assert_failed_change_in_error_response(response[8], input_name="another.reverse.zone.in-addr.arpa.", + assert_failed_change_in_error_response(response[9], input_name="another.reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet", error_messages=['Invalid Record Type In Reverse Zone: record with name "another.reverse.zone.in-addr.arpa." ' 'and type "A" is not allowed in a reverse zone.']) # zone discovery failure - assert_failed_change_in_error_response(response[9], input_name="zone.discovery.error.", + assert_failed_change_in_error_response(response[10], input_name="zone.discovery.error.", change_type="DeleteRecordSet", error_messages=['Zone Discovery Failed: zone for "zone.discovery.error." does not exist in VinylDNS. ' 'If zone exists, then it must be connected to in VinylDNS.']) # context validation failures: record does not exist, not authorized - assert_failed_change_in_error_response(response[10], input_name=f"non-existent.{ok_zone_name}", - change_type="DeleteRecordSet", - error_messages=[ - f'Record "non-existent.{ok_zone_name}" Does Not Exist: cannot delete a record that does not exist.']) assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn, change_type="DeleteRecordSet", error_messages=[f'User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes.']) @@ -1730,11 +1727,9 @@ def test_aaaa_recordtype_add_checks(shared_zone_test_context): error_messages=[f"Record Name \"cname-duplicate.{parent_zone_name}\" Not Unique In Batch Change: " f"cannot have multiple \"CNAME\" records with the same name."]) assert_successful_change_in_error_response(response[6], input_name=f"cname-duplicate.{parent_zone_name}", - record_type="AAAA", record_data="1::1") - assert_failed_change_in_error_response(response[7], input_name=existing_aaaa_fqdn, record_type="AAAA", - record_data="1::1", - error_messages=[f"Record \"{existing_aaaa_fqdn}\" Already Exists: cannot add an existing record; " - f"to update it, issue a DeleteRecordSet then an Add."]) + record_type="AAAA", record_data="1::1") + assert_successful_change_in_error_response(response[7], input_name=existing_aaaa_fqdn, record_type="AAAA", + record_data="1::1") assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_type="AAAA", record_data="1::1", error_messages=[f"CNAME Conflict: CNAME record names must be unique. Existing record with name \"{existing_cname_fqdn}\" " @@ -1781,6 +1776,8 @@ def test_aaaa_recordtype_update_delete_checks(shared_zone_test_context): get_change_A_AAAA_json(rs_delete_fqdn, record_type="AAAA", change_type="DeleteRecordSet", address="1:0::4:5:6:7:8"), get_change_A_AAAA_json(rs_update_fqdn, record_type="AAAA", ttl=300, address="1:2:3:4:5:6:7:8"), get_change_A_AAAA_json(rs_update_fqdn, record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json(f"delete-nonexistent.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json(f"update-nonexistent.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), # input validations failures get_change_A_AAAA_json(f"invalid-name$.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), @@ -1792,8 +1789,6 @@ def test_aaaa_recordtype_update_delete_checks(shared_zone_test_context): get_change_A_AAAA_json("no.zone.at.all.", record_type="AAAA", change_type="DeleteRecordSet"), # context validation failures - get_change_A_AAAA_json(f"delete-nonexistent.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), - get_change_A_AAAA_json(f"update-nonexistent.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), get_change_A_AAAA_json(f"update-nonexistent.{ok_zone_name}", record_type="AAAA", address="1::1"), get_change_A_AAAA_json(rs_delete_dummy_fqdn, record_type="AAAA", change_type="DeleteRecordSet"), get_change_A_AAAA_json(rs_update_dummy_fqdn, record_type="AAAA", address="1::1"), @@ -1826,39 +1821,37 @@ def test_aaaa_recordtype_update_delete_checks(shared_zone_test_context): record_data="1:2:3:4:5:6:7:8") assert_successful_change_in_error_response(response[2], input_name=rs_update_fqdn, record_type="AAAA", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="AAAA", + record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="AAAA", + record_data=None, change_type="DeleteRecordSet") # input validations failures: invalid input name, reverse zone error, invalid ttl - assert_failed_change_in_error_response(response[3], input_name=f"invalid-name$.{ok_zone_name}", record_type="AAAA", + assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", ' f'valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[4], input_name="reverse.zone.in-addr.arpa.", record_type="AAAA", + assert_failed_change_in_error_response(response[6], input_name="reverse.zone.in-addr.arpa.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", error_messages=["Invalid Record Type In Reverse Zone: record with name \"reverse.zone.in-addr.arpa.\" and " "type \"AAAA\" is not allowed in a reverse zone."]) - assert_failed_change_in_error_response(response[5], input_name=f"bad-ttl-and-invalid-name$-update.{ok_zone_name}", + assert_failed_change_in_error_response(response[7], input_name=f"bad-ttl-and-invalid-name$-update.{ok_zone_name}", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", error_messages=[f'Invalid domain name: "bad-ttl-and-invalid-name$-update.{ok_zone_name}", ' f'valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[6], input_name=f"bad-ttl-and-invalid-name$-update.{ok_zone_name}", ttl=29, + assert_failed_change_in_error_response(response[8], input_name=f"bad-ttl-and-invalid-name$-update.{ok_zone_name}", ttl=29, record_type="AAAA", record_data="1:2:3:4:5:6:7:8", error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', f'Invalid domain name: "bad-ttl-and-invalid-name$-update.{ok_zone_name}", ' f'valid domain names 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.at.all.", record_type="AAAA", + assert_failed_change_in_error_response(response[9], input_name="no.zone.at.all.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " "If zone exists, then it must be connected to in VinylDNS."]) # context validation failures: record does not exist, not authorized - assert_failed_change_in_error_response(response[8], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="AAAA", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"delete-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) - assert_failed_change_in_error_response(response[9], input_name=f"update-nonexistent.{ok_zone_name}", record_type="AAAA", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"update-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) assert_successful_change_in_error_response(response[10], input_name=f"update-nonexistent.{ok_zone_name}", record_type="AAAA", record_data="1::1") assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn, record_type="AAAA", record_data=None, change_type="DeleteRecordSet", @@ -1974,7 +1967,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", @@ -2010,8 +2003,8 @@ def test_cname_recordtype_add_checks(shared_zone_test_context): f"Existing record with name \"{existing_forward_fqdn}\" and type \"A\" conflicts with this record."]) assert_failed_change_in_error_response(response[14], input_name=existing_cname_fqdn, record_type="CNAME", record_data="test.com.", - error_messages=[f"Record \"{existing_cname_fqdn}\" Already Exists: cannot add an existing record; to update it, " - f"issue a DeleteRecordSet then an Add.", + error_messages=[f"RecordName \"{existing_cname_fqdn}\" already exists. Your request will be manually reviewed. " + f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add.", f"CNAME Conflict: CNAME record names must be unique. " f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."]) assert_failed_change_in_error_response(response[15], input_name=existing_reverse_fqdn, record_type="CNAME", @@ -2058,6 +2051,8 @@ def test_cname_recordtype_update_delete_checks(shared_zone_test_context): get_change_CNAME_json(f"delete3.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_CNAME_json(f"update3.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_CNAME_json(f"update3.{ok_zone_name}", ttl=300), + get_change_CNAME_json(f"non-existent-delete.{ok_zone_name}", change_type="DeleteRecordSet"), + get_change_CNAME_json(f"non-existent-update.{ok_zone_name}", change_type="DeleteRecordSet"), # valid changes - reverse zone get_change_CNAME_json(f"200.{ip4_zone_name}", change_type="DeleteRecordSet"), @@ -2073,8 +2068,6 @@ def test_cname_recordtype_update_delete_checks(shared_zone_test_context): get_change_CNAME_json("zone.discovery.error.", change_type="DeleteRecordSet"), # context validation failures: record does not exist, not authorized, failure on update with multiple adds - get_change_CNAME_json(f"non-existent-delete.{ok_zone_name}", change_type="DeleteRecordSet"), - get_change_CNAME_json(f"non-existent-update.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_CNAME_json(f"non-existent-update.{ok_zone_name}"), get_change_CNAME_json(f"delete-unauthorized3.{dummy_zone_name}", change_type="DeleteRecordSet"), get_change_CNAME_json(f"update-unauthorized3.{dummy_zone_name}", change_type="DeleteRecordSet"), @@ -2111,47 +2104,43 @@ def test_cname_recordtype_update_delete_checks(shared_zone_test_context): change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[2], input_name=f"update3.{ok_zone_name}", record_type="CNAME", ttl=300, record_data="test.com.") + assert_successful_change_in_error_response(response[3], input_name=f"non-existent-delete.{ok_zone_name}", record_type="CNAME", + change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"non-existent-update.{ok_zone_name}", record_type="CNAME", + change_type="DeleteRecordSet") # valid changes - reverse zone - assert_successful_change_in_error_response(response[3], input_name=f"200.{ip4_zone_name}", + assert_successful_change_in_error_response(response[5], input_name=f"200.{ip4_zone_name}", record_type="CNAME", change_type="DeleteRecordSet") - assert_successful_change_in_error_response(response[4], input_name=f"201.{ip4_zone_name}", + assert_successful_change_in_error_response(response[6], input_name=f"201.{ip4_zone_name}", record_type="CNAME", change_type="DeleteRecordSet") - assert_successful_change_in_error_response(response[5], input_name=f"201.{ip4_zone_name}", + assert_successful_change_in_error_response(response[7], input_name=f"201.{ip4_zone_name}", record_type="CNAME", ttl=300, record_data="test.com.") # ttl, domain name, data - assert_failed_change_in_error_response(response[6], input_name="$invalid.host.name.", record_type="CNAME", + assert_failed_change_in_error_response(response[8], input_name="$invalid.host.name.", record_type="CNAME", change_type="DeleteRecordSet", error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, numbers, ' 'underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[7], input_name="$another.invalid.host.name.", + assert_failed_change_in_error_response(response[9], input_name="$another.invalid.host.name.", record_type="CNAME", change_type="DeleteRecordSet", error_messages=['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.']) - assert_failed_change_in_error_response(response[8], input_name="$another.invalid.host.name.", ttl=20, + assert_failed_change_in_error_response(response[10], input_name="$another.invalid.host.name.", ttl=20, record_type="CNAME", record_data="$another.invalid.cname.", 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 - assert_failed_change_in_error_response(response[9], input_name="zone.discovery.error.", record_type="CNAME", + assert_failed_change_in_error_response(response[11], input_name="zone.discovery.error.", record_type="CNAME", change_type="DeleteRecordSet", error_messages=[ 'Zone Discovery Failed: zone for "zone.discovery.error." does not exist in VinylDNS. If zone exists, then it must be connected to in VinylDNS.']) # context validation failures: record does not exist, not authorized - assert_failed_change_in_error_response(response[10], input_name=f"non-existent-delete.{ok_zone_name}", record_type="CNAME", - change_type="DeleteRecordSet", - error_messages=[ - f'Record "non-existent-delete.{ok_zone_name}" Does Not Exist: cannot delete a record that does not exist.']) - assert_failed_change_in_error_response(response[11], input_name=f"non-existent-update.{ok_zone_name}", record_type="CNAME", - change_type="DeleteRecordSet", - error_messages=[ - f'Record "non-existent-update.{ok_zone_name}" Does Not Exist: cannot delete a record that does not exist.']) assert_successful_change_in_error_response(response[12], input_name=f"non-existent-update.{ok_zone_name}", record_type="CNAME", record_data="test.com.") assert_failed_change_in_error_response(response[13], input_name=f"delete-unauthorized3.{dummy_zone_name}", @@ -2317,7 +2306,8 @@ def test_ipv4_ptr_recordtype_add_checks(shared_zone_test_context): # context validations: existing cname recordset assert_failed_change_in_error_response(response[11], input_name=f"{ip4_prefix}.193", record_type="PTR", record_data="existing-ptr.", - error_messages=[f'Record "{ip4_prefix}.193" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add.']) + error_messages=[f'RecordName "{ip4_prefix}.193" already exists. Your request will be manually reviewed. ' + f'If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add.']) assert_failed_change_in_error_response(response[12], input_name=f"{ip4_prefix}.199", record_type="PTR", record_data="existing-cname.", error_messages=[ f'CNAME Conflict: CNAME record names must be unique. Existing record with name "{ip4_prefix}.199" and type "CNAME" conflicts with this record.']) @@ -2384,6 +2374,8 @@ def test_ipv4_ptr_recordtype_update_delete_checks(shared_zone_test_context): get_change_PTR_json(f"{ip4_prefix}.25", change_type="DeleteRecordSet"), get_change_PTR_json(f"{ip4_prefix}.193", ttl=300, ptrdname="has-updated.ptr."), get_change_PTR_json(f"{ip4_prefix}.193", change_type="DeleteRecordSet"), + get_change_PTR_json(f"{ip4_prefix}.199", change_type="DeleteRecordSet"), + get_change_PTR_json(f"{ip4_prefix}.200", change_type="DeleteRecordSet"), # valid changes: delete and add of same record name but different type get_change_CNAME_json(f"21.{ip4_zone_name}", change_type="DeleteRecordSet"), @@ -2400,9 +2392,7 @@ def test_ipv4_ptr_recordtype_update_delete_checks(shared_zone_test_context): get_change_PTR_json("192.1.1.25", change_type="DeleteRecordSet"), # context validation failures - get_change_PTR_json(f"{ip4_prefix}.199", change_type="DeleteRecordSet"), get_change_PTR_json(f"{ip4_prefix}.200", ttl=300, ptrdname="has-updated.ptr."), - get_change_PTR_json(f"{ip4_prefix}.200", change_type="DeleteRecordSet"), ] } @@ -2423,25 +2413,29 @@ def test_ipv4_ptr_recordtype_update_delete_checks(shared_zone_test_context): record_data="has-updated.ptr.") assert_successful_change_in_error_response(response[2], input_name=f"{ip4_prefix}.193", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[3], input_name=f"{ip4_prefix}.199", record_type="PTR", + record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"{ip4_prefix}.200", record_type="PTR", + record_data=None, change_type="DeleteRecordSet") # successful changes: add and delete of same record name but different type - assert_successful_change_in_error_response(response[3], input_name=f"21.{ip4_zone_name}", + assert_successful_change_in_error_response(response[5], input_name=f"21.{ip4_zone_name}", record_type="CNAME", record_data=None, change_type="DeleteRecordSet") - assert_successful_change_in_error_response(response[4], input_name=f"{ip4_prefix}.21", record_type="PTR", + assert_successful_change_in_error_response(response[6], input_name=f"{ip4_prefix}.21", record_type="PTR", record_data="replace-cname.ptr.") - assert_successful_change_in_error_response(response[5], input_name=f"17.{ip4_zone_name}", + assert_successful_change_in_error_response(response[7], input_name=f"17.{ip4_zone_name}", record_type="CNAME", record_data="replace-ptr.cname.") - assert_successful_change_in_error_response(response[6], input_name=f"{ip4_prefix}.17", record_type="PTR", + assert_successful_change_in_error_response(response[8], input_name=f"{ip4_prefix}.17", record_type="PTR", record_data=None, change_type="DeleteRecordSet") # input validations failures: invalid IP, ttl, and record data - assert_failed_change_in_error_response(response[7], input_name="1.1.1", record_type="PTR", record_data=None, + assert_failed_change_in_error_response(response[9], input_name="1.1.1", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=['Invalid IP address: "1.1.1".']) - assert_failed_change_in_error_response(response[8], input_name="192.0.2.", record_type="PTR", record_data=None, + assert_failed_change_in_error_response(response[10], input_name="192.0.2.", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=['Invalid IP address: "192.0.2.".']) - assert_failed_change_in_error_response(response[9], ttl=29, input_name="192.0.2.", record_type="PTR", + assert_failed_change_in_error_response(response[11], ttl=29, input_name="192.0.2.", record_type="PTR", record_data="failed-update$.ptr.", error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', 'Invalid IP address: "192.0.2.".', @@ -2449,19 +2443,13 @@ def test_ipv4_ptr_recordtype_update_delete_checks(shared_zone_test_context): 'joined by dots, and terminated with a dot.']) # zone discovery failure - assert_failed_change_in_error_response(response[10], input_name="192.1.1.25", record_type="PTR", + assert_failed_change_in_error_response(response[12], input_name="192.1.1.25", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=["Zone Discovery Failed: zone for \"192.1.1.25\" does not exist in VinylDNS. If zone exists, " "then it must be connected to in VinylDNS."]) # context validation failures: record does not exist - assert_failed_change_in_error_response(response[11], input_name=f"{ip4_prefix}.199", record_type="PTR", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"{ip4_prefix}.199\" Does Not Exist: cannot delete a record that does not exist."]) - assert_successful_change_in_error_response(response[12], ttl=300, input_name=f"{ip4_prefix}.200", record_type="PTR", record_data="has-updated.ptr.") - assert_failed_change_in_error_response(response[13], input_name=f"{ip4_prefix}.200", record_type="PTR", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"{ip4_prefix}.200\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[13], ttl=300, input_name=f"{ip4_prefix}.200", record_type="PTR", record_data="has-updated.ptr.") finally: clear_recordset_list(to_delete, ok_client) @@ -2528,8 +2516,8 @@ def test_ipv6_ptr_recordtype_add_checks(shared_zone_test_context): # context validations: existing record sets pre-request assert_failed_change_in_error_response(response[5], input_name=f"{ip6_prefix}:1000::bbbb", record_type="PTR", record_data="existing.ptr.", - error_messages=[f"Record \"{ip6_prefix}:1000::bbbb\" Already Exists: cannot add an existing record; " - "to update it, issue a DeleteRecordSet then an Add."]) + error_messages=[f"RecordName \"{ip6_prefix}:1000::bbbb\" already exists. Your request will be manually reviewed. " + f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add."]) finally: clear_recordset_list(to_delete, client) @@ -2556,6 +2544,8 @@ def test_ipv6_ptr_recordtype_update_delete_checks(shared_zone_test_context): get_change_PTR_json(f"{ip6_prefix}:1000::aaaa", change_type="DeleteRecordSet"), get_change_PTR_json(f"{ip6_prefix}:1000::62", ttl=300, ptrdname="has-updated.ptr."), get_change_PTR_json(f"{ip6_prefix}:1000::62", change_type="DeleteRecordSet"), + get_change_PTR_json(f"{ip6_prefix}:1000::60", change_type="DeleteRecordSet"), + get_change_PTR_json(f"{ip6_prefix}:1000::65", change_type="DeleteRecordSet"), # input validations failures get_change_PTR_json("fd69:27cc:fe91de::ab", change_type="DeleteRecordSet"), @@ -2566,9 +2556,7 @@ def test_ipv6_ptr_recordtype_update_delete_checks(shared_zone_test_context): get_change_PTR_json("fedc:ba98:7654::abc", change_type="DeleteRecordSet"), # context validation failures - get_change_PTR_json(f"{ip6_prefix}:1000::60", change_type="DeleteRecordSet"), get_change_PTR_json(f"{ip6_prefix}:1000::65", ttl=300, ptrdname="has-updated.ptr."), - get_change_PTR_json(f"{ip6_prefix}:1000::65", change_type="DeleteRecordSet") ] } @@ -2589,15 +2577,19 @@ def test_ipv6_ptr_recordtype_update_delete_checks(shared_zone_test_context): record_type="PTR", record_data="has-updated.ptr.") assert_successful_change_in_error_response(response[2], input_name=f"{ip6_prefix}:1000::62", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[3], input_name=f"{ip6_prefix}:1000::60", record_type="PTR", + record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"{ip6_prefix}:1000::65", record_type="PTR", + record_data=None, change_type="DeleteRecordSet") # input validations failures: invalid IP, ttl, and record data - assert_failed_change_in_error_response(response[3], input_name="fd69:27cc:fe91de::ab", record_type="PTR", + assert_failed_change_in_error_response(response[5], input_name="fd69:27cc:fe91de::ab", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=['Invalid IP address: "fd69:27cc:fe91de::ab".']) - assert_failed_change_in_error_response(response[4], input_name="fd69:27cc:fe91de::ba", record_type="PTR", + assert_failed_change_in_error_response(response[6], input_name="fd69:27cc:fe91de::ba", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=['Invalid IP address: "fd69:27cc:fe91de::ba".']) - assert_failed_change_in_error_response(response[5], ttl=29, input_name="fd69:27cc:fe91de::ba", + assert_failed_change_in_error_response(response[7], ttl=29, input_name="fd69:27cc:fe91de::ba", record_type="PTR", record_data="failed-update$.ptr.", error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', 'Invalid IP address: "fd69:27cc:fe91de::ba".', @@ -2605,20 +2597,14 @@ def test_ipv6_ptr_recordtype_update_delete_checks(shared_zone_test_context): 'and hyphens, joined by dots, and terminated with a dot.']) # zone discovery failure - assert_failed_change_in_error_response(response[6], input_name="fedc:ba98:7654::abc", record_type="PTR", + assert_failed_change_in_error_response(response[8], input_name="fedc:ba98:7654::abc", record_type="PTR", record_data=None, change_type="DeleteRecordSet", error_messages=["Zone Discovery Failed: zone for \"fedc:ba98:7654::abc\" does not exist in VinylDNS. " "If zone exists, then it must be connected to in VinylDNS."]) # context validation failures: record does not exist, failure on update with double add - assert_failed_change_in_error_response(response[7], input_name=f"{ip6_prefix}:1000::60", record_type="PTR", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"{ip6_prefix}:1000::60\" Does Not Exist: cannot delete a record that does not exist."]) - assert_successful_change_in_error_response(response[8], ttl=300, input_name=f"{ip6_prefix}:1000::65", + assert_successful_change_in_error_response(response[9], ttl=300, input_name=f"{ip6_prefix}:1000::65", record_type="PTR", record_data="has-updated.ptr.") - assert_failed_change_in_error_response(response[9], input_name=f"{ip6_prefix}:1000::65", record_type="PTR", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"{ip6_prefix}:1000::65\" Does Not Exist: cannot delete a record that does not exist."]) finally: clear_recordset_list(to_delete, ok_client) @@ -2697,10 +2683,8 @@ def test_txt_recordtype_add_checks(shared_zone_test_context): f"cannot have multiple \"CNAME\" records with the same name."]) # context validations: conflicting recordsets, unauthorized error - assert_failed_change_in_error_response(response[5], input_name=existing_txt_fqdn, record_type="TXT", - record_data="test", - error_messages=[f"Record \"{existing_txt_fqdn}\" Already Exists: " - f"cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_successful_change_in_error_response(response[5], input_name=existing_txt_fqdn, record_type="TXT", + record_data="test") assert_failed_change_in_error_response(response[6], input_name=existing_cname_fqdn, record_type="TXT", record_data="test", error_messages=[f"CNAME Conflict: CNAME record names must be unique. " @@ -2747,6 +2731,8 @@ def test_txt_recordtype_update_delete_checks(shared_zone_test_context): get_change_TXT_json(rs_delete_fqdn, change_type="DeleteRecordSet"), get_change_TXT_json(rs_update_fqdn, change_type="DeleteRecordSet"), get_change_TXT_json(rs_update_fqdn, ttl=300), + get_change_TXT_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), + get_change_TXT_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), # input validations failures get_change_TXT_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet"), @@ -2756,8 +2742,6 @@ def test_txt_recordtype_update_delete_checks(shared_zone_test_context): get_change_TXT_json("no.zone.at.all.", change_type="DeleteRecordSet"), # context validation failures - get_change_TXT_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), - get_change_TXT_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_TXT_json(f"update-nonexistent.{ok_zone_name}", text="test"), get_change_TXT_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet"), get_change_TXT_json(rs_update_dummy_fqdn, text="test"), @@ -2787,25 +2771,23 @@ def test_txt_recordtype_update_delete_checks(shared_zone_test_context): assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, record_type="TXT", record_data=None, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, record_type="TXT", record_data=None, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[2], ttl=300, input_name=rs_update_fqdn, record_type="TXT", record_data="test") + assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="TXT", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="TXT", record_data=None, change_type="DeleteRecordSet") # input validations failures: invalid input name, reverse zone error, invalid ttl - assert_failed_change_in_error_response(response[3], input_name=f"invalid-name$.{ok_zone_name}", record_type="TXT", record_data="test", change_type="DeleteRecordSet", + assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="TXT", record_data="test", change_type="DeleteRecordSet", error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be ' f'letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[4], input_name=f"invalid-ttl.{ok_zone_name}", ttl=29, record_type="TXT", record_data="bad-ttl", + assert_failed_change_in_error_response(response[6], input_name=f"invalid-ttl.{ok_zone_name}", ttl=29, record_type="TXT", record_data="bad-ttl", error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) # zone discovery failure - assert_failed_change_in_error_response(response[5], input_name="no.zone.at.all.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + assert_failed_change_in_error_response(response[7], input_name="no.zone.at.all.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", error_messages=[ "Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " "If zone exists, then it must be connected to in VinylDNS."]) # context validation failures: record does not exist, not authorized - assert_failed_change_in_error_response(response[6], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="TXT", record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"delete-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) - assert_failed_change_in_error_response(response[7], input_name=f"update-nonexistent.{ok_zone_name}", record_type="TXT", record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"update-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) assert_successful_change_in_error_response(response[8], input_name=f"update-nonexistent.{ok_zone_name}", record_type="TXT", record_data="test") assert_failed_change_in_error_response(response[9], input_name=rs_delete_dummy_fqdn, record_type="TXT", record_data=None, change_type="DeleteRecordSet", error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."]) @@ -2906,10 +2888,8 @@ def test_mx_recordtype_add_checks(shared_zone_test_context): f"cannot have multiple \"CNAME\" records with the same name."]) # context validations: conflicting recordsets, unauthorized error - assert_failed_change_in_error_response(response[8], input_name=existing_mx_fqdn, record_type="MX", - record_data={"preference": 1, "exchange": "foo.bar."}, - error_messages=[f"Record \"{existing_mx_fqdn}\" Already Exists: cannot add an existing record; to update it, " - f"issue a DeleteRecordSet then an Add."]) + assert_successful_change_in_error_response(response[8], input_name=existing_mx_fqdn, record_type="MX", + record_data={"preference": 1, "exchange": "foo.bar."}) assert_failed_change_in_error_response(response[9], input_name=existing_cname_fqdn, record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, error_messages=["CNAME Conflict: CNAME record names must be unique. " @@ -2958,6 +2938,8 @@ def test_mx_recordtype_update_delete_checks(shared_zone_test_context): get_change_MX_json(rs_delete_fqdn, change_type="DeleteRecordSet"), get_change_MX_json(rs_update_fqdn, change_type="DeleteRecordSet"), get_change_MX_json(rs_update_fqdn, ttl=300), + get_change_MX_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), + get_change_MX_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), # input validations failures get_change_MX_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet"), @@ -2969,8 +2951,6 @@ def test_mx_recordtype_update_delete_checks(shared_zone_test_context): get_change_MX_json("no.zone.at.all.", change_type="DeleteRecordSet"), # context validation failures - get_change_MX_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), - get_change_MX_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet"), get_change_MX_json(f"update-nonexistent.{ok_zone_name}", preference=1000, exchange="foo.bar."), get_change_MX_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet"), get_change_MX_json(rs_update_dummy_fqdn, preference=1000, exchange="foo.bar."), @@ -3000,37 +2980,35 @@ def test_mx_recordtype_update_delete_checks(shared_zone_test_context): assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, record_type="MX", record_data=None, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, record_type="MX", record_data=None, change_type="DeleteRecordSet") assert_successful_change_in_error_response(response[2], ttl=300, input_name=rs_update_fqdn, record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}) + assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="MX", + record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="MX", + record_data=None, change_type="DeleteRecordSet") # input validations failures: invalid input name, reverse zone error, invalid ttl - assert_failed_change_in_error_response(response[3], input_name=f"invalid-name$.{ok_zone_name}", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, change_type="DeleteRecordSet", error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be letters, ' f'numbers, underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[4], input_name=f"delete.{ok_zone_name}", ttl=29, record_type="MX", + assert_failed_change_in_error_response(response[6], input_name=f"delete.{ok_zone_name}", ttl=29, record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) - assert_failed_change_in_error_response(response[5], input_name=f"bad-exchange.{ok_zone_name}", record_type="MX", + assert_failed_change_in_error_response(response[7], input_name=f"bad-exchange.{ok_zone_name}", record_type="MX", record_data={"preference": 1, "exchange": "foo$.bar."}, error_messages=['Invalid domain name: "foo$.bar.", valid domain names must be letters, numbers, ' 'underscores, and hyphens, joined by dots, and terminated with a dot.']) - assert_failed_change_in_error_response(response[6], input_name=f"mx.{ip4_zone_name}", record_type="MX", + assert_failed_change_in_error_response(response[8], input_name=f"mx.{ip4_zone_name}", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, error_messages=[f'Invalid Record Type In Reverse Zone: record with name "mx.{ip4_zone_name}" ' f'and type "MX" is not allowed in a reverse zone.']) # zone discovery failure - assert_failed_change_in_error_response(response[7], input_name="no.zone.at.all.", record_type="MX", + assert_failed_change_in_error_response(response[9], input_name="no.zone.at.all.", record_type="MX", record_data=None, change_type="DeleteRecordSet", error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " "If zone exists, then it must be connected to in VinylDNS."]) # context validation failures: record does not exist, not authorized - assert_failed_change_in_error_response(response[8], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="MX", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"delete-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) - assert_failed_change_in_error_response(response[9], input_name=f"update-nonexistent.{ok_zone_name}", record_type="MX", - record_data=None, change_type="DeleteRecordSet", - error_messages=[f"Record \"update-nonexistent.{ok_zone_name}\" Does Not Exist: cannot delete a record that does not exist."]) assert_successful_change_in_error_response(response[10], input_name=f"update-nonexistent.{ok_zone_name}", record_type="MX", record_data={"preference": 1000, "exchange": "foo.bar."}) assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn, record_type="MX", @@ -3739,39 +3717,27 @@ def test_create_batch_with_zone_name_requiring_manual_review(shared_zone_test_co rejecter.reject_batch_change(response["id"], status=200) -def test_create_batch_delete_record_for_invalid_record_data_fails(shared_zone_test_context): +def test_create_batch_delete_record_that_does_not_exists_completes(shared_zone_test_context): """ - Test delete record set fails for non-existent record and non-existent record data + Test delete record set completes for non-existent record """ client = shared_zone_test_context.ok_vinyldns_client ok_zone_name = shared_zone_test_context.ok_zone["name"] - a_delete_name = generate_record_name() - a_delete_fqdn = a_delete_name + f".{ok_zone_name}" - a_delete = create_recordset(shared_zone_test_context.ok_zone, a_delete_fqdn, "A", [{"address": "1.1.1.1"}]) - batch_change_input = { "comments": "test delete record failures", "changes": [ - get_change_A_AAAA_json(f"delete-non-existent-record.{ok_zone_name}", change_type="DeleteRecordSet"), - get_change_A_AAAA_json(a_delete_fqdn, address="4.5.6.7", change_type="DeleteRecordSet") + get_change_A_AAAA_json(f"delete-non-existent-record.{ok_zone_name}", change_type="DeleteRecordSet") ] } - to_delete = [] + response = client.create_batch_change(batch_change_input, status=202) + get_batch = client.get_batch_change(response["id"]) - try: - create_rs = client.create_recordset(a_delete, status=202) - to_delete.append(client.wait_until_recordset_change_status(create_rs, "Complete")) + assert_that(get_batch["changes"][0]["systemMessage"], is_("This record does not exist." + + "No further action is required.")) - errors = client.create_batch_change(batch_change_input, status=400) - - assert_failed_change_in_error_response(errors[0], input_name=f"delete-non-existent-record.{ok_zone_name}", record_data="1.1.1.1", change_type="DeleteRecordSet", - error_messages=[f'Record "delete-non-existent-record.{ok_zone_name}" Does Not Exist: cannot delete a record that does not exist.']) - assert_failed_change_in_error_response(errors[1], input_name=a_delete_fqdn, record_data="4.5.6.7", change_type="DeleteRecordSet", - error_messages=["Record data 4.5.6.7 does not exist for \"" + a_delete_fqdn + "\"."]) - finally: - clear_recordset_list(to_delete, client) + assert_successful_change_in_error_response(response["changes"][0], input_name=f"delete-non-existent-record.{ok_zone_name}", record_data="1.1.1.1", change_type="DeleteRecordSet") @pytest.mark.serial @@ -4169,23 +4135,23 @@ def test_create_batch_change_with_multi_record_adds_with_multi_record_support(sh get_change_MX_json(f"multi-mx.{ok_zone_name}", preference=0), get_change_MX_json(f"multi-mx.{ok_zone_name}", preference=1000, exchange="bar.foo."), get_change_A_AAAA_json(rs_fqdn, address="1.1.1.1") - ] + ], + "ownerGroupId": shared_zone_test_context.ok_group["id"] } try: create_rs = client.create_recordset(rs_to_create, status=202) to_delete.append(client.wait_until_recordset_change_status(create_rs, "Complete")) - response = client.create_batch_change(batch_change_input, status=400) + response = client.create_batch_change(batch_change_input, status=202) - assert_successful_change_in_error_response(response[0], input_name=f"multi.{ok_zone_name}", record_data="1.2.3.4") - assert_successful_change_in_error_response(response[1], input_name=f"multi.{ok_zone_name}", record_data="4.5.6.7") - assert_successful_change_in_error_response(response[2], input_name=f"{ip4_prefix}.44", record_type="PTR", record_data="multi.test.") - assert_successful_change_in_error_response(response[3], input_name=f"{ip4_prefix}.44", record_type="PTR", record_data="multi2.test.") - assert_successful_change_in_error_response(response[4], input_name=f"multi-txt.{ok_zone_name}", record_type="TXT", record_data="some-multi-text") - assert_successful_change_in_error_response(response[5], input_name=f"multi-txt.{ok_zone_name}", record_type="TXT", record_data="more-multi-text") - assert_successful_change_in_error_response(response[6], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 0, "exchange": "foo.bar."}) - assert_successful_change_in_error_response(response[7], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 1000, "exchange": "bar.foo."}) - assert_failed_change_in_error_response(response[8], input_name=rs_fqdn, record_data="1.1.1.1", - error_messages=["Record \"" + rs_fqdn + "\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_successful_change_in_error_response(response["changes"][0], input_name=f"multi.{ok_zone_name}", record_data="1.2.3.4") + assert_successful_change_in_error_response(response["changes"][1], input_name=f"multi.{ok_zone_name}", record_data="4.5.6.7") + assert_successful_change_in_error_response(response["changes"][2], input_name=f"{ip4_prefix}.44", record_type="PTR", record_data="multi.test.") + assert_successful_change_in_error_response(response["changes"][3], input_name=f"{ip4_prefix}.44", record_type="PTR", record_data="multi2.test.") + assert_successful_change_in_error_response(response["changes"][4], input_name=f"multi-txt.{ok_zone_name}", record_type="TXT", record_data="some-multi-text") + assert_successful_change_in_error_response(response["changes"][5], input_name=f"multi-txt.{ok_zone_name}", record_type="TXT", record_data="more-multi-text") + assert_successful_change_in_error_response(response["changes"][6], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 0, "exchange": "foo.bar."}) + assert_successful_change_in_error_response(response["changes"][7], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 1000, "exchange": "bar.foo."}) + assert_successful_change_in_error_response(response["changes"][8], input_name=rs_fqdn, record_data="1.1.1.1") finally: clear_recordset_list(to_delete, client) diff --git a/modules/api/src/test/functional/tests/membership/get_group_changes_test.py b/modules/api/src/test/functional/tests/membership/get_group_changes_test.py index 61754373f..f1221a57a 100644 --- a/modules/api/src/test/functional/tests/membership/get_group_changes_test.py +++ b/modules/api/src/test/functional/tests/membership/get_group_changes_test.py @@ -33,11 +33,11 @@ def test_list_group_activity_start_from_success(group_activity_context, shared_z # start from a known good timestamp start_from = str(int((datetime.strptime(page_one["changes"][start_from_index]["created"], "%Y-%m-%dT%H:%M:%S.%fZ") - epoch).total_seconds() * 1000)) # now, we say give me all changes since the start_from, which should yield 8-7-6-5-4 - result = client.get_group_changes(created_group["id"], start_from=start_from, max_items=5, status=200) + result = client.get_group_changes(created_group["id"], start_from=page_one["nextId"], max_items=5, status=200) assert_that(result["changes"], has_length(5)) assert_that(result["maxItems"], is_(5)) - assert_that(result["startFrom"], is_(start_from)) + assert_that(result["startFrom"], is_(page_one["nextId"])) assert_that(result["nextId"], is_not(none())) # we should have, in order, changes 8 7 6 5 4 diff --git a/modules/api/src/test/functional/tests/membership/list_my_groups_test.py b/modules/api/src/test/functional/tests/membership/list_my_groups_test.py index cfcf557e8..516cdc76a 100644 --- a/modules/api/src/test/functional/tests/membership/list_my_groups_test.py +++ b/modules/api/src/test/functional/tests/membership/list_my_groups_test.py @@ -13,20 +13,11 @@ def test_list_my_groups_no_parameters(list_my_groups_context): Test that we can get all the groups where a user is a member """ results = list_my_groups_context.client.list_my_groups(status=200) - assert_that(results, has_length(3)) # 3 fields - - # Only count the groups with the group prefix - groups = [x for x in results["groups"] if x["name"].startswith(list_my_groups_context.group_prefix)] - assert_that(groups, has_length(50)) + assert_that(results, has_length(4)) # 4 fields assert_that(results, is_not(has_key("groupNameFilter"))) assert_that(results, is_not(has_key("startFrom"))) - assert_that(results, is_not(has_key("nextId"))) - assert_that(results["maxItems"], is_(200)) - - results["groups"] = sorted(groups, key=lambda x: x["name"]) - - for i in range(0, 50): - assert_that(results["groups"][i]["name"], is_("{0}-{1:0>3}".format(list_my_groups_context.group_prefix, i))) + assert_that(results, is_(has_key("nextId"))) + assert_that(results["maxItems"], is_(100)) def test_get_my_groups_using_old_account_auth(list_my_groups_context): @@ -34,11 +25,11 @@ def test_get_my_groups_using_old_account_auth(list_my_groups_context): Test passing in an account will return an empty set """ results = list_my_groups_context.client.list_my_groups(status=200) - assert_that(results, has_length(3)) + assert_that(results, has_length(4)) assert_that(results, is_not(has_key("groupNameFilter"))) assert_that(results, is_not(has_key("startFrom"))) - assert_that(results, is_not(has_key("nextId"))) - assert_that(results["maxItems"], is_(200)) + assert_that(results, is_(has_key("nextId"))) + assert_that(results["maxItems"], is_(100)) def test_list_my_groups_max_items(list_my_groups_context): @@ -102,7 +93,7 @@ def test_list_my_groups_filter_matches(list_my_groups_context): assert_that(results["groupNameFilter"], is_(f"{list_my_groups_context.group_prefix}-01")) assert_that(results, is_not(has_key("startFrom"))) assert_that(results, is_not(has_key("nextId"))) - assert_that(results["maxItems"], is_(200)) + assert_that(results["maxItems"], is_(100)) results["groups"] = sorted(results["groups"], key=lambda x: x["name"]) @@ -133,28 +124,20 @@ def test_list_my_groups_with_ignore_access_true(list_my_groups_context): Test that we can get all the groups whether a user is a member or not """ results = list_my_groups_context.client.list_my_groups(ignore_access=True, status=200) - - # Only count the groups with the group prefix + assert_that(results, has_length(4)) # 4 fields assert_that(len(results["groups"]), greater_than(50)) - assert_that(results["maxItems"], is_(200)) + assert_that(results["maxItems"], is_(100)) assert_that(results["ignoreAccess"], is_(True)) - my_results = list_my_groups_context.client.list_my_groups(status=200) - my_groups = [x for x in my_results["groups"] if x["name"].startswith(list_my_groups_context.group_prefix)] - sorted_groups = sorted(my_groups, key=lambda x: x["name"]) - - for i in range(0, 50): - assert_that(sorted_groups[i]["name"], is_("{0}-{1:0>3}".format(list_my_groups_context.group_prefix, i))) - def test_list_my_groups_as_support_user(list_my_groups_context): """ Test that we can get all the groups as a support user, even without ignore_access """ results = list_my_groups_context.support_user_client.list_my_groups(status=200) - + assert_that(results, has_length(4)) # 4 fields assert_that(len(results["groups"]), greater_than(50)) - assert_that(results["maxItems"], is_(200)) + assert_that(results["maxItems"], is_(100)) assert_that(results["ignoreAccess"], is_(False)) @@ -163,7 +146,7 @@ def test_list_my_groups_as_support_user_with_ignore_access_true(list_my_groups_c Test that we can get all the groups as a support user """ results = list_my_groups_context.support_user_client.list_my_groups(ignore_access=True, status=200) - + assert_that(results, has_length(4)) # 4 fields assert_that(len(results["groups"]), greater_than(50)) - assert_that(results["maxItems"], is_(200)) + assert_that(results["maxItems"], is_(100)) assert_that(results["ignoreAccess"], is_(True)) diff --git a/modules/api/src/test/functional/tests/recordsets/create_recordset_test.py b/modules/api/src/test/functional/tests/recordsets/create_recordset_test.py index c86d0e6a7..bf6975041 100644 --- a/modules/api/src/test/functional/tests/recordsets/create_recordset_test.py +++ b/modules/api/src/test/functional/tests/recordsets/create_recordset_test.py @@ -508,9 +508,11 @@ def test_create_invalid_record_data(shared_zone_test_context): )) -def test_create_dotted_a_record_not_apex_fails(shared_zone_test_context): +def test_create_dotted_a_record_not_apex_fails_when_dotted_hosts_config_not_satisfied(shared_zone_test_context): """ - Test that creating a dotted host name A record set fails. + Test that creating a dotted host name A record set fails + Here the zone and user (individual) is allowed but record type is not allowed. Hence the test fails + Config present in reference.conf """ client = shared_zone_test_context.ok_vinyldns_client @@ -524,8 +526,57 @@ def test_create_dotted_a_record_not_apex_fails(shared_zone_test_context): zone_name = shared_zone_test_context.parent_zone["name"] error = client.create_recordset(dotted_host_a_record, status=422) - assert_that(error, is_("Record with name " + dotted_host_a_record["name"] + " and type A is a dotted host which " - "is not allowed in zone " + zone_name)) + assert_that(error, is_("Record type is not allowed or the user is not authorized to create a dotted host in the " + "zone '" + zone_name + "'")) + + +def test_create_dotted_a_record_succeeds_if_all_dotted_hosts_config_satisfied(shared_zone_test_context): + """ + Test that creating a A record set with dotted host record name succeeds + Here the zone, user (in group) and record type is allowed. Hence the test succeeds + Config present in reference.conf + """ + client = shared_zone_test_context.history_client + zone = shared_zone_test_context.dummy_zone + dotted_host_a_record = { + "zoneId": zone["id"], + "name": "dot.ted", + "type": "A", + "ttl": 500, + "records": [{"address": "127.0.0.1"}] + } + + dotted_a_record = None + try: + dotted_cname_response = client.create_recordset(dotted_host_a_record, status=202) + dotted_a_record = client.wait_until_recordset_change_status(dotted_cname_response, "Complete")["recordSet"] + assert_that(dotted_a_record["name"], is_(dotted_host_a_record["name"])) + finally: + if dotted_a_record: + delete_result = client.delete_recordset(dotted_a_record["zoneId"], dotted_a_record["id"], status=202) + client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_create_dotted_a_record_fails_if_all_dotted_hosts_config_not_satisfied(shared_zone_test_context): + """ + Test that creating a A record set with dotted host record name fails + Here the zone, user (in group) and record type is allowed. + But the record name has more dots than the number of dots allowed for this zone. Hence the test fails + The 'dots-limit' config from dotted-hosts config is not satisfied. Config present in reference.conf + """ + client = shared_zone_test_context.history_client + zone = shared_zone_test_context.dummy_zone + dotted_host_a_record = { + "zoneId": zone["id"], + "name": "dot.ted.trial.test.host", + "type": "A", + "ttl": 500, + "records": [{"address": "127.0.0.1"}] + } + + error = client.create_recordset(dotted_host_a_record, status=422) + assert_that(error, is_("RecordSet with name " + dotted_host_a_record["name"] + " has more dots than that is " + "allowed in config for this zone which is, 'dots-limit = 3'.")) def test_create_dotted_a_record_apex_succeeds(shared_zone_test_context): @@ -581,13 +632,15 @@ def test_create_dotted_a_record_apex_with_trailing_dot_succeeds(shared_zone_test client.wait_until_recordset_change_status(delete_result, "Complete") -def test_create_dotted_cname_record_fails(shared_zone_test_context): +def test_create_dotted_cname_record_fails_when_dotted_hosts_config_not_satisfied(shared_zone_test_context): """ - Test that creating a CNAME record set with dotted host record name returns an error. + Test that creating a CNAME record set with dotted host record name returns an error + Here the zone is allowed but user (individual or in group) and record type is not allowed. Hence the test fails + Config present in reference.conf """ - client = shared_zone_test_context.ok_vinyldns_client - zone = shared_zone_test_context.parent_zone - apex_cname_rs = { + client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.dummy_zone + dotted_host_cname_record = { "zoneId": zone["id"], "name": "dot.ted", "type": "CNAME", @@ -595,8 +648,37 @@ def test_create_dotted_cname_record_fails(shared_zone_test_context): "records": [{"cname": "foo.bar."}] } - error = client.create_recordset(apex_cname_rs, status=422) - assert_that(error, is_(f'Record with name dot.ted and type CNAME is a dotted host which is not allowed in zone {zone["name"]}')) + error = client.create_recordset(dotted_host_cname_record, status=422) + assert_that(error, is_("Record type is not allowed or the user is not authorized to create a dotted host in the " + "zone '" + zone["name"] + "'")) + + +def test_create_dotted_cname_record_succeeds_if_all_dotted_hosts_config_satisfied(shared_zone_test_context): + """ + Test that creating a CNAME record set with dotted host record name succeeds. + Here the zone, user (individual) and record type is allowed. Hence the test succeeds + Config present in reference.conf + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + dotted_host_cname_record = { + "zoneId": zone["id"], + "name": "dot.ted", + "type": "CNAME", + "ttl": 500, + "records": [{"cname": "foo.bar."}] + } + + dotted_cname_record = None + try: + dotted_cname_response = client.create_recordset(dotted_host_cname_record, status=202) + dotted_cname_record = client.wait_until_recordset_change_status(dotted_cname_response, "Complete")["recordSet"] + assert_that(dotted_cname_record["name"], is_(dotted_host_cname_record["name"])) + finally: + if dotted_cname_record: + delete_result = client.delete_recordset(dotted_cname_record["zoneId"], dotted_cname_record["id"], + status=202) + client.wait_until_recordset_change_status(delete_result, "Complete") def test_create_cname_with_multiple_records(shared_zone_test_context): @@ -701,7 +783,8 @@ def test_create_cname_with_existing_record_with_name_fails(shared_zone_test_cont a_record = client.wait_until_recordset_change_status(a_create, "Complete")["recordSet"] error = client.create_recordset(cname_rs, status=409) - assert_that(error, is_(f'RecordSet with name duplicate-test-name already exists in zone {zone["name"]}, CNAME record cannot use duplicate name')) + assert_that(error, + is_(f'RecordSet with name duplicate-test-name already exists in zone {zone["name"]}, CNAME record cannot use duplicate name')) finally: if a_record: delete_result = client.delete_recordset(a_record["zoneId"], a_record["id"], status=202) @@ -744,7 +827,8 @@ def test_create_record_with_existing_cname_fails(shared_zone_test_context): cname_record = client.wait_until_recordset_change_status(cname_create, "Complete")["recordSet"] error = client.create_recordset(a_rs, status=409) - assert_that(error, is_(f'RecordSet with name duplicate-test-name and type CNAME already exists in zone {zone["name"]}')) + assert_that(error, + is_(f'RecordSet with name duplicate-test-name and type CNAME already exists in zone {zone["name"]}')) finally: if cname_record: delete_result = client.delete_recordset(cname_record["zoneId"], cname_record["id"], status=202) @@ -1368,7 +1452,6 @@ def test_at_create_recordset(shared_zone_test_context): } result = client.create_recordset(new_rs, status=202) - assert_that(result["changeType"], is_("Create")) assert_that(result["status"], is_("Pending")) assert_that(result["created"], is_not(none())) @@ -1418,7 +1501,6 @@ def test_create_record_with_escape_characters_in_record_data_succeeds(shared_zon } result = client.create_recordset(new_rs, status=202) - assert_that(result["changeType"], is_("Create")) assert_that(result["status"], is_("Pending")) assert_that(result["created"], is_not(none())) @@ -1554,6 +1636,29 @@ def test_create_long_txt_record_succeeds(shared_zone_test_context): pass +def test_create_long_spf_record_succeeds(shared_zone_test_context): + client = shared_zone_test_context.ok_vinyldns_client + + zone = shared_zone_test_context.system_test_zone + + # Anything larger than 255 will test the limits of SPF, 4000 is the value used by R53 + # (https://aws.amazon.com/premiumsupport/knowledge-center/route-53-configure-long-spf-txt-records/) + record_data = "a" * 4000 + long_spf_rs = create_recordset(zone, "long-spf-record", "SPF", [{"text": record_data}]) + + try: + rs_create = client.create_recordset(long_spf_rs, status=202) + rs = client.wait_until_recordset_change_status(rs_create, "Complete")["recordSet"] + assert_that(rs["records"][0]["text"], is_(record_data)) + finally: + try: + delete_result = client.delete_recordset(rs["zoneId"], rs["id"], status=202) + client.wait_until_recordset_change_status(delete_result, "Complete") + except Exception: + traceback.print_exc() + pass + + def test_txt_dotted_host_create_succeeds(shared_zone_test_context): """ Tests that a TXT dotted host recordset create succeeds @@ -1743,7 +1848,8 @@ def test_create_high_value_domain_fails(shared_zone_test_context): } error = client.create_recordset(new_rs, status=422) - assert_that(error, is_(f'Record name "high-value-domain.{zone["name"]}" is configured as a High Value Domain, so it cannot be modified.')) + assert_that(error, + is_(f'Record name "high-value-domain.{zone["name"]}" is configured as a High Value Domain, so it cannot be modified.')) def test_create_high_value_domain_fails_case_insensitive(shared_zone_test_context): @@ -1765,7 +1871,8 @@ def test_create_high_value_domain_fails_case_insensitive(shared_zone_test_contex } error = client.create_recordset(new_rs, status=422) - assert_that(error, is_(f'Record name "hIgH-vAlUe-dOmAiN.{zone["name"]}" is configured as a High Value Domain, so it cannot be modified.')) + assert_that(error, + is_(f'Record name "hIgH-vAlUe-dOmAiN.{zone["name"]}" is configured as a High Value Domain, so it cannot be modified.')) def test_create_high_value_domain_fails_for_ip4_ptr(shared_zone_test_context): @@ -1786,7 +1893,8 @@ def test_create_high_value_domain_fails_for_ip4_ptr(shared_zone_test_context): } error_ptr = client.create_recordset(ptr, status=422) - assert_that(error_ptr, is_(f'Record name "{shared_zone_test_context.ip4_classless_prefix}.252" is configured as a High Value Domain, so it cannot be modified.')) + assert_that(error_ptr, + is_(f'Record name "{shared_zone_test_context.ip4_classless_prefix}.252" is configured as a High Value Domain, so it cannot be modified.')) def test_create_high_value_domain_fails_for_ip6_ptr(shared_zone_test_context): @@ -1807,7 +1915,8 @@ def test_create_high_value_domain_fails_for_ip6_ptr(shared_zone_test_context): } error_ptr = client.create_recordset(ptr, status=422) - assert_that(error_ptr, is_(f'Record name "{shared_zone_test_context.ip6_prefix}:0000:0000:0000:0000:ffff" is configured as a High Value Domain, so it cannot be modified.')) + assert_that(error_ptr, + is_(f'Record name "{shared_zone_test_context.ip6_prefix}:0000:0000:0000:0000:ffff" is configured as a High Value Domain, so it cannot be modified.')) def test_create_with_owner_group_in_private_zone_by_admin_passes(shared_zone_test_context): @@ -1874,7 +1983,8 @@ def test_create_with_owner_group_in_private_zone_by_acl_passes(shared_zone_test_ finally: clear_ok_acl_rules(shared_zone_test_context) if create_rs: - delete_result = shared_zone_test_context.ok_vinyldns_client.delete_recordset(zone["id"], create_rs["id"], status=202) + delete_result = shared_zone_test_context.ok_vinyldns_client.delete_recordset(zone["id"], create_rs["id"], + status=202) shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(delete_result, "Complete") @@ -1900,8 +2010,11 @@ def test_create_with_owner_group_in_shared_zone_by_acl_passes(shared_zone_test_c finally: clear_shared_zone_acl_rules(shared_zone_test_context) if create_rs: - delete_result = shared_zone_test_context.shared_zone_vinyldns_client.delete_recordset(zone["id"], create_rs["id"], status=202) - shared_zone_test_context.shared_zone_vinyldns_client.wait_until_recordset_change_status(delete_result, "Complete") + delete_result = shared_zone_test_context.shared_zone_vinyldns_client.delete_recordset(zone["id"], + create_rs["id"], + status=202) + shared_zone_test_context.shared_zone_vinyldns_client.wait_until_recordset_change_status(delete_result, + "Complete") def test_create_in_shared_zone_without_owner_group_id_succeeds(shared_zone_test_context): @@ -1955,10 +2068,12 @@ def test_create_in_shared_zone_by_unassociated_user_fails_if_record_type_is_not_ zone = shared_zone_test_context.shared_zone group = shared_zone_test_context.dummy_group - record_json = create_recordset(zone, "test_shared_not_approved_record_type", "MX", [{"preference": 3, "exchange": "mx"}]) + record_json = create_recordset(zone, "test_shared_not_approved_record_type", "MX", + [{"preference": 3, "exchange": "mx"}]) record_json["ownerGroupId"] = group["id"] error = client.create_recordset(record_json, status=403) - assert_that(error, is_(f'User dummy does not have access to create test-shared-not-approved-record-type.{zone["name"]}')) + assert_that(error, + is_(f'User dummy does not have access to create test-shared-not-approved-record-type.{zone["name"]}')) def test_create_with_not_found_owner_group_fails(shared_zone_test_context): @@ -1997,7 +2112,8 @@ def test_create_ds_success(shared_zone_test_context): zone = shared_zone_test_context.ds_zone record_data = [ {"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}, - {"keytag": 60485, "algorithm": 5, "digesttype": 2, "digest": "D4B7D520E7BB5F0F67674A0CCEB1E3E0614B93C4F9E99B8383F6A1E4469DA50A"} + {"keytag": 60485, "algorithm": 5, "digesttype": 2, + "digest": "D4B7D520E7BB5F0F67674A0CCEB1E3E0614B93C4F9E99B8383F6A1E4469DA50A"} ] record_json = create_recordset(zone, "dskey", "DS", record_data, ttl=3600) result_rs = None @@ -2039,7 +2155,8 @@ def test_create_ds_unknown_algorithm(shared_zone_test_context): """ client = shared_zone_test_context.ok_vinyldns_client zone = shared_zone_test_context.ds_zone - record_data = [{"keytag": 60485, "algorithm": 0, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] + record_data = [ + {"keytag": 60485, "algorithm": 0, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] record_json = create_recordset(zone, "dskey", "DS", record_data) errors = client.create_recordset(record_json, status=400)["errors"] assert_that(errors, contains_inanyorder("Algorithm 0 is not a supported DNSSEC algorithm")) @@ -2051,7 +2168,8 @@ def test_create_ds_unknown_digest_type(shared_zone_test_context): """ client = shared_zone_test_context.ok_vinyldns_client zone = shared_zone_test_context.ds_zone - record_data = [{"keytag": 60485, "algorithm": 5, "digesttype": 0, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] + record_data = [ + {"keytag": 60485, "algorithm": 5, "digesttype": 0, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] record_json = create_recordset(zone, "dskey", "DS", record_data) errors = client.create_recordset(record_json, status=400)["errors"] assert_that(errors, contains_inanyorder("Digest Type 0 is not a supported DS record digest type")) @@ -2063,10 +2181,12 @@ def test_create_ds_no_ns_fails(shared_zone_test_context): """ client = shared_zone_test_context.ok_vinyldns_client zone = shared_zone_test_context.ds_zone - record_data = [{"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] + record_data = [ + {"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] record_json = create_recordset(zone, "no-ns-exists", "DS", record_data, ttl=3600) error = client.create_recordset(record_json, status=422) - assert_that(error, is_(f'DS record [no-ns-exists] is invalid because there is no NS record with that name in the zone [{zone["name"]}]')) + assert_that(error, + is_(f'DS record [no-ns-exists] is invalid because there is no NS record with that name in the zone [{zone["name"]}]')) def test_create_apex_ds_fails(shared_zone_test_context): @@ -2075,7 +2195,8 @@ def test_create_apex_ds_fails(shared_zone_test_context): """ client = shared_zone_test_context.ok_vinyldns_client zone = shared_zone_test_context.ds_zone - record_data = [{"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] + record_data = [ + {"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] record_json = create_recordset(zone, "@", "DS", record_data, ttl=100) error = client.create_recordset(record_json, status=422) assert_that(error, is_(f'Record with name [{zone["name"]}] is an DS record at apex and cannot be added')) @@ -2087,7 +2208,9 @@ def test_create_dotted_ds_fails(shared_zone_test_context): """ client = shared_zone_test_context.ok_vinyldns_client zone = shared_zone_test_context.ds_zone - record_data = [{"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] + record_data = [ + {"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}] record_json = create_recordset(zone, "dotted.ds", "DS", record_data, ttl=100) error = client.create_recordset(record_json, status=422) - assert_that(error, is_(f'Record with name dotted.ds and type DS is a dotted host which is not allowed in zone {zone["name"]}')) + assert_that(error, + is_(f'Record with name dotted.ds and type DS is a dotted host which is not allowed in zone {zone["name"]}')) diff --git a/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py b/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py index 62d653bdf..cefbea85a 100644 --- a/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py +++ b/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py @@ -1593,7 +1593,7 @@ def test_update_fails_for_unapplied_unsynced_record_change(shared_zone_test_cont ] update_response = client.update_recordset(update_rs, status=202) response = client.wait_until_recordset_change_status(update_response, "Failed") - assert_that(response["systemMessage"], is_(f"Failed validating update to DNS for change {response['id']}:{a_rs['name']}: " + assert_that(response["systemMessage"], is_(f"Failed validating update to DNS for change \"{response['id']}\": \"{a_rs['name']}\": " f"This record set is out of sync with the DNS backend; sync this zone before attempting to update this record set.")) finally: try: diff --git a/modules/api/src/test/functional/tests/shared_zone_test_context.py b/modules/api/src/test/functional/tests/shared_zone_test_context.py index 8d919cb23..1206d608c 100644 --- a/modules/api/src/test/functional/tests/shared_zone_test_context.py +++ b/modules/api/src/test/functional/tests/shared_zone_test_context.py @@ -174,6 +174,15 @@ class SharedZoneTestContext(object): "shared": False, "adminGroupId": self.dummy_group["id"], "isTest": True, + "acl": { + "rules": [ + { + "accessLevel": "Delete", + "description": "some_test_rule", + "userId": "history-id" + } + ] + }, "connection": { "name": "dummy.", "keyName": VinylDNSTestContext.dns_key_name, diff --git a/modules/api/src/test/functional/tests/zones/list_zones_test.py b/modules/api/src/test/functional/tests/zones/list_zones_test.py index 1279f5b2f..1caf96e31 100644 --- a/modules/api/src/test/functional/tests/zones/list_zones_test.py +++ b/modules/api/src/test/functional/tests/zones/list_zones_test.py @@ -23,6 +23,44 @@ def test_list_zones_success(list_zone_context, shared_zone_test_context): assert_that(result["nameFilter"], is_(f"*{shared_zone_test_context.partition_id}")) +def test_list_zones_by_admin_group_name(list_zone_context, shared_zone_test_context): + """ + Test that we can retrieve list of zones by searching with admin group name + """ + result = shared_zone_test_context.list_zones_client.list_zones(name_filter=f"list-zones-group{shared_zone_test_context.partition_id}", search_by_admin_group=True, status=200) + retrieved = result["zones"] + + assert_that(retrieved, has_length(5)) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone1["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone2["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone3["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.non_search_zone1["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.non_search_zone2["name"]))) + assert_that(retrieved, has_item(has_entry("adminGroupName", list_zone_context.list_zones_group["name"]))) + assert_that(retrieved, has_item(has_entry("backendId", "func-test-backend"))) + + assert_that(result["nameFilter"], is_(f"list-zones-group{shared_zone_test_context.partition_id}")) + + +def test_list_zones_by_admin_group_name_with_wildcard(list_zone_context, shared_zone_test_context): + """ + Test that we can retrieve list of zones by searching with admin group name with wildcard character + """ + result = shared_zone_test_context.list_zones_client.list_zones(name_filter=f"*group{shared_zone_test_context.partition_id}", search_by_admin_group=True, status=200) + retrieved = result["zones"] + + assert_that(retrieved, has_length(5)) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone1["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone2["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.search_zone3["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.non_search_zone1["name"]))) + assert_that(retrieved, has_item(has_entry("name", list_zone_context.non_search_zone2["name"]))) + assert_that(retrieved, has_item(has_entry("adminGroupName", list_zone_context.list_zones_group["name"]))) + assert_that(retrieved, has_item(has_entry("backendId", "func-test-backend"))) + + assert_that(result["nameFilter"], is_(f"*group{shared_zone_test_context.partition_id}")) + + def test_list_zones_max_items_100(shared_zone_test_context): """ Test that the default max items for a list zones request is 100 diff --git a/modules/api/src/test/functional/vinyldns_python.py b/modules/api/src/test/functional/vinyldns_python.py index 63df14233..87c7f8866 100644 --- a/modules/api/src/test/functional/vinyldns_python.py +++ b/modules/api/src/test/functional/vinyldns_python.py @@ -220,7 +220,7 @@ class VinylDNSClient(object): return data - def list_my_groups(self, group_name_filter=None, start_from=None, max_items=200, ignore_access=False, **kwargs): + def list_my_groups(self, group_name_filter=None, start_from=None, max_items=100, ignore_access=False, **kwargs): """ Retrieves my groups :param start_from: the start key of the page @@ -445,7 +445,7 @@ class VinylDNSClient(object): response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs) return data - def list_zones(self, name_filter=None, start_from=None, max_items=None, ignore_access=False, **kwargs): + def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False, ignore_access=False, **kwargs): """ Gets a list of zones that currently exist :return: a list of zones @@ -462,6 +462,9 @@ class VinylDNSClient(object): if max_items: query.append("maxItems=" + str(max_items)) + if search_by_admin_group: + query.append("searchByAdminGroup=" + str(search_by_admin_group)) + if ignore_access: query.append("ignoreAccess=" + str(ignore_access)) diff --git a/modules/api/src/test/scala/vinyldns/api/VinylDNSTestHelpers.scala b/modules/api/src/test/scala/vinyldns/api/VinylDNSTestHelpers.scala index ed0bdc7e7..ffc6054f8 100644 --- a/modules/api/src/test/scala/vinyldns/api/VinylDNSTestHelpers.scala +++ b/modules/api/src/test/scala/vinyldns/api/VinylDNSTestHelpers.scala @@ -19,6 +19,7 @@ package vinyldns.api import com.comcast.ip4s.IpAddress import java.time.{Instant, LocalDateTime, Month, ZoneOffset} import vinyldns.api.config.{BatchChangeConfig, HighValueDomainConfig, LimitsConfig, ManualReviewConfig, ScheduledChangesConfig} +import vinyldns.api.config.{ZoneAuthConfigs, DottedHostsConfig, BatchChangeConfig, HighValueDomainConfig, LimitsConfig, ManualReviewConfig, ScheduledChangesConfig} import vinyldns.api.domain.batch.V6DiscoveryNibbleBoundaries import vinyldns.core.domain.record._ import vinyldns.core.domain.zone._ @@ -40,6 +41,10 @@ trait VinylDNSTestHelpers { val approvedNameServers: List[Regex] = List(new Regex("some.test.ns.")) + val dottedHostsConfig: DottedHostsConfig = DottedHostsConfig(List(ZoneAuthConfigs("dotted.xyz.",List("xyz"),List("dummy"),List("CNAME"), 3), ZoneAuthConfigs("abc.zone.recordsets.",List("locked"),List("dummy"),List("CNAME"), 3), ZoneAuthConfigs("xyz.",List("super"),List("xyz"),List("CNAME"), 3), ZoneAuthConfigs("dot.xyz.",List("super"),List("xyz"),List("CNAME"), 0))) + + val emptyDottedHostsConfig: DottedHostsConfig = DottedHostsConfig(List.empty) + val defaultTtl: Long = 7200 val manualReviewDomainList: List[Regex] = List(new Regex("needs-review.*")) diff --git a/modules/api/src/test/scala/vinyldns/api/backend/dns/DnsConversionsSpec.scala b/modules/api/src/test/scala/vinyldns/api/backend/dns/DnsConversionsSpec.scala index 2b405ae7f..fc0f6c23d 100644 --- a/modules/api/src/test/scala/vinyldns/api/backend/dns/DnsConversionsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/backend/dns/DnsConversionsSpec.scala @@ -398,9 +398,9 @@ class DnsConversionsSpec verifyMatch(result, testLongTXT) } - "fail to convert a bad SPF record set" in { - val result = toDnsRRset(testLongSPF, testZoneName).left.value - result shouldBe a[java.lang.IllegalArgumentException] + "convert long SPF record set" in { + val result = toDnsRRset(testLongSPF, testZoneName).right.value + verifyMatch(result, testLongSPF) } } @@ -514,6 +514,9 @@ class DnsConversionsSpec "convert to/from RecordType TXT long TXT record data" in { verifyMatch(testLongTXT, roundTrip(testLongTXT)) } + "convert to/from RecordType SPF long SPF record data" in { + verifyMatch(testLongSPF, roundTrip(testLongSPF)) + } } "Converting to DNS RecordType" should { 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 b547dec43..3101f9571 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.{InvalidDomainName, InvalidLength} +import vinyldns.core.domain.{InvalidDomainName, InvalidCname, InvalidLength} class DomainValidationsSpec extends AnyPropSpec @@ -111,4 +110,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/BatchChangeConverterSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeConverterSpec.scala index 1483dc333..bd9b457f5 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeConverterSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeConverterSpec.scala @@ -38,13 +38,15 @@ import vinyldns.core.domain.record._ import vinyldns.core.domain.zone.Zone class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelpers { + private val notExistCompletedMessage: String = "This record does not exist." + + "No further action is required." private def makeSingleAddChange( - name: String, - recordData: RecordData, - typ: RecordType = A, - zone: Zone = okZone - ) = { + name: String, + recordData: RecordData, + typ: RecordType = A, + zone: Zone = okZone + ) = { val fqdn = s"$name.${zone.name}" SingleAddChange( Some(zone.id), @@ -78,10 +80,10 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper } private def makeAddChangeForValidation( - recordName: String, - recordData: RecordData, - typ: RecordType = RecordType.A - ): AddChangeForValidation = + recordName: String, + recordData: RecordData, + typ: RecordType = RecordType.A + ): AddChangeForValidation = AddChangeForValidation( okZone, s"$recordName", @@ -90,9 +92,9 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper ) private def makeDeleteRRSetChangeForValidation( - recordName: String, - typ: RecordType = RecordType.A - ): DeleteRRSetChangeForValidation = + recordName: String, + typ: RecordType = RecordType.A + ): DeleteRRSetChangeForValidation = DeleteRRSetChangeForValidation( okZone, s"$recordName", @@ -161,6 +163,14 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper makeAddChangeForValidation("mxToUpdate", MXData(1, Fqdn("update.com.")), MX) ) + private val singleChangesOneDelete = List( + makeSingleDeleteRRSetChange("DoesNotExistToDelete", A) + ) + + private val changeForValidationOneDelete = List( + makeDeleteRRSetChangeForValidation("DoesNotExistToDelete", A) + ) + private val singleChangesOneBad = List( makeSingleAddChange("one", AData("1.1.1.1")), makeSingleAddChange("two", AData("1.1.1.2")), @@ -536,6 +546,42 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper savedBatch shouldBe Some(returnedBatch) } + "set status to complete when deleting a record that does not exist" in { + val batchWithBadChange = + BatchChange( + okUser.id, + okUser.userName, + None, + DateTime.now, + singleChangesOneDelete, + approvalStatus = BatchChangeApprovalStatus.AutoApproved + ) + val result = rightResultOf( + underTest + .sendBatchForProcessing( + batchWithBadChange, + existingZones, + ChangeForValidationMap(changeForValidationOneDelete.map(_.validNel), existingRecordSets), + None + ) + .value + ) + + val returnedBatch = result.batchChange + + // validate completed status returned + val receivedChange = returnedBatch.changes(0) + receivedChange.status shouldBe SingleChangeStatus.Complete + receivedChange.recordChangeId shouldBe None + receivedChange.systemMessage shouldBe Some(notExistCompletedMessage) + returnedBatch.changes(0) shouldBe singleChangesOneDelete(0).copy(systemMessage = Some(notExistCompletedMessage), status = SingleChangeStatus.Complete) + + // check the update has been made in the DB + val savedBatch: Option[BatchChange] = + await(batchChangeRepo.getBatchChange(batchWithBadChange.id)) + savedBatch shouldBe Some(returnedBatch) + } + "return error if an unsupported record is received" in { val batchChangeUnsupported = BatchChange( @@ -678,11 +724,11 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper } private def validateRecordSetChange( - name: String, - recordSetChanges: List[RecordSetChange], - batchChange: BatchChange, - typ: RecordSetChangeType - ) = { + name: String, + recordSetChanges: List[RecordSetChange], + batchChange: BatchChange, + typ: RecordSetChangeType + ) = { val singleChangesOut = batchChange.changes.filter { change => change.recordName match { case Some(rn) if rn == name => true @@ -709,10 +755,10 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper } private def validateRecordDataCombination( - name: String, - recordSetChanges: List[RecordSetChange], - batchChange: BatchChange - ) = { + name: String, + recordSetChanges: List[RecordSetChange], + batchChange: BatchChange + ) = { val singleChangesOut = batchChange.changes.filter { change => change.recordName match { case Some(rn) if rn == name => true @@ -729,4 +775,4 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper recordChangeOut.recordSet.records should contain theSameElementsAs expectedRecords } -} +} \ No newline at end of file diff --git a/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeServiceSpec.scala index 97fe3de0b..e184c8c6b 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeServiceSpec.scala @@ -56,7 +56,7 @@ import vinyldns.api.domain.access.AccessValidations import scala.concurrent.ExecutionContext class BatchChangeServiceSpec - extends AnyWordSpec + extends AnyWordSpec with Matchers with MockitoSugar with CatsHelpers @@ -67,8 +67,8 @@ class BatchChangeServiceSpec private implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) - private val nonFatalError = ZoneDiscoveryError("test") - private val fatalError = RecordAlreadyExists("test") + private val nonFatalErrorZoneDiscoveryError = ZoneDiscoveryError("test") + private val nonFatalErrorRecordAlreadyExists = RecordAlreadyExists("test", AData("1.1.1.1"), true) private val validations = new BatchChangeValidations( new AccessValidations( @@ -748,7 +748,7 @@ class BatchChangeServiceSpec "succeed if the batchChange is PendingReview and reviewer is authorized" in { batchChangeRepo.save(batchChangeNeedsApproval) - val result = + val result = { rightResultOf( underTestManualEnabled .approveBatchChange( @@ -758,6 +758,7 @@ class BatchChangeServiceSpec ) .value ) + } result.userId shouldBe batchChangeNeedsApproval.userId result.userName shouldBe batchChangeNeedsApproval.userName @@ -1709,8 +1710,8 @@ class BatchChangeServiceSpec BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete), Some("owner-group-ID")), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, true @@ -1746,7 +1747,7 @@ class BatchChangeServiceSpec None, None, None, - List(SingleChangeError(nonFatalError)), + List(SingleChangeError(nonFatalErrorZoneDiscoveryError)), result.changes(1).id ) result.changes(2) shouldBe SingleDeleteRRSetChange( @@ -1760,7 +1761,7 @@ class BatchChangeServiceSpec None, None, None, - List(SingleChangeError(nonFatalError)), + List(SingleChangeError(nonFatalErrorZoneDiscoveryError)), result.changes(2).id ) } @@ -1776,8 +1777,8 @@ class BatchChangeServiceSpec ), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, allowManualReview = true @@ -1810,7 +1811,7 @@ class BatchChangeServiceSpec List( ZoneDiscoveryError("no.zone.match.").invalidNel, AddChangeForValidation(baseZone, "non-apex", nonApexAddA, 7200L).validNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, true @@ -1826,7 +1827,7 @@ class BatchChangeServiceSpec ibcr.changeRequestResponses(1) shouldBe Valid( AddChangeForValidation(baseZone, "non-apex", nonApexAddA, 7200L) ) - ibcr.changeRequestResponses(2) should haveInvalid[DomainValidationError](nonFatalError) + ibcr.changeRequestResponses(2) should haveInvalid[DomainValidationError](nonFatalErrorZoneDiscoveryError) } "return a BatchChangeErrorList if all data inputs are valid/soft failures and manual review is disabled" in { @@ -1836,8 +1837,8 @@ class BatchChangeServiceSpec BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, true @@ -1861,8 +1862,8 @@ class BatchChangeServiceSpec ), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, true @@ -1903,8 +1904,8 @@ class BatchChangeServiceSpec BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, false @@ -1927,8 +1928,8 @@ class BatchChangeServiceSpec ), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel, + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, allowManualReview = false @@ -1946,7 +1947,7 @@ class BatchChangeServiceSpec BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA), None), List( AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, - nonFatalError.invalidNel + nonFatalErrorZoneDiscoveryError.invalidNel ), okAuth, true @@ -2008,7 +2009,7 @@ class BatchChangeServiceSpec asAdds.head, 7200L ).validNel, - fatalError.invalidNel + nonFatalErrorRecordAlreadyExists.invalidNel ), reviewInfo ) @@ -2559,4 +2560,4 @@ class BatchChangeServiceSpec ) } } -} +} \ No newline at end of file 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 0b9d77322..85960b107 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 @@ -43,7 +43,7 @@ import java.time.temporal.ChronoUnit import scala.util.Random class BatchChangeValidationsSpec - extends AnyPropSpec + extends AnyPropSpec with Matchers with ScalaCheckDrivenPropertyChecks with EitherMatchers @@ -194,9 +194,9 @@ class BatchChangeValidationsSpec ) private def makeAddUpdateRecord( - recordName: String, - aData: AData = AData("1.2.3.4") - ): AddChangeForValidation = + recordName: String, + aData: AData = AData("1.2.3.4") + ): AddChangeForValidation = AddChangeForValidation( okZone, s"$recordName", @@ -205,9 +205,9 @@ class BatchChangeValidationsSpec ) private def makeDeleteUpdateDeleteRRSet( - recordName: String, - recordData: Option[RecordData] = None - ): DeleteRRSetChangeForValidation = + recordName: String, + recordData: Option[RecordData] = None + ): DeleteRRSetChangeForValidation = DeleteRRSetChangeForValidation( okZone, s"$recordName", @@ -668,7 +668,7 @@ class BatchChangeValidationsSpec } property("""validateAddChangeInput: should fail with InvalidIpv4Address - |if validateRecordData fails for an invalid ipv4 address""".stripMargin) { + |if validateRecordData fails for an invalid ipv4 address""".stripMargin) { val invalidIpv4 = "invalidIpv4:123" val change = AddChangeInput("test.comcast.com.", RecordType.A, ttl, AData(invalidIpv4)) val result = validateAddChangeInput(change, false) @@ -714,7 +714,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 InvalidLength @@ -826,10 +826,10 @@ class BatchChangeValidationsSpec result(0) shouldBe valid result(1) should haveInvalid[DomainValidationError]( - RecordAlreadyExists(existingA.inputChange.inputName) + RecordAlreadyExists(existingA.inputChange.inputName, existingA.inputChange.record, false) ) result(2) should haveInvalid[DomainValidationError]( - RecordAlreadyExists(existingCname.inputChange.inputName) + RecordAlreadyExists(existingCname.inputChange.inputName, existingCname.inputChange.record, false) ).and( haveInvalid[DomainValidationError]( CnameIsNotUniqueError(existingCname.inputChange.inputName, existingCname.inputChange.typ) @@ -1006,7 +1006,7 @@ class BatchChangeValidationsSpec ) } - property("validateChangesWithContext: should fail for update if record does not exist") { + property("validateChangesWithContext: should complete for update if record does not exist") { val deleteRRSet = makeDeleteUpdateDeleteRRSet("deleteRRSet") val deleteRecord = makeDeleteUpdateDeleteRRSet("deleteRecord", Some(AData("1.1.1.1"))) val deleteNonExistentEntry = makeDeleteUpdateDeleteRRSet("ok", Some(AData("1.1.1.1"))) @@ -1028,15 +1028,8 @@ class BatchChangeValidationsSpec ) result(0) shouldBe valid - result(1) should haveInvalid[DomainValidationError]( - RecordDoesNotExist(deleteRRSet.inputChange.inputName) - ) - result(3) should haveInvalid[DomainValidationError]( - RecordDoesNotExist(deleteRecord.inputChange.inputName) - ) - result(3) should haveInvalid[DomainValidationError]( - RecordDoesNotExist(deleteRecord.inputChange.inputName) - ) + result(1) shouldBe valid + result(3) shouldBe valid result(4) shouldBe valid deleteNonExistentEntry.inputChange.record.foreach { record => result(5) should haveInvalid[DomainValidationError]( @@ -1047,7 +1040,7 @@ class BatchChangeValidationsSpec property( """validateChangesWithContext: should succeed for update in shared zone if user belongs to record - | owner group""".stripMargin + | owner group""".stripMargin ) { val existingRecord = sharedZoneRecord.copy( @@ -1082,7 +1075,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should succeed adding a record - |if an existing CNAME with the same name exists but is being deleted""".stripMargin) { + |if an existing CNAME with the same name exists but is being deleted""".stripMargin) { val existingCname = rsOk.copy(name = "deleteRRSet", typ = RecordType.CNAME) val existingCname2 = existingCname.copy(name = "deleteRecord", records = List(CNAMEData(Fqdn("cname.data.")))) @@ -1191,7 +1184,7 @@ class BatchChangeValidationsSpec ) result(0) should haveInvalid[DomainValidationError]( - RecordAlreadyExists(input.inputChange.inputName) + RecordAlreadyExists(input.inputChange.inputName, input.inputChange.record, false) ) } } @@ -1227,7 +1220,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should fail with CnameIsNotUniqueError - |if CNAME record name already exists""".stripMargin) { + |if CNAME record name already exists""".stripMargin) { val addCname = AddChangeForValidation( validZone, "existingCname", @@ -1249,7 +1242,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should succeed for CNAME record - |if there's a duplicate PTR ipv4 record that is being deleted""".stripMargin) { + |if there's a duplicate PTR ipv4 record that is being deleted""".stripMargin) { val addCname = AddChangeForValidation( validIp4ReverseZone, "30", @@ -1277,7 +1270,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should fail with CnameIsNotUniqueError for CNAME record - |if there's a duplicate PTR ipv6 record""".stripMargin) { + |if there's a duplicate PTR ipv6 record""".stripMargin) { val addCname = AddChangeForValidation( validZone, "0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", @@ -1303,7 +1296,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: CNAME record should pass - |if no other changes in batch change have same record name""".stripMargin) { + |if no other changes in batch change have same record name""".stripMargin) { val addA = AddChangeForValidation( okZone, "test", @@ -1338,7 +1331,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: CNAME record should fail - |if another add change in batch change has the same record name""".stripMargin) { + |if another add change in batch change has the same record name""".stripMargin) { val addA = AddChangeForValidation( okZone, "test", @@ -1378,7 +1371,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: both CNAME records should fail - |if there are duplicate CNAME add change inputs""".stripMargin) { + |if there are duplicate CNAME add change inputs""".stripMargin) { val addA = AddChangeForValidation( okZone, "test", @@ -1420,7 +1413,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: both PTR records should succeed - |if there are duplicate PTR add change inputs""".stripMargin) { + |if there are duplicate PTR add change inputs""".stripMargin) { val addA = AddChangeForValidation( okZone, "test", @@ -1453,7 +1446,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should succeed for AddChangeForValidation - |if user has group admin access""".stripMargin) { + |if user has group admin access""".stripMargin) { val addA = AddChangeForValidation( validZone, "valid", @@ -1542,7 +1535,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should fail with RecordNameNotUniqueInBatch for PTR record - |if valid CNAME with same name exists in batch""".stripMargin) { + |if valid CNAME with same name exists in batch""".stripMargin) { val addCname = AddChangeForValidation( validZone, "existing", @@ -1591,7 +1584,7 @@ class BatchChangeValidationsSpec } property( - """validateChangesWithContext: should fail DeleteChangeForValidation with RecordDoesNotExist + """validateChangesWithContext: should complete DeleteChangeForValidation |if record does not exist""".stripMargin ) { val deleteRRSet = makeDeleteUpdateDeleteRRSet("record-does-not-exist") @@ -1608,16 +1601,12 @@ class BatchChangeValidationsSpec None ) - result(0) should haveInvalid[DomainValidationError]( - RecordDoesNotExist(deleteRRSet.inputChange.inputName) - ) - result(1) should haveInvalid[DomainValidationError]( - RecordDoesNotExist(deleteRecord.inputChange.inputName) - ) + result(0) shouldBe valid + result(1) shouldBe valid } property("""validateChangesWithContext: should succeed for DeleteChangeForValidation - |if record set status is Active""".stripMargin) { + |if record set status is Active""".stripMargin) { val deleteA = DeleteRRSetChangeForValidation( validZone, "Active-record-status", @@ -1642,7 +1631,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should succeed for DeleteChangeForValidation - |if user has group admin access"""".stripMargin) { + |if user has group admin access"""".stripMargin) { val deleteA = DeleteRRSetChangeForValidation( validZone, @@ -1664,7 +1653,7 @@ class BatchChangeValidationsSpec } property(""" validateChangesWithContext: should fail for DeleteChangeForValidation - | if user is superUser with no other access""".stripMargin) { + | if user is superUser with no other access""".stripMargin) { val deleteA = DeleteRRSetChangeForValidation( validZone, @@ -1747,7 +1736,7 @@ class BatchChangeValidationsSpec } property("""validateChangesWithContext: should properly process batch that contains - |a CNAME and different type record with the same name""".stripMargin) { + |a CNAME and different type record with the same name""".stripMargin) { val addDuplicateA = AddChangeForValidation( okZone, "test", @@ -2166,7 +2155,7 @@ class BatchChangeValidationsSpec result should haveInvalid[DomainValidationError](InvalidIpv4Address(invalidIp)) } - property("validateChangesWithContext: should fail if MX record in batch already exists") { + property("validateChangesWithContext: should Success if MX record in batch already exists") { val existingMX = rsOk.copy( zoneId = okZone.id, name = "name-conflict", @@ -2187,7 +2176,7 @@ class BatchChangeValidationsSpec false, None ) - result(0) should haveInvalid[DomainValidationError](RecordAlreadyExists("name-conflict.")) + result(0) shouldBe valid } property("validateChangesWithContext: should succeed if duplicate MX records in batch") { @@ -2458,7 +2447,7 @@ class BatchChangeValidationsSpec property( """validateChangesWithContext: should fail validateAddWithContext with - |ZoneDiscoveryError if new record is dotted host but not a TXT record type""".stripMargin + |ZoneDiscoveryError if new record is dotted host but not a TXT record type""".stripMargin ) { val addA = AddChangeForValidation( okZone, @@ -2668,4 +2657,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..923f89959 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) @@ -714,6 +783,59 @@ class MembershipServiceSpec } } + "getGroupChange" should { + "return the single group change" in { + val groupChangeRepoResponse = listOfDummyGroupChanges.take(1).head + doReturn(IO.pure(Option(groupChangeRepoResponse))) + .when(mockGroupChangeRepo) + .getGroupChange(anyString) + + doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val userMap = Seq(dummyUser).map(u => (u.id, u.userName)).toMap + val expected: GroupChangeInfo = + listOfDummyGroupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))).take(1).head + + val result: GroupChangeInfo = + rightResultOf(underTest.getGroupChange(dummyGroup.id, dummyAuth).value) + result shouldBe expected + } + + "return the single group change even if the user is not authorized" in { + val groupChangeRepoResponse = listOfDummyGroupChanges.take(1).head + doReturn(IO.pure(Some(groupChangeRepoResponse))) + .when(mockGroupChangeRepo) + .getGroupChange(anyString) + + doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val userMap = Seq(dummyUser).map(u => (u.id, u.userName)).toMap + val expected: GroupChangeInfo = + listOfDummyGroupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))).take(1).head + + val result: GroupChangeInfo = + rightResultOf(underTest.getGroupChange(dummyGroup.id, okAuth).value) + result shouldBe expected + } + + "return a InvalidGroupRequestError if the group change id is not valid" in { + doReturn(IO.pure(None)) + .when(mockGroupChangeRepo) + .getGroupChange(anyString) + + doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val result = leftResultOf(underTest.getGroupChange(dummyGroup.id, okAuth).value) + result shouldBe a[InvalidGroupRequestError] + } + } + "getGroupActivity" should { "return the group activity" in { val groupChangeRepoResponse = ListGroupChangesResults( @@ -724,8 +846,13 @@ class MembershipServiceSpec .when(mockGroupChangeRepo) .getGroupChanges(anyString, any[Option[String]], anyInt) + doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val userMap = Seq(dummyUser).map(u => (u.id, u.userName)).toMap val expected: List[GroupChangeInfo] = - listOfDummyGroupChanges.map(GroupChangeInfo.apply).take(100) + listOfDummyGroupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))).take(100) val result: ListGroupChangesResponse = rightResultOf(underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value) @@ -744,8 +871,13 @@ class MembershipServiceSpec .when(mockGroupChangeRepo) .getGroupChanges(anyString, any[Option[String]], anyInt) + doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val userMap = Seq(dummyUser).map(u => (u.id, u.userName)).toMap val expected: List[GroupChangeInfo] = - listOfDummyGroupChanges.map(GroupChangeInfo.apply).take(100) + listOfDummyGroupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))).take(100) val result: ListGroupChangesResponse = rightResultOf(underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value) @@ -756,6 +888,19 @@ class MembershipServiceSpec } } + "determine group difference" should { + "return difference between two groups" in { + val groupChange = Seq(okGroupChange, dummyGroupChangeUpdate, okGroupChange.copy(changeType = GroupChangeType.Delete)) + val result: Seq[String] = rightResultOf(underTest.determineGroupDifference(groupChange).value) + // Newly created group's change message + result(0) shouldBe "Group Created." + // Updated group's change message + result(1) shouldBe "Group name changed to 'dummy-group'. Group email changed to 'dummy@test.com'. Group description changed to 'dummy group'. Group admin/s with userId/s (12345-abcde-6789,56789-edcba-1234) added. Group admin/s with userId/s (ok) removed. Group member/s with userId/s (12345-abcde-6789,56789-edcba-1234) added. Group member/s with userId/s (ok) removed." + // Deleted group's change message + result(2) shouldBe "Group Deleted." + } + } + "listAdmins" should { "return a list of admins" in { val testGroup = diff --git a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipValidationsSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipValidationsSpec.scala index 84e7e842c..c59eb2fa0 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipValidationsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipValidationsSpec.scala @@ -96,5 +96,16 @@ class MembershipValidationsSpec } } + + "isGroupChangePresent" should { + "return true when there is a group change present for the requested group change id" in { + isGroupChangePresent(Some(okGroupChange)) should be(right) + } + "return an error when there is a group change present for the requested group change id" in { + val error = leftValue(isGroupChangePresent(None)) + error shouldBe an[InvalidGroupRequestError] + } + + } } } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetChangeSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetChangeSpec.scala index a964cc154..2fdbb4ae9 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetChangeSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetChangeSpec.scala @@ -103,4 +103,15 @@ class RecordSetChangeSpec extends AnyWordSpec with Matchers { result.systemMessage shouldBe None } } + + "for Already exists" should { + "set the system message when provided" in { + val result = pendingCreateAAAA.successful + result.systemMessage shouldBe None + } + "set the system message to none when not provided" in { + val result = pendingCreateAAAA.successful + result.systemMessage shouldBe None + } + } } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala index ad941d9d8..7f704f437 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala @@ -24,6 +24,7 @@ import org.scalatestplus.mockito.MockitoSugar import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import org.scalatest.BeforeAndAfterEach +import vinyldns.api.config.{ZoneAuthConfigs, DottedHostsConfig} import vinyldns.api.{ResultHelpers, VinylDNSTestHelpers} import vinyldns.api.domain.access.AccessValidations import vinyldns.api.domain.record.RecordSetHelpers._ @@ -83,6 +84,7 @@ class RecordSetServiceSpec mockBackendResolver, false, VinylDNSTestHelpers.highValueDomainConfig, + VinylDNSTestHelpers.dottedHostsConfig, VinylDNSTestHelpers.approvedNameServers, true ) @@ -101,10 +103,57 @@ class RecordSetServiceSpec mockBackendResolver, true, VinylDNSTestHelpers.highValueDomainConfig, + VinylDNSTestHelpers.dottedHostsConfig, VinylDNSTestHelpers.approvedNameServers, true ) + val underTestWithEmptyDottedHostsConfig = new RecordSetService( + mockZoneRepo, + mockGroupRepo, + mockRecordRepo, + mockRecordDataRepo, + mockRecordChangeRepo, + mockUserRepo, + mockMessageQueue, + new AccessValidations( + sharedApprovedTypes = VinylDNSTestHelpers.sharedApprovedTypes + ), + mockBackendResolver, + true, + VinylDNSTestHelpers.highValueDomainConfig, + VinylDNSTestHelpers.emptyDottedHostsConfig, + VinylDNSTestHelpers.approvedNameServers, + true + ) + + def getDottedHostsConfigGroupsAllowed(zone: Zone, config: DottedHostsConfig): List[String] = { + val configZones = config.zoneAuthConfigs.map(x => x.zone) + val zoneName = if(zone.name.takeRight(1) != ".") zone.name + "." else zone.name + val dottedZoneConfig = configZones.filter(_.contains("*")).map(_.replace("*", "[A-Za-z.]*")) + val isContainWildcardZone = dottedZoneConfig.exists(x => zoneName.substring(0, zoneName.length - 1).matches(x)) + val isContainNormalZone = configZones.contains(zoneName) + val groups = if (isContainWildcardZone || isContainNormalZone) { + config.zoneAuthConfigs.flatMap { + x: ZoneAuthConfigs => + if (x.zone.contains("*")) { + val wildcardZone = x.zone.replace("*", "[A-Za-z.]*") + if (zoneName.substring(0, zoneName.length - 1).matches(wildcardZone)) x.groupList else List.empty + } else { + if (x.zone == zoneName) x.groupList else List.empty + } + } + } + else { + List.empty + } + groups + } + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(okZone, VinylDNSTestHelpers.dottedHostsConfig) + "addRecordSet" should { "return the recordSet change as the result" in { val record = aaaa.copy(zoneId = okZone.id) @@ -115,6 +164,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( @@ -132,7 +202,6 @@ class RecordSetServiceSpec val result = leftResultOf(underTest.getRecordSetByZone(aaaa.id, mockZone.id, okAuth).value) result shouldBe a[ZoneNotFoundError] } - "fail when the account is not authorized" in { doReturn(IO.pure(Some(aaaa))) .when(mockRecordRepo) @@ -155,7 +224,7 @@ class RecordSetServiceSpec val result = leftResultOf(underTest.addRecordSet(aaaa, okAuth).value) result shouldBe a[RecordSetAlreadyExists] } - "fail if the record is dotted" in { + "fail if the record is dotted and does not satisfy properties in dotted hosts config" in { val record = aaaa.copy(name = "new.name", zoneId = okZone.id, status = RecordSetStatus.Active) @@ -165,10 +234,66 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result = leftResultOf(underTest.addRecordSet(record, okAuth).value) result shouldBe an[InvalidRequest] } + "fail if the record is dotted and dotted hosts config is empty" in { + val record = + aaaa.copy(name = "new.name", zoneId = okZone.id, status = RecordSetStatus.Active) + + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(okZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByNames(Set.empty) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + + val result = leftResultOf(underTestWithEmptyDottedHostsConfig.addRecordSet(record, okAuth).value) + result shouldBe an[InvalidRequest] + } "fail if the record is relative with trailing dot" in { val record = aaaa.copy(name = "new.", zoneId = okZone.id, status = RecordSetStatus.Active) @@ -179,6 +304,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result = leftResultOf(underTestWithDnsBackendValidations.addRecordSet(record, okAuth).value) @@ -204,6 +350,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -222,6 +389,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -259,6 +447,27 @@ class RecordSetServiceSpec doReturn(IO.pure(Some(okGroup))) .when(mockGroupRepo) .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -312,6 +521,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( @@ -326,6 +556,296 @@ class RecordSetServiceSpec result.status shouldBe RecordSetChangeStatus.Pending } } + "succeed if the record is dotted and zone, user, record type is in allowed dotted hosts config" in { + val record = + cname.copy(name = "new.name", zoneId = dottedZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(dottedZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(dottedZone))).when(mockZoneRepo).getZone(dottedZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(dottedZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(dottedZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + dottedZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + dottedZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + dottedZone.name).toSet) + doReturn(IO.pure(Set(dummyGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) + .when(mockUserRepo) + .getUsers(dummyGroup.memberIds, None, None) + + // passes as all three properties within dotted hosts config (allowed zones, users and record types) are satisfied + val result: RecordSetChange = rightResultOf( + underTest.addRecordSet(record, xyzAuth).map(_.asInstanceOf[RecordSetChange]).value + ) + + result.recordSet.name shouldBe record.name + } + "succeed if the record is dotted and zone, user in group, record type is in allowed dotted hosts config" in { + val record = + cname.copy(name = "new.name", zoneId = xyzZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(xyzZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(xyzZone))).when(mockZoneRepo).getZone(xyzZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(xyzZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(xyzZone.id, record.name) + doReturn(IO.pure(Set(xyzZone, abcZone, xyzZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + xyzZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + xyzZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + xyzZone.name).toSet) + doReturn(IO.pure(Set(xyzGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(xyzUser), None))) + .when(mockUserRepo) + .getUsers(xyzGroup.memberIds, None, None) + + // passes as all three properties within dotted hosts config (allowed zones, users and record types) are satisfied + val result: RecordSetChange = rightResultOf( + underTest.addRecordSet(record, xyzAuth).map(_.asInstanceOf[RecordSetChange]).value + ) + + result.recordSet.name shouldBe record.name + } + "fail if the record is dotted and zone, user in group, record type is allowed but record name has dot in the end and is not an apex record" in { + val record = + cname.copy(name = "new.name.", zoneId = xyzZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(xyzZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(xyzZone))).when(mockZoneRepo).getZone(xyzZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(xyzZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(xyzZone.id, record.name) + doReturn(IO.pure(Set(xyzZone, abcZone, xyzZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + xyzZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + xyzZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + xyzZone.name).toSet) + doReturn(IO.pure(Set(xyzGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(xyzUser), None))) + .when(mockUserRepo) + .getUsers(xyzGroup.memberIds, None, None) + + // fails as dotted host record name has dot at the end and is not an apex record + val result = leftResultOf(underTest.addRecordSet(record, xyzAuth).value) + result shouldBe an[InvalidRequest] + } + "fail if the record is dotted and zone, user, record type is allowed but number of dots allowed in config is 0" in { + val record = + cname.copy(name = "new.name", zoneId = dotZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(dottedZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(dotZone))).when(mockZoneRepo).getZone(dotZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(dotZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(dotZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + dotZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + dotZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + dotZone.name).toSet) + doReturn(IO.pure(Set(dummyGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) + .when(mockUserRepo) + .getUsers(dummyGroup.memberIds, None, None) + + // fails as no.of.dots allowed for the zone in the config is 0 + val result = leftResultOf(underTest.addRecordSet(record, xyzAuth).value) + result shouldBe an[InvalidRequest] + } + "fail if the record is dotted and user, record type is in allowed dotted hosts config except zone" in { + val record = + cname.copy(name = "new.name", zoneId = okZone.id, status = RecordSetStatus.Active) + + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(okZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(okZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + + // fails as only two properties within dotted hosts config (users and record types) are satisfied while zone is not allowed + val result = leftResultOf(underTest.addRecordSet(record, okAuth).value) + result shouldBe an[InvalidRequest] + } + "fail if the record is dotted and zone, record type is in allowed dotted hosts config except user" in { + val record = + cname.copy(name = "new.name", zoneId = abcZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(abcZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(abcZone))).when(mockZoneRepo).getZone(abcZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(abcZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(abcZone.id, record.name) + doReturn(IO.pure(Set(abcZone, dottedZone, xyzZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + abcZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + abcZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + abcZone.name).toSet) + doReturn(IO.pure(Set(dummyGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) + .when(mockUserRepo) + .getUsers(dummyGroup.memberIds, None, None) + + // fails as only two properties within dotted hosts config (zones and record types) are satisfied while user is not allowed + val result = leftResultOf(underTest.addRecordSet(record, abcAuth).value) + result shouldBe an[InvalidRequest] + } + "fail if the record is dotted and zone, user is in allowed dotted hosts config except record type" in { + val record = + aaaa.copy(name = "new.name", zoneId = dottedZone.id, status = RecordSetStatus.Active) + + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map { + case y:ZoneAuthConfigs => y.zone + } + + val dottedHostsConfigGroupsAllowed: List[String] = getDottedHostsConfigGroupsAllowed(dottedZone, VinylDNSTestHelpers.dottedHostsConfig) + + doReturn(IO.pure(Some(dottedZone))).when(mockZoneRepo).getZone(dottedZone.id) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSets(dottedZone.id, record.name, record.typ) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByName(dottedZone.id, record.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(record.name + "." + dottedZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(record.name + "." + dottedZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(record.name.split('.').map(x => x + "." + dottedZone.name).toSet) + doReturn(IO.pure(Set(dummyGroup))) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) + .when(mockUserRepo) + .getUsers(dummyGroup.memberIds, None, None) + + // fails as only two properties within dotted hosts config (zone and user) are satisfied while record type is not allowed + val result = leftResultOf(underTest.addRecordSet(record, xyzAuth).value) + result shouldBe an[InvalidRequest] + } "updateRecordSet" should { "return the recordSet change as the result" in { @@ -341,6 +861,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -377,6 +918,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(newRecord.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -416,6 +978,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(newRecord.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -438,6 +1021,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(newRecord.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -460,6 +1064,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List())) .when(mockRecordRepo) .getRecordSetsByName(okZone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(newRecord.name.split('.').map(x => x + "." + okZone.name).toSet) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result: RecordSetChange = rightResultOf( underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value @@ -595,6 +1220,27 @@ class RecordSetServiceSpec doReturn(IO.pure(Some(oneUserDummyGroup))) .when(mockGroupRepo) .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result = rightResultOf( underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value @@ -624,6 +1270,27 @@ class RecordSetServiceSpec doReturn(IO.pure(List(oldRecord))) .when(mockRecordRepo) .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) val result = rightResultOf( underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala index a2f1b9c9a..703dc386e 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala @@ -45,6 +45,8 @@ class RecordSetValidationsSpec import RecordSetValidations._ + val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone) + "RecordSetValidations" should { "validRecordTypes" should { "return invalid request when adding a PTR record to a forward zone" in { @@ -185,24 +187,75 @@ class RecordSetValidationsSpec } } + "isDotted" should { + "return a failure for any record with dotted hosts if it is already present" in { + val test = aaaa.copy(name = "this.is.a.failure.") + leftValue(isDotted(test, okZone, None, false, true)) shouldBe an[InvalidRequest] + } + + "return a failure for any record that is a dotted host if user or record type is not allowed" in { + val test = aaaa.copy(name = "this.is.a.failure." + okZone.name) + leftValue(isDotted(test, okZone, None, true, false)) shouldBe an[InvalidRequest] + } + + "return success for a dotted record if it does not already have a record or zone with same name and user is allowed" in { + val test = aaaa.copy(name = "this.passes") + isDotted(test, okZone, None, true, true) should be(right) + } + + "return success for a new record that has the same name as the existing record" in { + val newRecord = aaaa.copy(name = "dot.ted") + val existingRecord = newRecord.copy(ttl = 330) + + isDotted(newRecord, okZone, Some(existingRecord), true, true) should be(right) + } + } + "typeSpecificValidations" should { "Run dotted hosts checks" should { val dottedARecord = rsOk.copy(name = "this.is.a.failure.") "return a failure for any new record with dotted hosts in forward zones" in { leftValue( - typeSpecificValidations(dottedARecord, List(), okZone, None, Nil) + typeSpecificValidations(dottedARecord, List(), okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) ) shouldBe an[InvalidRequest] } "return a failure for any new record with dotted hosts in forward zones (CNAME)" in { leftValue( - typeSpecificValidations(dottedARecord.copy(typ = CNAME), List(), okZone, None, Nil) + typeSpecificValidations(dottedARecord.copy(typ = CNAME), List(), okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) ) shouldBe an[InvalidRequest] } "return a failure for any new record with dotted hosts in forward zones (NS)" in { leftValue( - typeSpecificValidations(dottedARecord.copy(typ = NS), List(), okZone, None, Nil) + typeSpecificValidations(dottedARecord.copy(typ = NS), List(), okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) + ) shouldBe an[InvalidRequest] + } + + "return a success for any new record with dotted hosts in forward zones if it satisfies dotted hosts configs" in { + // Zone, User, Record Type and Number of dots are all satisfied + val record = typeSpecificValidations(dottedARecord.copy(typ = CNAME, zoneId = dottedZone.id), List(), dottedZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, true, 5) + record should be(right) + } + + "return a failure for any new record with dotted hosts if no.of.dots allowed is 0" in { + // Zone, User, Record Type and Number of dots are all satisfied + leftValue( + typeSpecificValidations(dottedARecord.copy(typ = CNAME, zoneId = dottedZone.id), List(), dottedZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, true, 0) + ) shouldBe an[InvalidRequest] + } + + "return a failure for any new record with dotted hosts in forward zones (A record) if it doesn't satisfy dotted hosts configs" in { + // 'A' record is not allowed in the config + leftValue( + typeSpecificValidations(dottedARecord.copy(zoneId = dottedZone.id), List(), dottedZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false, 5) + ) shouldBe an[InvalidRequest] + } + + "return a failure for any new record with dotted hosts in forward zones (NS record) if it doesn't satisfy dotted hosts configs" in { + // 'NS' record is not allowed in the config + leftValue( + typeSpecificValidations(dottedARecord.copy(typ = NS, zoneId = dottedZone.id), List(), dottedZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false, 5) ) shouldBe an[InvalidRequest] } @@ -212,7 +265,10 @@ class RecordSetValidationsSpec List(), okZone, Some(dottedARecord.copy(ttl = 300)), - Nil + Nil, + true, + dottedHostsConfigZonesAllowed.toSet, + false ) should be(right) } @@ -223,7 +279,10 @@ class RecordSetValidationsSpec List(), okZone, Some(dottedCNAMERecord.copy(ttl = 300)), - Nil + Nil, + true, + dottedHostsConfigZonesAllowed.toSet, + false ) should be(right) } @@ -235,7 +294,10 @@ class RecordSetValidationsSpec List(), okZone, Some(dottedNSRecord.copy(ttl = 300)), - Nil + Nil, + true, + dottedHostsConfigZonesAllowed.toSet, + false ) ) shouldBe an[InvalidRequest] } @@ -246,35 +308,35 @@ class RecordSetValidationsSpec val test = srv.copy(name = "_sip._tcp.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success for an SRV record following convention without FQDN" in { val test = srv.copy(name = "_sip._tcp") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success for an SRV record following convention with a record name" in { val test = srv.copy(name = "_sip._tcp.foo.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success on a wildcard SRV that follows convention" in { val test = srv.copy(name = "*._tcp.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success on a wildcard in second position SRV that follows convention" in { val test = srv.copy(name = "_sip._*.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } } "Skip dotted checks on NAPTR" should { @@ -282,21 +344,21 @@ class RecordSetValidationsSpec val test = naptr.copy(name = "sub.naptr.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success for an NAPTR record without FQDN" in { val test = naptr.copy(name = "sub.naptr") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return success on a wildcard NAPTR" in { val test = naptr.copy(name = "*.sub.naptr.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } } @@ -305,7 +367,7 @@ class RecordSetValidationsSpec val test = ptrIp4.copy(name = "10.1.2.") val zone = zoneIp4.copy(name = "198.in-addr.arpa.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } } "Skip dotted checks on TXT" should { @@ -313,7 +375,7 @@ class RecordSetValidationsSpec val test = txt.copy(name = "sub.txt.example.com.") val zone = okZone.copy(name = "example.com.") - typeSpecificValidations(test, List(), zone, None, Nil) should be(right) + typeSpecificValidations(test, List(), zone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } } @@ -330,7 +392,7 @@ class RecordSetValidationsSpec List(SOAData(Fqdn("something"), "other", 1, 2, 3, 5, 6)) ) - typeSpecificValidations(test, List(), zoneIp4, None, Nil) should be(right) + typeSpecificValidations(test, List(), zoneIp4, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } } } @@ -343,29 +405,29 @@ class RecordSetValidationsSpec records = List(NSData(Fqdn("some.test.ns."))) ) - nsValidations(valid, okZone, None, List(new Regex(".*"))) should be(right) + nsValidations(valid, okZone, None, List(new Regex(".*")), true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return an InvalidRequest if an NS record is '@'" in { - val error = leftValue(nsValidations(invalidNsApexRs, okZone, None, Nil)) + val error = leftValue(nsValidations(invalidNsApexRs, okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if an NS record is the same as the zone" in { val invalid = invalidNsApexRs.copy(name = okZone.name) - val error = leftValue(nsValidations(invalid, okZone, None, Nil)) + val error = leftValue(nsValidations(invalid, okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if the NS record being updated is '@'" in { val valid = invalidNsApexRs.copy(name = "this-is-not-origin-mate") - val error = leftValue(nsValidations(valid, okZone, Some(invalidNsApexRs), Nil)) + val error = leftValue(nsValidations(valid, okZone, Some(invalidNsApexRs), Nil, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if an NS record data is not in the approved server list" in { val ns = invalidNsApexRs.copy(records = List(NSData(Fqdn("not.approved.")))) - val error = leftValue(nsValidations(ns, okZone, None, List(new Regex("not.*")))) + val error = leftValue(nsValidations(ns, okZone, None, List(new Regex("not.*")), true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } } @@ -373,25 +435,35 @@ class RecordSetValidationsSpec "DSValidations" should { val matchingNs = ns.copy(zoneId = ds.zoneId, name = ds.name, ttl = ds.ttl) "return ok if the record is non-origin DS with matching NS" in { - dsValidations(ds, List(matchingNs), okZone) should be(right) + dsValidations(ds, List(matchingNs), okZone, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return an InvalidRequest if a DS record is '@'" in { val apex = ds.copy(name = "@") - val error = leftValue(dsValidations(apex, List(matchingNs), okZone)) + val error = leftValue(dsValidations(apex, List(matchingNs), okZone, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if a DS record is the same as the zone" in { val apex = ds.copy(name = okZone.name) - val error = leftValue(dsValidations(apex, List(matchingNs), okZone)) + val error = leftValue(dsValidations(apex, List(matchingNs), okZone, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if there is no NS matching the record" in { - val error = leftValue(dsValidations(ds, List(), okZone)) + val error = leftValue(dsValidations(ds, List(), okZone, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if the DS is dotted" in { val error = - leftValue(dsValidations(ds.copy(name = "test.dotted"), List(matchingNs), okZone)) + leftValue(dsValidations(ds.copy(name = "test.dotted"), List(matchingNs), okZone, true, dottedHostsConfigZonesAllowed.toSet, false)) + error shouldBe an[InvalidRequest] + } + "return ok if the DS is dotted and zone, user, record type is allowed in dotted hosts config" in { + val record = + dsValidations(ds.copy(name = "dotted.trial", zoneId = dottedZone.id), List(matchingNs), dottedZone, true, dottedHostsConfigZonesAllowed.toSet, true, 5) + record should be(right) + } + "return an InvalidRequest if the DS is dotted and zone, user, record type is allowed in dotted hosts config but has a conflict with existing record or zone" in { + val error = + leftValue(dsValidations(ds.copy(name = "dotted.trial", zoneId = dottedZone.id), List(matchingNs), dottedZone, false, dottedHostsConfigZonesAllowed.toSet, true)) error shouldBe an[InvalidRequest] } } @@ -399,54 +471,64 @@ class RecordSetValidationsSpec "CnameValidations" should { val invalidCnameApexRs: RecordSet = cname.copy(name = "@") "return a RecordSetAlreadyExistsError if a record with the same name exists and creating a cname" in { - val error = leftValue(cnameValidations(cname, List(aaaa), okZone)) + val error = leftValue(cnameValidations(cname, List(aaaa), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe a[RecordSetAlreadyExists] } "return ok if name is not '@'" in { - cnameValidations(cname, List(), okZone) should be(right) + cnameValidations(cname, List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false) should be(right) } "return an InvalidRequest if a cname record set name is '@'" in { - val error = leftValue(cnameValidations(invalidCnameApexRs, List(), okZone)) + val error = leftValue(cnameValidations(invalidCnameApexRs, List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if a cname record set name is same as zone" in { val invalid = invalidCnameApexRs.copy(name = okZone.name) - val error = leftValue(cnameValidations(invalid, List(), okZone)) + val error = leftValue(cnameValidations(invalid, List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if a cname record set name is dotted" in { - val error = leftValue(cnameValidations(cname.copy(name = "dot.ted"), List(), okZone)) + val error = leftValue(cnameValidations(cname.copy(name = "dot.ted"), List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return ok if new recordset name does not contain dot" in { - cnameValidations(cname, List(), okZone, Some(cname.copy(name = "not-dotted"))) should be( + cnameValidations(cname, List(), okZone, Some(cname.copy(name = "not-dotted")), true, dottedHostsConfigZonesAllowed.toSet, false) should be( right ) } "return ok if dotted host name doesn't change" in { val newRecord = cname.copy(name = "dot.ted", ttl = 500) - cnameValidations(newRecord, List(), okZone, Some(newRecord.copy(ttl = 300))) should be( + cnameValidations(newRecord, List(), okZone, Some(newRecord.copy(ttl = 300)), true, dottedHostsConfigZonesAllowed.toSet, false) should be( right ) } "return an InvalidRequest if a cname record set name is updated to '@'" in { - val error = leftValue(cnameValidations(cname.copy(name = "@"), List(), okZone, Some(cname))) + val error = leftValue(cnameValidations(cname.copy(name = "@"), List(), okZone, Some(cname), true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an InvalidRequest if updated cname record set name is same as zone" in { val error = - leftValue(cnameValidations(cname.copy(name = okZone.name), List(), okZone, Some(cname))) + leftValue(cnameValidations(cname.copy(name = okZone.name), List(), okZone, Some(cname), true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[InvalidRequest] } "return an RecordSetValidation error if recordset data contain more than one sequential '.'" in { - val error = leftValue(cnameValidations(cname.copy(records = List(CNAMEData(Fqdn("record..zone")))), List(), okZone)) + val error = leftValue(cnameValidations(cname.copy(records = List(CNAMEData(Fqdn("record..zone")))), List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false)) error shouldBe an[RecordSetValidation] } "return ok if recordset data does not contain sequential '.'" in { - cnameValidations(cname.copy(records = List(CNAMEData(Fqdn("record.zone")))), List(), okZone) should be( + cnameValidations(cname.copy(records = List(CNAMEData(Fqdn("record.zone")))), List(), okZone, None, true, dottedHostsConfigZonesAllowed.toSet, false) should be( right ) } + "return ok if the CNAME is dotted and zone, user, record type is allowed in dotted hosts config" in { + val record = + cnameValidations(cname.copy(name = "dot.ted", zoneId = dottedZone.id), List(), dottedZone, None, true, dottedHostsConfigZonesAllowed.toSet, true, 5) + record should be(right) + } + "return an InvalidRequest if the CNAME is dotted and zone, user, record type is allowed in dotted hosts config but has a conflict with existing record or zone" in { + val error = + leftValue(cnameValidations(cname.copy(name = "dot.ted", zoneId = dottedZone.id), List(), dottedZone, None, false, dottedHostsConfigZonesAllowed.toSet, true)) + error shouldBe an[InvalidRequest] + } } "isNotHighValueDomain" should { diff --git a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionValidatorSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionValidatorSpec.scala index 2ab21e8bb..01dc10bbb 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionValidatorSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionValidatorSpec.scala @@ -242,5 +242,15 @@ class ZoneConnectionValidatorSpec underTest.isValidBackendId(Some("bad")) shouldBe left } } + + "Zone Connection toString" should { + "not display key and algorithm" in { + zc.toString shouldBe "ZoneConnection: [name=\"zc.\"; keyName=\"zc.\"; primaryServer=\"10.1.1.1\"; ]" + } + "not display key and algorithm while displaying connection and transferConnection of a Zone" in { + val zoneString = s"""Zone: [id="${testZone.id}"; name="vinyldns."; account="system"; adminGroupId="system"; status="Active"; shared="false"; connection="Some(ZoneConnection: [name="vinyldns."; keyName="vinyldns."; primaryServer="10.1.1.1"; ])"; transferConnection="Some(ZoneConnection: [name="vinyldns."; keyName="vinyldns."; primaryServer="10.1.1.1"; ])"; reverse="false"; isTest="false"; created="${testZone.created}"; ]""" + testZone.toString shouldBe zoneString + } + } } } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala index c6ab3e935..69faad71a 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala @@ -562,6 +562,45 @@ class ZoneServiceSpec result.ignoreAccess shouldBe true } + "name filter must be used to return zones by admin group name, when search by admin group option is true" in { + doReturn(IO.pure(Set(abcGroup))) + .when(mockGroupRepo) + .getGroupsByName(any[String]) + doReturn(IO.pure(ListZonesResults(List(abcZone), ignoreAccess = true, zonesFilter = Some("abcGroup")))) + .when(mockZoneRepo) + .listZonesByAdminGroupIds(abcAuth, None, 100, Set(abcGroup.id), ignoreAccess = true) + doReturn(IO.pure(Set(abcGroup))).when(mockGroupRepo).getGroups(any[Set[String]]) + + // When searchByAdminGroup is true, zones are filtered by admin group name given in nameFilter + val result: ListZonesResponse = + rightResultOf(underTest.listZones(abcAuth, Some("abcGroup"), None, 100, searchByAdminGroup = true, ignoreAccess = true).value) + result.zones shouldBe List(abcZoneSummary) + result.maxItems shouldBe 100 + result.startFrom shouldBe None + result.nameFilter shouldBe Some("abcGroup") + result.nextId shouldBe None + result.ignoreAccess shouldBe true + } + + "name filter must be used to return zone by zone name, when search by admin group option is false" in { + doReturn(IO.pure(Set(abcGroup))) + .when(mockGroupRepo) + .getGroups(any[Set[String]]) + doReturn(IO.pure(ListZonesResults(List(abcZone), ignoreAccess = true, zonesFilter = Some("abcZone")))) + .when(mockZoneRepo) + .listZones(abcAuth, Some("abcZone"), None, 100, true) + + // When searchByAdminGroup is false, zone name given in nameFilter is returned + val result: ListZonesResponse = + rightResultOf(underTest.listZones(abcAuth, Some("abcZone"), None, 100, searchByAdminGroup = false, ignoreAccess = true).value) + result.zones shouldBe List(abcZoneSummary) + result.maxItems shouldBe 100 + result.startFrom shouldBe None + result.nameFilter shouldBe Some("abcZone") + result.nextId shouldBe None + result.ignoreAccess shouldBe true + } + "return Unknown group name if zone admin group cannot be found" in { doReturn(IO.pure(ListZonesResults(List(abcZone, xyzZone)))) .when(mockZoneRepo) diff --git a/modules/api/src/test/scala/vinyldns/api/engine/RecordSetChangeHandlerSpec.scala b/modules/api/src/test/scala/vinyldns/api/engine/RecordSetChangeHandlerSpec.scala index 92a435da5..5ed73ebc4 100644 --- a/modules/api/src/test/scala/vinyldns/api/engine/RecordSetChangeHandlerSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/engine/RecordSetChangeHandlerSpec.scala @@ -154,7 +154,8 @@ class RecordSetChangeHandlerSpec val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => ch.copy( - status = SingleChangeStatus.Complete, + systemMessage= None, + status = SingleChangeStatus.Complete, recordChangeId = Some(rsChange.id), recordSetId = Some(rsChange.recordSet.id) ) @@ -199,6 +200,7 @@ class RecordSetChangeHandlerSpec val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => ch.copy( + systemMessage= None, status = SingleChangeStatus.Complete, recordChangeId = Some(rsChange.id), recordSetId = Some(rsChange.recordSet.id) @@ -603,6 +605,7 @@ class RecordSetChangeHandlerSpec val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => ch.copy( + systemMessage= None, status = SingleChangeStatus.Complete, recordChangeId = Some(rsChange.id), recordSetId = Some(rsChange.recordSet.id) @@ -644,7 +647,7 @@ class RecordSetChangeHandlerSpec changeSet.status shouldBe RecordSetChangeStatus.Failed changeSet.recordSet.status shouldBe RecordSetStatus.Inactive changeSet.systemMessage shouldBe Some( - s"Failed validating update to DNS for change ${changeSet.id}:${changeSet.recordSet.name}: " + + s"""Failed validating update to DNS for change "${changeSet.id}": "${changeSet.recordSet.name}": """ + s"This record set is out of sync with the DNS backend; sync this zone before attempting to " + "update this record set." ) diff --git a/modules/api/src/test/scala/vinyldns/api/repository/EmptyRepositories.scala b/modules/api/src/test/scala/vinyldns/api/repository/EmptyRepositories.scala index a1239cc71..70893e3de 100644 --- a/modules/api/src/test/scala/vinyldns/api/repository/EmptyRepositories.scala +++ b/modules/api/src/test/scala/vinyldns/api/repository/EmptyRepositories.scala @@ -84,6 +84,14 @@ trait EmptyZoneRepo extends ZoneRepository { def getZoneByName(zoneName: String): IO[Option[Zone]] = IO.pure(None) + def listZonesByAdminGroupIds( + authPrincipal: AuthPrincipal, + startFrom: Option[String] = None, + maxItems: Int = 100, + adminGroupIds: Set[String], + ignoreAccess: Boolean = false + ): IO[ListZonesResults] = IO.pure(ListZonesResults()) + def listZones( authPrincipal: AuthPrincipal, zoneNameFilter: Option[String] = None, @@ -111,8 +119,12 @@ trait EmptyGroupRepo extends GroupRepository { def getGroups(groupIds: Set[String]): IO[Set[Group]] = IO.pure(Set()) + def getGroupsByName(groupNames: Set[String]): IO[Set[Group]] = IO.pure(Set()) + def getGroupByName(groupName: String): IO[Option[Group]] = IO.pure(None) + def getGroupsByName(groupName: String): IO[Set[Group]] = IO.pure(Set()) + def getAllGroups(): IO[Set[Group]] = IO.pure(Set()) } diff --git a/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala index 510a10d5e..2289495f0 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala @@ -730,6 +730,37 @@ class MembershipRoutingSpec } } + "GET group change" should { + "return a 200 response with the group change when found" in { + val grpChange = GroupChangeInfo(okGroupChange) + doReturn(result(grpChange)).when(membershipService).getGroupChange("ok", okAuth) + Get("/groups/change/ok") ~> Route.seal(membershipRoute) ~> check { + status shouldBe StatusCodes.OK + + val result = responseAs[GroupChangeInfo] + result shouldBe grpChange + } + } + + "return a 400 Bad Request when the group change id is not valid" in { + doReturn(result(InvalidGroupRequestError("Invalid Group Change ID"))) + .when(membershipService) + .getGroupChange("notValid", okAuth) + Get("/groups/change/notValid") ~> Route.seal(membershipRoute) ~> check { + status shouldBe StatusCodes.BadRequest + } + } + + "return a 500 response on failure" in { + doReturn(result(new RuntimeException("fail"))) + .when(membershipService) + .getGroupChange("bad", okAuth) + Get(s"/groups/change/bad") ~> Route.seal(membershipRoute) ~> check { + status shouldBe StatusCodes.InternalServerError + } + } + } + "PUT update user lock status" should { "return a 200 response with the user locked" in { membershipRoute = superUserRoute diff --git a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala index 38927d2d8..f3750a5b6 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala @@ -124,7 +124,7 @@ class ZoneRoutingSpec ("status" -> "invalidStatus") ~~ ("adminGroupId" -> "admin-group-id") - private val zoneCreate = ZoneChange(ok, "ok", ZoneChangeType.Create, ZoneChangeStatus.Complete) + private val zoneCreate = ZoneChange(ok, "ok", ZoneChangeType.Create, ZoneChangeStatus.Synced) private val listZoneChangeResponse = ListZoneChangesResponse( ok.id, List(zoneCreate, zoneUpdate), @@ -157,7 +157,7 @@ class ZoneRoutingSpec case ok.email | connectionOk.email | trailingDot.email | "invalid-zone-status@test.com" => Right( zoneCreate.copy( - status = ZoneChangeStatus.Complete, + status = ZoneChangeStatus.Synced, zone = Zone(createZoneInput, false).copy(status = ZoneStatus.Active) ) ) @@ -183,7 +183,7 @@ class ZoneRoutingSpec case ok.email | connectionOk.email => Right( zoneUpdate.copy( - status = ZoneChangeStatus.Complete, + status = ZoneChangeStatus.Synced, zone = Zone(updateZoneInput, zoneUpdate.zone).copy(status = ZoneStatus.Active) ) ) @@ -208,7 +208,7 @@ class ZoneRoutingSpec case notFound.id => Left(ZoneNotFoundError(s"$zoneId")) case notAuthorized.id => Left(NotAuthorizedError(s"$zoneId")) case ok.id | connectionOk.id => - Right(ZoneChange(ok, "ok", ZoneChangeType.Delete, ZoneChangeStatus.Complete)) + Right(ZoneChange(ok, "ok", ZoneChangeType.Delete, ZoneChangeStatus.Synced)) case error.id => Left(new RuntimeException("fail")) case zone1.id => Left(ZoneUnavailableError(zoneId)) } @@ -252,6 +252,7 @@ class ZoneRoutingSpec nameFilter: Option[String], startFrom: Option[String], maxItems: Int, + searchByAdminGroup: Boolean = false, ignoreAccess: Boolean = false ): Result[ListZonesResponse] = { @@ -372,7 +373,7 @@ class ZoneRoutingSpec authPrincipal, NoOpCrypto.instance ) - .copy(status = ZoneChangeStatus.Complete) + .copy(status = ZoneChangeStatus.Synced) ) case error.id => Left(new RuntimeException("fail")) } @@ -397,7 +398,7 @@ class ZoneRoutingSpec authPrincipal, NoOpCrypto.instance ) - .copy(status = ZoneChangeStatus.Complete) + .copy(status = ZoneChangeStatus.Synced) ) case error.id => Left(new RuntimeException("fail")) } @@ -920,6 +921,20 @@ class ZoneRoutingSpec } } + "return zones by admin group name when searchByAdminGroup is true" in { + Get(s"/zones?nameFilter=ok&startFrom=zone4.&maxItems=4&searchByAdminGroup=true") ~> zoneRoute ~> check { + val resp = responseAs[ListZonesResponse] + val zones = resp.zones + (zones.map(_.id) should contain) + .only(zone1.id, zone2.id, zone3.id) + resp.nextId shouldBe None + resp.maxItems shouldBe 4 + resp.startFrom shouldBe Some("zone4.") + resp.nameFilter shouldBe Some("ok") + resp.ignoreAccess shouldBe false + } + } + "return all zones when list all is true" in { Get(s"/zones?maxItems=5&ignoreAccess=true") ~> zoneRoute ~> check { val resp = responseAs[ListZonesResponse] 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 91486ce79..1fe3ab85a 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/DomainValidationErrors.scala @@ -52,6 +52,18 @@ final case class InvalidDomainName(param: String) extends DomainValidationError "joined by dots, and terminated with a dot." } +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 = @@ -109,10 +121,15 @@ final case class ZoneDiscoveryError(name: String, fatal: Boolean = false) "If zone exists, then it must be connected to in VinylDNS." } -final case class RecordAlreadyExists(name: String) extends DomainValidationError { - def message: String = - s"""Record "$name" Already Exists: cannot add an existing record; to update it, """ + - "issue a DeleteRecordSet then an Add." +final case class RecordAlreadyExists(name: String, recordData: RecordData, isApproved:Boolean, + fatal: Boolean = false) extends DomainValidationError(fatal) { + def message: String = { + if (isApproved == false) + s"""RecordName "$name" already exists. Your request will be manually reviewed. """ + + "If you intended to update this record, you can avoid manual review by adding " + + " a DeleteRecordSet entry followed by an Add." + else s"""ℹ️ Record data "$recordData" is does not exists. + Complete the request in DNS and give approve. """ } } final case class RecordDoesNotExist(name: String) extends DomainValidationError { 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 a7ed47b4e..c7805e74c 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/core/src/main/scala/vinyldns/core/domain/batch/SingleChange.scala b/modules/core/src/main/scala/vinyldns/core/domain/batch/SingleChange.scala index 48d494485..5483820a4 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/batch/SingleChange.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/batch/SingleChange.scala @@ -17,7 +17,6 @@ package vinyldns.core.domain.batch import java.util.UUID - import vinyldns.core.domain.SingleChangeError import vinyldns.core.domain.batch.SingleChangeStatus.SingleChangeStatus import vinyldns.core.domain.record.RecordData @@ -47,6 +46,13 @@ sealed trait SingleChange { delete.copy(status = SingleChangeStatus.Failed, systemMessage = Some(error)) } + def withDoesNotExistMessage(error: String): SingleChange = this match { + case add: SingleAddChange => + add.copy(status = SingleChangeStatus.Failed, systemMessage = Some(error)) + case delete: SingleDeleteRRSetChange => + delete.copy(status = SingleChangeStatus.Complete, systemMessage = Some(error)) + } + def withProcessingError(message: Option[String], failedRecordChangeId: String): SingleChange = this match { case add: SingleAddChange => @@ -63,16 +69,18 @@ sealed trait SingleChange { ) } - def complete(completeRecordChangeId: String, recordSetId: String): SingleChange = this match { + def complete(message: Option[String], completeRecordChangeId: String, recordSetId: String): SingleChange = this match { case add: SingleAddChange => add.copy( status = SingleChangeStatus.Complete, + systemMessage = message, recordChangeId = Some(completeRecordChangeId), recordSetId = Some(recordSetId) ) case delete: SingleDeleteRRSetChange => delete.copy( status = SingleChangeStatus.Complete, + systemMessage = message, recordChangeId = Some(completeRecordChangeId), recordSetId = Some(recordSetId) ) @@ -140,12 +148,18 @@ object SingleChangeStatus extends Enumeration { } case class RecordKey(zoneId: String, recordName: String, recordType: RecordType) +case class RecordKeyData(zoneId: String, recordName: String, recordType: RecordType, recordData: RecordData) object RecordKey { def apply(zoneId: String, recordName: String, recordType: RecordType): RecordKey = new RecordKey(zoneId, recordName.toLowerCase, recordType) } +object RecordKeyData { + def apply(zoneId: String, recordName: String, recordType: RecordType, recordData: RecordData): RecordKeyData = + new RecordKeyData(zoneId, recordName.toLowerCase, recordType, recordData) +} + object OwnerType extends Enumeration { type OwnerType = Value val Record, Zone = Value diff --git a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChange.scala b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChange.scala index 1bb127c67..96096a29d 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChange.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChange.scala @@ -35,7 +35,9 @@ case class GroupChange( userId: String, oldGroup: Option[Group] = None, id: String = UUID.randomUUID().toString, - created: Instant = Instant.now.truncatedTo(ChronoUnit.MILLIS) + created: Instant = Instant.now.truncatedTo(ChronoUnit.MILLIS), + userName: Option[String] = None, + groupChangeMessage: Option[String] = None ) object GroupChange { diff --git a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala index 5b177bd48..408a9345c 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala @@ -24,7 +24,8 @@ import vinyldns.core.repository.Repository trait GroupChangeRepository extends Repository { def save(db: DB, groupChange: GroupChange): IO[GroupChange] - def getGroupChange(groupChangeId: String): IO[Option[GroupChange]] // For testing + def getGroupChange(groupChangeId: String): IO[Option[GroupChange]] + def getGroupChanges( groupId: String, startFrom: Option[String], diff --git a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupRepository.scala index 233a2f6b5..525323ef4 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupRepository.scala @@ -31,8 +31,12 @@ trait GroupRepository extends Repository { def getGroups(groupIds: Set[String]): IO[Set[Group]] + def getGroupsByName(groupNames: Set[String]): IO[Set[Group]] + def getGroupByName(groupName: String): IO[Option[Group]] + def getGroupsByName(groupName: String): IO[Set[Group]] + def getAllGroups(): IO[Set[Group]] } diff --git a/modules/core/src/main/scala/vinyldns/core/domain/zone/Zone.scala b/modules/core/src/main/scala/vinyldns/core/domain/zone/Zone.scala index 36b5384b8..4ed0f2a60 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/zone/Zone.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/zone/Zone.scala @@ -188,6 +188,16 @@ case class ZoneConnection( def decrypted(crypto: CryptoAlgebra): ZoneConnection = copy(key = crypto.decrypt(key)) + + override def toString: String = { + val sb = new StringBuilder + sb.append("ZoneConnection: [") + sb.append("name=\"").append(name).append("\"; ") + sb.append("keyName=\"").append(keyName).append("\"; ") + sb.append("primaryServer=\"").append(primaryServer).append("\"; ") + sb.append("]") + sb.toString + } } final case class LegacyDnsBackend( diff --git a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChange.scala b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChange.scala index 2d854b8f1..287eb2cf6 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChange.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChange.scala @@ -23,7 +23,7 @@ import java.time.temporal.ChronoUnit object ZoneChangeStatus extends Enumeration { type ZoneChangeStatus = Value - val Pending, Complete, Failed, Synced = Value + val Pending, Failed, Synced = Value } object ZoneChangeType extends Enumeration { diff --git a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneRepository.scala index 86a2f289c..ec8e82af9 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneRepository.scala @@ -35,6 +35,14 @@ trait ZoneRepository extends Repository { def getZonesByFilters(zoneNames: Set[String]): IO[Set[Zone]] + def listZonesByAdminGroupIds( + authPrincipal: AuthPrincipal, + startFrom: Option[String] = None, + maxItems: Int = 100, + adminGroupIds: Set[String], + ignoreAccess: Boolean = false + ): IO[ListZonesResults] + def listZones( authPrincipal: AuthPrincipal, zoneNameFilter: Option[String] = None, diff --git a/modules/core/src/test/scala/vinyldns/core/TestMembershipData.scala b/modules/core/src/test/scala/vinyldns/core/TestMembershipData.scala index 59c6df206..d4fa04f8c 100644 --- a/modules/core/src/test/scala/vinyldns/core/TestMembershipData.scala +++ b/modules/core/src/test/scala/vinyldns/core/TestMembershipData.scala @@ -37,6 +37,7 @@ object TestMembershipData { val dummyUser = User("dummyName", "dummyAccess", "dummySecret") val superUser = User("super", "superAccess", "superSecret", isSuper = true) + val xyzUser = User("xyz", "xyzAccess", "xyzSecret") val supportUser = User("support", "supportAccess", "supportSecret", isSupport = true) val lockedUser = User("locked", "lockedAccess", "lockedSecret", lockStatus = LockStatus.Locked) val sharedZoneUser = User("sharedZoneAdmin", "sharedAccess", "sharedSecret") @@ -158,4 +159,13 @@ object TestMembershipData { id = s"$i" ) } + val dummyGroupChangeUpdate: GroupChange = GroupChange( + okGroup.copy(name = "dummy-group", email = "dummy@test.com", description = Some("dummy group"), + memberIds = Set(dummyUser.copy(id="12345-abcde-6789").id, superUser.copy(id="56789-edcba-1234").id), + adminUserIds = Set(dummyUser.copy(id="12345-abcde-6789").id, superUser.copy(id="56789-edcba-1234").id)), + GroupChangeType.Update, + okUser.id, + Some(okGroup), + created = DateTime.now.secondOfDay().roundFloorCopy() + ) } diff --git a/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala b/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala index 7f829ecbc..8392403ae 100644 --- a/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala +++ b/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala @@ -35,6 +35,8 @@ object TestZoneData { adminGroupId = okGroup.id, connection = testConnection ) + val dottedZone: Zone = Zone("dotted.xyz.", "dotted@xyz.com", adminGroupId = xyzGroup.id) + val dotZone: Zone = Zone("dot.xyz.", "dotted@xyz.com", adminGroupId = xyzGroup.id) val abcZone: Zone = Zone("abc.zone.recordsets.", "test@test.com", adminGroupId = abcGroup.id) val xyzZone: Zone = Zone("xyz.", "abc@xyz.com", adminGroupId = xyzGroup.id) val zoneIp4: Zone = Zone("0.162.198.in-addr.arpa.", "test@test.com", adminGroupId = abcGroup.id) @@ -80,11 +82,11 @@ object TestZoneData { okZone, "ok", ZoneChangeType.Create, - ZoneChangeStatus.Complete, + ZoneChangeStatus.Synced, created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000) ) - val zoneUpdate: ZoneChange = zoneChangePending.copy(status = ZoneChangeStatus.Complete) + val zoneUpdate: ZoneChange = zoneChangePending.copy(status = ZoneChangeStatus.Synced) def makeTestPendingZoneChange(zone: Zone): ZoneChange = ZoneChange(zone, "userId", ZoneChangeType.Update, ZoneChangeStatus.Pending) diff --git a/modules/core/src/test/scala/vinyldns/core/domain/zone/ZoneChangeSpec.scala b/modules/core/src/test/scala/vinyldns/core/domain/zone/ZoneChangeSpec.scala index c487e00e6..375441b17 100644 --- a/modules/core/src/test/scala/vinyldns/core/domain/zone/ZoneChangeSpec.scala +++ b/modules/core/src/test/scala/vinyldns/core/domain/zone/ZoneChangeSpec.scala @@ -27,7 +27,7 @@ class ZoneChangeSpec extends AnyWordSpec with Matchers { Zone("test", "test"), "ok", ZoneChangeType.Create, - ZoneChangeStatus.Complete, + ZoneChangeStatus.Synced, created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000) ) diff --git a/modules/core/src/test/scala/vinyldns/core/protobuf/ProtobufConversionsSpec.scala b/modules/core/src/test/scala/vinyldns/core/protobuf/ProtobufConversionsSpec.scala index 18cdd7533..4cdae225d 100644 --- a/modules/core/src/test/scala/vinyldns/core/protobuf/ProtobufConversionsSpec.scala +++ b/modules/core/src/test/scala/vinyldns/core/protobuf/ProtobufConversionsSpec.scala @@ -75,7 +75,7 @@ class ProtobufConversionsSpec zone, "system", ZoneChangeType.Update, - ZoneChangeStatus.Complete, + ZoneChangeStatus.Synced, Instant.now.truncatedTo(ChronoUnit.MILLIS), Some("hello") ) diff --git a/modules/docs/src/main/mdoc/operator/config-api.md b/modules/docs/src/main/mdoc/operator/config-api.md index 2d820849a..fa8b69ba3 100644 --- a/modules/docs/src/main/mdoc/operator/config-api.md +++ b/modules/docs/src/main/mdoc/operator/config-api.md @@ -536,7 +536,66 @@ v6-discovery-nibble-boundaries { min = 5 max = 20 } +``` +### Dotted Hosts + +Configuration setting that determines the zones, users (either individual or based on group) and record types that are +allowed to create dotted hosts. If only all the above are satisfied, one can create a dotted host in VinylDNS. + +Note the following: +1. Zones defined in the `zone` must always end with a dot. Eg: `comcast.com.` +2. Wildcard character `*` can be used in `zone` to allow dotted hosts for all zones matching it. +3. Individual users who are allowed to create dotted hosts are added to the `user-list` using their username. +4. A set of users in a group who are allowed to create dotted hosts are added to the `group-list` using group name. +5. If the user is either in `user-list` or `group-list`, they are allowed to create a dotted host. It is +not necessary for the user to be in both `user-list` and `group-list`. +6. The record types which are allowed while creating a dotted host is added to the `record-types`. +7. The number of dots allowed in a record name for a zone is given in `dots-limit`. +8. If `user-list` is left empty (`user-list = []`), no user will be allowed to create dotted hosts unless +they're present in `group-list` and vice-versa. If both `user-list` and `group-list` is left empty +no users will be allowed to create dotted hosts in that zone. +9. If `record-types` is left empty (`record-types = []`), user cannot create dotted hosts of any record type +in that zone. +10. If `dots-limit` is set to 0 (`dots-limit = 0`), we cannot create dotted hosts record in that zone. + +```yaml +# approved zones, individual users, users in groups, record types and no.of.dots that are allowed for dotted hosts +dotted-hosts = { + allowed-settings = [ + { + zone = "dummy." + user-list = ["testuser"] + group-list = ["dummy-group"] + record-types = ["AAAA"] + dots-limit = 3 + }, + { + # for wildcard zones. Settings will be applied to all matching zones + zone = "*ent.com." + user-list = ["professor", "testuser"] + group-list = ["testing-group"] + record-types = ["A", "CNAME"] + dots-limit = 3 + } + ] +} +``` + +In the above, the dotted hosts can be created only in the zone `dummy.` and zones matching `*ent.com.` (parent.com., child.parent.com.) + +Also, it must satisfy the allowed users or group users and record type of the respective zone to create a dotted host. + +For eg, we can't create a dotted host with `CNAME` record type in the zone `dummy.` as it's not in `record-types`. +And the user `professor` can't create a dotted host in the zone `dummy.` as the user is not in `user-list` or +`group-list` (not part of `dummy-group`). + +The config can be left empty as follows if we don't want to use it: + +```yaml +dotted-hosts = { + allowed-settings = [] +} ``` ### Full Example Config @@ -713,6 +772,27 @@ v6-discovery-nibble-boundaries { } } + # approved zones, individual users, users in groups, record types and no.of.dots that are allowed for dotted hosts + dotted-hosts = { + allowed-settings = [ + { + zone = "dummy." + user-list = ["testuser"] + group-list = ["dummy-group"] + record-types = ["AAAA"] + dots-limit = 3 + }, + { + # for wildcard zones. Settings will be applied to all matching zones + zone = "*ent.com." + user-list = ["professor", "testuser"] + group-list = ["testing-group"] + record-types = ["A", "CNAME"] + dots-limit = 3 + } + ] + } + # true if you want to enable manual review for non-fatal errors manual-batch-review-enabled = true diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlBatchChangeRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlBatchChangeRepositoryIntegrationSpec.scala index ac544d770..6ad03ab21 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlBatchChangeRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlBatchChangeRepositoryIntegrationSpec.scala @@ -116,7 +116,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec val pendingBatchChange: BatchChange = randomBatchChange().copy(createdTimestamp = Instant.now.truncatedTo(ChronoUnit.MILLIS)) val completeBatchChange: BatchChange = randomBatchChangeWithList( - randomBatchChange().changes.map(_.complete("recordChangeId", "recordSetId")) + randomBatchChange().changes.map(_.complete(Some("Complete"),"recordChangeId", "recordSetId")) ).copy(createdTimestamp = Instant.now.truncatedTo(ChronoUnit.MILLIS).plusMillis(1000)) val failedBatchChange: BatchChange = @@ -124,7 +124,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec .copy(createdTimestamp = Instant.now.truncatedTo(ChronoUnit.MILLIS).plusMillis(100000)) val partialFailureBatchChange: BatchChange = randomBatchChangeWithList( - randomBatchChange().changes.take(2).map(_.complete("recordChangeId", "recordSetId")) + randomBatchChange().changes.take(2).map(_.complete(Some("Complete"),"recordChangeId", "recordSetId")) ++ randomBatchChange().changes.drop(2).map(_.withFailureMessage("failed")) ).copy(createdTimestamp = Instant.now.truncatedTo(ChronoUnit.MILLIS).plusMillis(1000000)) @@ -411,7 +411,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec "update single changes" in { val batchChange = randomBatchChange() - val completed = batchChange.changes.map(_.complete("aaa", "bbb")) + val completed = batchChange.changes.map(_.complete(Some("Complete"),"aaa", "bbb")) val f = for { _ <- repo.save(batchChange) @@ -430,7 +430,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec "update some changes in a batch" in { val batchChange = randomBatchChange() - val completed = batchChange.changes.take(2).map(_.complete("recordChangeId", "recordSetId")) + val completed = batchChange.changes.take(2).map(_.complete(Some("Complete"),"recordChangeId", "recordSetId")) val incomplete = batchChange.changes.drop(2) val f = for { @@ -444,7 +444,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec "return the batch when updating single changes" in { val batchChange = randomBatchChange() - val completed = batchChange.changes.take(2).map(_.complete("recordChangeId", "recordSetId")) + val completed = batchChange.changes.take(2).map(_.complete(Some("Complete"),"recordChangeId", "recordSetId")) val f = for { _ <- repo.save(batchChange) diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupRepositoryIntegrationSpec.scala index b658c741a..d600318c3 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupRepositoryIntegrationSpec.scala @@ -93,6 +93,24 @@ class MySqlGroupRepositoryIntegrationSpec } } + "MySqlGroupRepository.getGroupsByName" should { + "omits all non existing groups" in { + val result = repo.getGroupsByName(Set("no-existo", groups.head.name)).unsafeRunSync() + result should contain theSameElementsAs Set(groups.head) + } + + "returns correct list of groups" in { + val names = Set(groups(0).name, groups(1).name, groups(2).name) + val result = repo.getGroupsByName(names).unsafeRunSync() + result should contain theSameElementsAs groups.take(3).toSet + } + + "returns empty list when given no names" in { + val result = repo.getGroupsByName(Set[String]()).unsafeRunSync() + result should contain theSameElementsAs Set() + } + } + "MySqlGroupRepository.getGroupByName" should { "retrieve a group" in { repo.getGroupByName(groups.head.name).unsafeRunSync() shouldBe Some(groups.head) @@ -103,6 +121,20 @@ class MySqlGroupRepositoryIntegrationSpec } } + "MySqlGroupRepository.getGroupsByName" should { + "retrieve a group" in { + repo.getGroupsByName(groups.head.name).unsafeRunSync() shouldBe Set(groups.head) + } + + "retrieve groups with wildcard character" in { + repo.getGroupsByName("*-group-*").unsafeRunSync() shouldBe groups.toSet + } + + "returns empty set when group does not exist" in { + repo.getGroupsByName("no-existo").unsafeRunSync() shouldBe Set() + } + } + "MySqlGroupRepository.getAllGroups" should { "retrieve all groups" in { repo.getAllGroups().unsafeRunSync() should contain theSameElementsAs groups.toSet 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..8daa0e58b 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala @@ -29,14 +29,16 @@ import vinyldns.core.domain.zone._ import vinyldns.core.TestZoneData.okZone import vinyldns.core.TestMembershipData._ import vinyldns.core.domain.zone.ZoneRepository.DuplicateZoneError -import vinyldns.mysql.TestMySqlInstance +import vinyldns.mysql.{TestMySqlInstance, TransactionProvider} +import vinyldns.mysql.TestMySqlInstance.groupRepository class MySqlZoneRepositoryIntegrationSpec extends AnyWordSpec with BeforeAndAfterAll with BeforeAndAfterEach with Matchers - with Inspectors { + with Inspectors + with TransactionProvider { private var repo: ZoneRepository = _ @@ -221,6 +223,32 @@ class MySqlZoneRepositoryIntegrationSpec (repo.listZones(dummyAuth).unsafeRunSync().zones should contain).only(testZones.head) } + "get authorized zone by admin group name" in { + + executeWithinTransaction { db: DB => + groupRepository.save(db, okGroup.copy(id = testZoneAdminGroupId)) + }.unsafeRunSync() + + // store all of the zones + + val f = saveZones(testZones) + + // query for all zones for the ok user, he should have access to all of the zones + val okUserAuth = AuthPrincipal( + signedInUser = okUser, + memberGroupIds = groups.map(_.id) + ) + + f.unsafeRunSync() + repo.listZonesByAdminGroupIds(okUserAuth, None, 100, Set(testZoneAdminGroupId)).unsafeRunSync().zones should contain theSameElementsAs testZones + + // dummy user only has access to one zone + (repo.listZonesByAdminGroupIds(dummyAuth, None, 100, Set(testZoneAdminGroupId)).unsafeRunSync().zones should contain).only(testZones.head) + + // delete the group created to test + groupRepository.delete(okGroup).unsafeRunSync() + } + "get all zones" in { // store all of the zones val privateZone = okZone.copy( @@ -259,6 +287,82 @@ class MySqlZoneRepositoryIntegrationSpec .zones should contain theSameElementsAs testZones } + "get all zones by admin group name" in { + + executeWithinTransaction { db: DB => + groupRepository.save(db, okGroup) + }.unsafeRunSync() + + val group = groupRepository.getGroupsByName(okGroup.name).unsafeRunSync() + val groupId = group.head.id + + // store all of the zones + val privateZone = okZone.copy( + name = "private-zone.", + id = UUID.randomUUID().toString, + acl = ZoneACL(), + adminGroupId = groupId + ) + + val sharedZone = okZone.copy( + name = "shared-zone.", + id = UUID.randomUUID().toString, + acl = ZoneACL(), + shared = true, + adminGroupId = groupId + ) + + val testZones = Seq(privateZone, sharedZone) + + val f = saveZones(testZones) + + // query for all zones for the ok user, should have all of the zones returned + val okUserAuth = AuthPrincipal( + signedInUser = okUser, + memberGroupIds = groups.map(_.id) + ) + + f.unsafeRunSync() + + repo + .listZonesByAdminGroupIds(okUserAuth, None, 100, Set(groupId), ignoreAccess = true) + .unsafeRunSync() + .zones should contain theSameElementsAs testZones + + // dummy user only have all of the zones returned + repo + .listZonesByAdminGroupIds(dummyAuth, None, 100, Set(groupId), ignoreAccess = true) + .unsafeRunSync() + .zones should contain theSameElementsAs testZones + + + // delete the group created to test + groupRepository.delete(okGroup).unsafeRunSync() + } + + "get empty list when no matching admin group name is found while filtering zones by group name" in { + + executeWithinTransaction { db: DB => + groupRepository.save(db, okGroup.copy(id = testZoneAdminGroupId)) + }.unsafeRunSync() + + // store all of the zones + + val f = saveZones(testZones) + + // query for all zones for the ok user, he should have access to all of the zones + val okUserAuth = AuthPrincipal( + signedInUser = okUser, + memberGroupIds = groups.map(_.id) + ) + + f.unsafeRunSync() + repo.listZonesByAdminGroupIds(okUserAuth, None, 100, Set()).unsafeRunSync().zones shouldBe empty + + // delete the group created to test + groupRepository.delete(okGroup).unsafeRunSync() + } + "get zones that are accessible by everyone" in { //user and group id being set to None implies EVERYONE access @@ -468,6 +572,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/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupRepository.scala index 83ead82f7..02311e6fd 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupRepository.scala @@ -69,6 +69,13 @@ class MySqlGroupRepository extends GroupRepository with GroupProtobufConversions | WHERE id """.stripMargin + private val BASE_GET_GROUPS_BY_NAMES = + """ + |SELECT data + | FROM groups + | WHERE name + """.stripMargin + def save(db: DB, group: Group): IO[Group] = monitor("repo.Group.save") { IO { @@ -141,6 +148,27 @@ class MySqlGroupRepository extends GroupRepository with GroupProtobufConversions } } + def getGroupsByName(groupNames: Set[String]): IO[Set[Group]] = + monitor("repo.Group.getGroups") { + IO { + logger.debug(s"Getting group with names: $groupNames") + if (groupNames.isEmpty) + Set[Group]() + else { + DB.readOnly { implicit s => + val groupNameList = groupNames.toList + val inClause = " IN (" + groupNameList.as("?").mkString(",") + ")" + val query = BASE_GET_GROUPS_BY_NAMES + inClause + SQL(query) + .bind(groupNameList: _*) + .map(toGroup(1)) + .list() + .apply() + }.toSet + } + } + } + def getGroupByName(groupName: String): IO[Option[Group]] = monitor("repo.Group.getGroupByName") { IO { @@ -155,6 +183,30 @@ class MySqlGroupRepository extends GroupRepository with GroupProtobufConversions } } + def getGroupsByName(nameFilter: String): IO[Set[Group]] = + monitor("repo.Group.getGroupByName") { + IO { + logger.debug(s"Getting groups with name: $nameFilter") + val initialQuery = "SELECT data FROM groups WHERE name" + val sb = new StringBuilder + sb.append(initialQuery) + val groupsLike = if (nameFilter.contains('*')) { + s" LIKE '${nameFilter.replace('*', '%')}'" + } else { + s" LIKE '$nameFilter%'" + } + sb.append(groupsLike) + val query = sb.toString() + + DB.readOnly { implicit s => + SQL(query) + .map(toGroup(1)) + .list() + .apply() + }.toSet + } + } + def getAllGroups(): IO[Set[Group]] = monitor("repo.Group.getAllGroups") { IO { diff --git a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala index e80402632..d08b90309 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala @@ -229,6 +229,65 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M } } + /** + * This is somewhat complicated due to how we need to build the SQL. + * + * - Dynamically build the accessor list combining the user id and group ids + * - Dynamically build the LIMIT clause. We cannot specify an offset if this is the first page (offset == 0) + * + * @return a ListZonesResults + */ + def listZonesByAdminGroupIds( + authPrincipal: AuthPrincipal, + startFrom: Option[String] = None, + maxItems: Int = 100, + adminGroupIds: Set[String], + ignoreAccess: Boolean = false + ): IO[ListZonesResults] = + monitor("repo.ZoneJDBC.listZonesByAdminGroupIds") { + IO { + DB.readOnly { implicit s => + val (withAccessorCheck, accessors) = + withAccessors(authPrincipal.signedInUser, authPrincipal.memberGroupIds, ignoreAccess) + val sb = new StringBuilder + sb.append(withAccessorCheck) + + if(adminGroupIds.nonEmpty) { + val groupIds = adminGroupIds.map(x => "'" + x + "'").mkString(",") + sb.append(s" WHERE admin_group_id IN ($groupIds) ") + } else { + sb.append(s" WHERE admin_group_id IN ('') ") + } + + sb.append(s" GROUP BY z.name ") + sb.append(s" LIMIT ${maxItems + 1}") + + val query = sb.toString + + val results: List[Zone] = SQL(query) + .bind(accessors: _*) + .map(extractZone(1)) + .list() + .apply() + + val (newResults, nextId) = + if (results.size > maxItems) + (results.dropRight(1), results.dropRight(1).lastOption.map(_.name)) + else (results, None) + + + ListZonesResults( + zones = newResults, + nextId = nextId, + startFrom = startFrom, + maxItems = maxItems, + zonesFilter = None, + ignoreAccess = ignoreAccess, + ) + } + } + } + /** * This is somewhat complicated due to how we need to build the SQL. * 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/controllers/VinylDNS.scala b/modules/portal/app/controllers/VinylDNS.scala index 8e8908252..f6d3852e9 100644 --- a/modules/portal/app/controllers/VinylDNS.scala +++ b/modules/portal/app/controllers/VinylDNS.scala @@ -214,6 +214,32 @@ class VinylDNS @Inject() ( }) } + def getGroupChange(gcid: String): Action[AnyContent] = userAction.async { implicit request => + val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"groups/change/$gcid") + executeRequest(vinyldnsRequest, request.user).map(response => { + logger.info(s"group change [$gcid] retrieved with status [${response.status}]") + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + + def listGroupChanges(id: String): Action[AnyContent] = userAction.async { implicit request => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"groups/$id/activity", + parameters = queryParameters + ) + executeRequest(vinyldnsRequest, request.user).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + def getUser(id: String): Action[AnyContent] = userAction.async { implicit request => val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"users/$id") executeRequest(vinyldnsRequest, request.user).map(response => { @@ -431,6 +457,23 @@ class VinylDNS @Inject() ( }) } + def getZoneChange(id: String): Action[AnyContent] = userAction.async { implicit request => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = + new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"zones/$id/changes", + parameters = queryParameters) + executeRequest(vinyldnsRequest, request.user).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + def syncZone(id: String): Action[AnyContent] = userAction.async { implicit request => // $COVERAGE-OFF$ val vinyldnsRequest = diff --git a/modules/portal/app/views/dnsChanges/dnsChangeDetail.scala.html b/modules/portal/app/views/dnsChanges/dnsChangeDetail.scala.html index b62690a12..1d05c1613 100644 --- a/modules/portal/app/views/dnsChanges/dnsChangeDetail.scala.html +++ b/modules/portal/app/views/dnsChanges/dnsChangeDetail.scala.html @@ -158,10 +158,16 @@ {{batch.status}} -

- {{error.message ? error.message : error}} -

- {{change.systemMessage}} +
+

+ {{error.message ? error.message : error}}

{{change.systemMessage}}
+
+
+
+ ℹ️ No further action is required.
diff --git a/modules/portal/app/views/groups/groupDetail.scala.html b/modules/portal/app/views/groups/groupDetail.scala.html index e7c3862d8..62717c8d2 100644 --- a/modules/portal/app/views/groups/groupDetail.scala.html +++ b/modules/portal/app/views/groups/groupDetail.scala.html @@ -2,110 +2,281 @@ @content = { - -
+ +
-
- - - +
+ + + - -
-

Group {{membership.group.name}}

-
- - - -
- -
-
- -
+ +
+

Group {{membership.group.name}}

+ -
-
-

Description: {{membership.group.description}}

-

Group Email: {{membership.group.email}}

- -
-
-
-
-
-
-
- + +
+ +
+
+ +
+
+ + +
+ +
+ +
+
+
+

Description: {{membership.group.description}}

+

Group Email: {{membership.group.email}}

+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
-
- -
-
-
- -
+
+

Loading members...

+

You don't have any members yet.

+ + + + + + + + + + + + + + + + + + + + + +
User NameNameEmailGroup ManagerStatusActions
{{member.userName}}{{([member.lastName, member.firstName] | filter: "" ).join(", ")}}{{member.email}} + + {{member.lockStatus}} + +
- + + +
-
-

Loading members...

-

You don't have any members yet.

- - - - - - - - - - - - - - - - - - - - - -
User NameNameEmailGroup ManagerStatusActions
{{member.userName}}{{([member.lastName, member.firstName] | filter: "" ).join(", ")}}{{member.email}} - - {{member.lockStatus}} - -
+
+ +
+
+

All Group Changes {{ getChangePageTitle() }}

+
+
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
TimeGroup Change IDChange TypeChange MessageChange InfoUser
{{change.created}}{{change.id}}{{change.changeType}} + View created group + + + + {{change.userName}}
+ + +
+ +
+ + +
+ +
+
- -
-
+ -
- -
+
+
- + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ } @plugins = { diff --git a/modules/portal/app/views/groups/groups.scala.html b/modules/portal/app/views/groups/groups.scala.html index 5e4ba60da..c1b4ea7fb 100644 --- a/modules/portal/app/views/groups/groups.scala.html +++ b/modules/portal/app/views/groups/groups.scala.html @@ -26,12 +26,12 @@
-
+
@@ -66,7 +66,22 @@

Loading groups...

You don't have any groups yet.

-

No groups match the search criteria.

+

No groups match the search criteria.

+ + +
+ {{ getGroupsPageNumber("myGroups") }} + +
+ + @@ -96,6 +111,118 @@
+ +
+ {{ getGroupsPageNumber("myGroups") }} + +
+ +
+
+ + +
+
+
+
+
+
+ + +
+
+
+ + +
+ + +
+
+
+ + + + +
+
+
+ + +
+
+

Loading groups...

+

No groups match the search criteria.

+ + +
+ {{ getGroupsPageNumber("allGroups") }} + +
+ + + + + + + + + + + + + + + + + + + +
Group NameEmailDescriptionActions
+ {{group.name}} + {{group.email}}{{group.description}} +
+ + View + + Edit + +
+
+ +
+ {{ getGroupsPageNumber("allGroups") }} + +
+
diff --git a/modules/portal/app/views/header.scala.html b/modules/portal/app/views/header.scala.html index 54e79900b..dc458dc64 100644 --- a/modules/portal/app/views/header.scala.html +++ b/modules/portal/app/views/header.scala.html @@ -1,6 +1,6 @@ @(rootAccountName: String)(implicit request: play.api.mvc.Request[Any]) -
+