2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-29 05:18:08 +00:00

Merge branch 'master' into nspadaccino/view-shared-zones

This commit is contained in:
Nicholas Spadaccino 2022-11-07 12:26:04 -05:00 committed by GitHub
commit e1df5a8958
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 4861 additions and 682 deletions

View File

@ -36,14 +36,17 @@ in any way, but do not see your name here, please open a PR to add yourself (in
- Joshulyne Park - Joshulyne Park
- Nathan Pierce - Nathan Pierce
- Michael Pilquist - Michael Pilquist
- Aravindh Raju
- Sriram Ramakrishnan - Sriram Ramakrishnan
- Khalid Reid - Khalid Reid
- Timo Schmid - Timo Schmid
- Trent Schmidt - Trent Schmidt
- Nick Spadaccino
- Ghafar Shah - Ghafar Shah
- Rebecca Star - Rebecca Star
- Jess Stodola - Jess Stodola
- Juan Valencia - Juan Valencia
- Jayaraj Velkumar
- Anastasia Vishnyakova - Anastasia Vishnyakova
- Jim Wakemen - Jim Wakemen
- Fei Wan - Fei Wan

View File

@ -10,6 +10,7 @@
* [Portal](#portal) * [Portal](#portal)
* [Documentation](#documentation) * [Documentation](#documentation)
- [Running VinylDNS Locally](#running-vinyldns-locally) - [Running VinylDNS Locally](#running-vinyldns-locally)
* [Support for M1 Macs](#support-for-m1-macs)
* [Starting the API Server](#starting-the-api-server) * [Starting the API Server](#starting-the-api-server)
* [Starting the Portal](#starting-the-portal) * [Starting the Portal](#starting-the-portal)
- [Testing](#testing) - [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 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. 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 ### Starting the API Server
Before starting the API service, you can start the dependencies for local development: 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 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. test execution process as well as easier inspection of logs.
You can run a specific test by name by running `make run -- -k <name of test function>`. Any arguments after You can run a specific test by name by running `make build` and `make run -- -k <name of test function>`. Any arguments after
`make run --` will be passed to the test runner [`test/api/functional/run.sh`](test/api/functional/run.sh). `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. Finally, you can execute `make run-deps-bg` to all of the dependencies for the functional test, but not run the tests.

View File

@ -163,6 +163,28 @@ vinyldns {
"ns1.parent.com4." "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 # Note: This MUST match the Portal or strange errors will ensue, NoOpCrypto should not be used for production
crypto { crypto {
type = "vinyldns.core.crypto.NoOpCrypto" type = "vinyldns.core.crypto.NoOpCrypto"

View File

@ -32,7 +32,6 @@ import vinyldns.api.domain.access.AccessValidations
import vinyldns.api.domain.zone._ import vinyldns.api.domain.zone._
import vinyldns.api.engine.TestMessageQueue import vinyldns.api.engine.TestMessageQueue
import vinyldns.mysql.TransactionProvider import vinyldns.mysql.TransactionProvider
import vinyldns.core.TestMembershipData._
import vinyldns.core.TestZoneData.testConnection import vinyldns.core.TestZoneData.testConnection
import vinyldns.core.domain.{Fqdn, HighValueDomainError} import vinyldns.core.domain.{Fqdn, HighValueDomainError}
import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.auth.AuthPrincipal
@ -64,14 +63,24 @@ class RecordSetServiceIntegrationSpec
private var testRecordSetService: RecordSetServiceAlgebra = _ private var testRecordSetService: RecordSetServiceAlgebra = _
private val user = User("live-test-user", "key", "secret") 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 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 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 group2 = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id, user2.id))
private val sharedGroup = private val sharedGroup =
Group(s"test-shared-group", "test@test.com", adminUserIds = Set(user.id, user2.id)) 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 auth = AuthPrincipal(user, Seq(group.id, sharedGroup.id))
private val auth2 = AuthPrincipal(user2, Seq(sharedGroup.id, group2.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( private val zone = Zone(
s"live-zone-test.", s"live-zone-test.",
"test@test.com", "test@test.com",
@ -100,6 +109,16 @@ class RecordSetServiceIntegrationSpec
None, None,
List(AAAAData("fd69:27cc:fe91::60")) 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( private val subTestRecordA = RecordSet(
zone.id, zone.id,
"a-record", "a-record",
@ -254,8 +273,8 @@ class RecordSetServiceIntegrationSpec
groupRepo.save(db, group) groupRepo.save(db, group)
} }
List(group, group2, sharedGroup).traverse(g => saveGroupData(groupRepo, g).void).unsafeRunSync() List(group, group2, sharedGroup, dummyGroup).traverse(g => saveGroupData(groupRepo, g).void).unsafeRunSync()
List(zone, zoneTestNameConflicts, zoneTestAddRecords, sharedZone) List(zone, dummyZone, zoneTestNameConflicts, zoneTestAddRecords, sharedZone)
.traverse( .traverse(
z => zoneRepo.save(z) z => zoneRepo.save(z)
) )
@ -273,6 +292,7 @@ class RecordSetServiceIntegrationSpec
val zoneRecords = List( val zoneRecords = List(
apexTestRecordA, apexTestRecordA,
apexTestRecordAAAA, apexTestRecordAAAA,
dottedTestRecord,
subTestRecordA, subTestRecordA,
subTestRecordAAAA, subTestRecordAAAA,
subTestRecordNS, subTestRecordNS,
@ -300,6 +320,7 @@ class RecordSetServiceIntegrationSpec
mockBackendResolver, mockBackendResolver,
false, false,
vinyldnsConfig.highValueDomainConfig, vinyldnsConfig.highValueDomainConfig,
vinyldnsConfig.dottedHostsConfig,
vinyldnsConfig.serverConfig.approvedNameServers, vinyldnsConfig.serverConfig.approvedNameServers,
useRecordSetCache = true useRecordSetCache = true
) )
@ -338,6 +359,93 @@ class RecordSetServiceIntegrationSpec
.name shouldBe "zone-test-add-records." .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 { "update apex A record and add trailing dot" in {
val newRecord = apexTestRecordA.copy(ttl = 200) val newRecord = apexTestRecordA.copy(ttl = 200)
val result = testRecordSetService val result = testRecordSetService
@ -549,6 +657,15 @@ class RecordSetServiceIntegrationSpec
Some(group2.id) 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 { "fail deleting for user not in record owner group in shared zone" in {
val result = leftResultOf( val result = leftResultOf(
testRecordSetService testRecordSetService

View File

@ -165,6 +165,19 @@ vinyldns {
"ns1.parent.com4." "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 # Note: This MUST match the Portal or strange errors will ensue, NoOpCrypto should not be used for production
crypto { crypto {
type = "vinyldns.core.crypto.NoOpCrypto" type = "vinyldns.core.crypto.NoOpCrypto"

View File

@ -90,6 +90,29 @@ vinyldns {
"ns1.parent.com." "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 should be green or blue, used in order to do blue/green deployment
color = "green" color = "green"
@ -111,7 +134,7 @@ vinyldns {
batchchange-routing-max-items-limit = 100 batchchange-routing-max-items-limit = 100
membership-routing-default-max-items = 100 membership-routing-default-max-items = 100
membership-routing-max-items-limit = 1000 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 recordset-routing-default-max-items= 100
zone-routing-default-max-items = 100 zone-routing-default-max-items = 100
zone-routing-max-items-limit = 100 zone-routing-max-items-limit = 100

View File

@ -139,6 +139,7 @@ object Boot extends App {
backendResolver, backendResolver,
vinyldnsConfig.serverConfig.validateRecordLookupAgainstDnsBackend, vinyldnsConfig.serverConfig.validateRecordLookupAgainstDnsBackend,
vinyldnsConfig.highValueDomainConfig, vinyldnsConfig.highValueDomainConfig,
vinyldnsConfig.dottedHostsConfig,
vinyldnsConfig.serverConfig.approvedNameServers, vinyldnsConfig.serverConfig.approvedNameServers,
vinyldnsConfig.serverConfig.useRecordSetCache vinyldnsConfig.serverConfig.useRecordSetCache
) )

View File

@ -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))
}

View File

@ -47,6 +47,7 @@ final case class VinylDNSConfig(
notifierConfigs: List[NotifierConfig], notifierConfigs: List[NotifierConfig],
dataStoreConfigs: List[DataStoreConfig], dataStoreConfigs: List[DataStoreConfig],
backendConfigs: BackendConfigs, backendConfigs: BackendConfigs,
dottedHostsConfig: DottedHostsConfig,
configuredDnsConnections: ConfiguredDnsConnections, configuredDnsConnections: ConfiguredDnsConnections,
apiMetricSettings: APIMetricsSettings, apiMetricSettings: APIMetricsSettings,
crypto: CryptoAlgebra, crypto: CryptoAlgebra,
@ -85,6 +86,7 @@ object VinylDNSConfig {
serverConfig <- loadIO[ServerConfig](config, "vinyldns") serverConfig <- loadIO[ServerConfig](config, "vinyldns")
batchChangeConfig <- loadIO[BatchChangeConfig](config, "vinyldns") batchChangeConfig <- loadIO[BatchChangeConfig](config, "vinyldns")
backendConfigs <- loadIO[BackendConfigs](config, "vinyldns.backend") backendConfigs <- loadIO[BackendConfigs](config, "vinyldns.backend")
dottedHostsConfig <- loadIO[DottedHostsConfig](config, "vinyldns.dotted-hosts")
httpConfig <- loadIO[HttpConfig](config, "vinyldns.rest") httpConfig <- loadIO[HttpConfig](config, "vinyldns.rest")
hvdConfig <- loadIO[HighValueDomainConfig](config, "vinyldns.high-value-domains") hvdConfig <- loadIO[HighValueDomainConfig](config, "vinyldns.high-value-domains")
scheduledChangesConfig <- loadIO[ScheduledChangesConfig](config, "vinyldns") scheduledChangesConfig <- loadIO[ScheduledChangesConfig](config, "vinyldns")
@ -110,6 +112,7 @@ object VinylDNSConfig {
notifierConfigs, notifierConfigs,
dataStoreConfigs, dataStoreConfigs,
backendConfigs, backendConfigs,
dottedHostsConfig,
connections, connections,
metricSettings, metricSettings,
crypto, crypto,

View File

@ -27,6 +27,10 @@ import scala.util.matching.Regex
Object to house common domain validations Object to house common domain validations
*/ */
object DomainValidations { 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 = 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 """^(?:([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 = val validIpv4Regex: Regex =
@ -60,6 +64,30 @@ object DomainValidations {
def validateHostName(name: Fqdn): ValidatedNel[DomainValidationError, Fqdn] = def validateHostName(name: Fqdn): ValidatedNel[DomainValidationError, Fqdn] =
validateHostName(name.fqdn).map(_ => name) 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] = { def validateHostName(name: String): ValidatedNel[DomainValidationError, String] = {
/* /*
Label rules are as follows (from RFC 952; detailed in RFC 1034): Label rules are as follows (from RFC 952; detailed in RFC 1034):
@ -85,6 +113,8 @@ object DomainValidations {
checkRegex.combine(checkLength).map(_ => name) checkRegex.combine(checkLength).map(_ => name)
} }
def validateIpv4Address(address: String): ValidatedNel[DomainValidationError, String] = def validateIpv4Address(address: String): ValidatedNel[DomainValidationError, String] =
validIpv4Regex validIpv4Regex
.findFirstIn(address) .findFirstIn(address)

View File

@ -27,12 +27,15 @@ import vinyldns.api.domain.record.RecordSetChangeGenerator
import vinyldns.core.domain.record._ import vinyldns.core.domain.record._
import vinyldns.core.domain.zone.Zone import vinyldns.core.domain.zone.Zone
import vinyldns.core.domain.batch._ 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 import vinyldns.core.queue.MessageQueue
class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: MessageQueue) class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: MessageQueue)
extends BatchChangeConverterAlgebra { 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]) private val logger = LoggerFactory.getLogger(classOf[BatchChangeConverter])
def sendBatchForProcessing( def sendBatchForProcessing(
@ -67,8 +70,13 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
recordSetChanges: List[RecordSetChange] recordSetChanges: List[RecordSetChange]
): BatchResult[Unit] = { ): BatchResult[Unit] = {
val convertedIds = recordSetChanges.flatMap(_.singleBatchChangeIds).toSet val convertedIds = recordSetChanges.flatMap(_.singleBatchChangeIds).toSet
singleChanges.find(ch => !convertedIds.contains(ch.id)) match { singleChanges.find(ch => !convertedIds.contains(ch.id)) match {
// 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 Some(change) => BatchConversionError(change).toLeftBatchResult
case None => case None =>
logger.info(s"Successfully converted SingleChanges [${singleChanges logger.info(s"Successfully converted SingleChanges [${singleChanges
@ -104,7 +112,6 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
val idsMap = recordSetChanges.flatMap { rsChange => val idsMap = recordSetChanges.flatMap { rsChange =>
rsChange.singleBatchChangeIds.map(batchId => (batchId, rsChange.id)) rsChange.singleBatchChangeIds.map(batchId => (batchId, rsChange.id))
}.toMap }.toMap
val withStatus = batchChange.changes.map { change => val withStatus = batchChange.changes.map { change =>
idsMap idsMap
.get(change.id) .get(change.id)
@ -113,19 +120,26 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
change change
} }
.getOrElse { .getOrElse {
// failure here means there was a message queue issue for this change // Match and check if it's a delete change for a record that doesn't exists.
change.withFailureMessage("Error queueing RecordSetChange for processing") 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) batchChange.copy(changes = withStatus)
} }
def storeQueuingFailures(batchChange: BatchChange): BatchResult[Unit] = { def storeQueuingFailures(batchChange: BatchChange): BatchResult[Unit] = {
val failedChanges = batchChange.changes.collect { // Update if Single change is Failed or if a record that does not exist is deleted
case change if change.status == SingleChangeStatus.Failed => change 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 }.toBatchResult
def createRecordSetChangesForBatch( def createRecordSetChangesForBatch(

View File

@ -32,19 +32,8 @@ import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.batch.BatchChangeApprovalStatus.BatchChangeApprovalStatus import vinyldns.core.domain.batch.BatchChangeApprovalStatus.BatchChangeApprovalStatus
import vinyldns.core.domain.batch._ import vinyldns.core.domain.batch._
import vinyldns.core.domain.batch.BatchChangeApprovalStatus._ import vinyldns.core.domain.batch.BatchChangeApprovalStatus._
import vinyldns.core.domain.{ import vinyldns.core.domain.{CnameAtZoneApexError, SingleChangeError, UserIsNotAuthorizedError, ZoneDiscoveryError}
CnameAtZoneApexError, import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository}
SingleChangeError,
UserIsNotAuthorizedError,
ZoneDiscoveryError
}
import vinyldns.core.domain.membership.{
Group,
GroupRepository,
ListUsersResults,
User,
UserRepository
}
import vinyldns.core.domain.record.RecordType._ import vinyldns.core.domain.record.RecordType._
import vinyldns.core.domain.record.RecordSetRepository import vinyldns.core.domain.record.RecordSetRepository
import vinyldns.core.domain.zone.ZoneRepository import vinyldns.core.domain.zone.ZoneRepository

View File

@ -17,15 +17,9 @@
package vinyldns.api.domain.batch package vinyldns.api.domain.batch
import java.net.InetAddress import java.net.InetAddress
import cats.data._ import cats.data._
import cats.implicits._ import cats.implicits._
import vinyldns.api.config.{ import vinyldns.api.config.{BatchChangeConfig, HighValueDomainConfig, ManualReviewConfig, ScheduledChangesConfig}
BatchChangeConfig,
HighValueDomainConfig,
ManualReviewConfig,
ScheduledChangesConfig
}
import vinyldns.api.domain.DomainValidations._ import vinyldns.api.domain.DomainValidations._
import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.api.domain.access.AccessValidationsAlgebra
import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.auth.AuthPrincipal
@ -34,7 +28,7 @@ import vinyldns.api.domain.batch.BatchTransformations._
import vinyldns.api.domain.zone.ZoneRecordValidations import vinyldns.api.domain.zone.ZoneRecordValidations
import vinyldns.core.domain.record._ import vinyldns.core.domain.record._
import vinyldns.core.domain._ 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 import vinyldns.core.domain.membership.Group
trait BatchChangeValidationsAlgebra { trait BatchChangeValidationsAlgebra {
@ -211,7 +205,7 @@ class BatchChangeValidations(
isApproved: Boolean isApproved: Boolean
): SingleValidation[Unit] = { ): SingleValidation[Unit] = {
val validTTL = addChangeInput.ttl.map(validateTTL(_).asUnit).getOrElse(().valid) 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) val validInput = validateInputName(addChangeInput, isApproved)
validTTL |+| validRecord |+| validInput validTTL |+| validRecord |+| validInput
@ -222,7 +216,7 @@ class BatchChangeValidations(
isApproved: Boolean isApproved: Boolean
): SingleValidation[Unit] = { ): SingleValidation[Unit] = {
val validRecord = deleteRRSetChangeInput.record match { val validRecord = deleteRRSetChangeInput.record match {
case Some(recordData) => validateRecordData(recordData) case Some(recordData) => validateRecordData(recordData, deleteRRSetChangeInput)
case None => ().validNel case None => ().validNel
} }
val validInput = validateInputName(deleteRRSetChangeInput, isApproved) val validInput = validateInputName(deleteRRSetChangeInput, isApproved)
@ -230,11 +224,18 @@ class BatchChangeValidations(
validRecord |+| validInput validRecord |+| validInput
} }
def validateRecordData(record: RecordData): SingleValidation[Unit] = def validateRecordData(record: RecordData,change: ChangeInput): SingleValidation[Unit] =
record match { record match {
case a: AData => validateIpv4Address(a.address).asUnit case a: AData => validateIpv4Address(a.address).asUnit
case aaaa: AAAAData => validateIpv6Address(aaaa.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 ptr: PTRData => validateHostName(ptr.ptrdname).asUnit
case txt: TXTData => validateTxtTextLength(txt.text).asUnit case txt: TXTData => validateTxtTextLength(txt.text).asUnit
case mx: MXData => case mx: MXData =>
@ -347,7 +348,7 @@ class BatchChangeValidations(
userCanDeleteRecordSet(change, auth, rs.ownerGroupId, rs.records) |+| userCanDeleteRecordSet(change, auth, rs.ownerGroupId, rs.records) |+|
zoneDoesNotRequireManualReview(change, isApproved) |+| zoneDoesNotRequireManualReview(change, isApproved) |+|
ensureRecordExists(change, groupedChanges) ensureRecordExists(change, groupedChanges)
case None => RecordDoesNotExist(change.inputChange.inputName).invalidNel case None => RecordDoesNotExist(change.inputChange.inputName).validNel
} }
validations.map(_ => change) validations.map(_ => change)
} }
@ -401,7 +402,7 @@ class BatchChangeValidations(
zoneDoesNotRequireManualReview(change, isApproved) |+| zoneDoesNotRequireManualReview(change, isApproved) |+|
ensureRecordExists(change, groupedChanges) ensureRecordExists(change, groupedChanges)
case None => case None =>
RecordDoesNotExist(change.inputChange.inputName).invalidNel RecordDoesNotExist(change.inputChange.inputName).validNel
} }
validations.map(_ => change) validations.map(_ => change)
@ -435,11 +436,12 @@ class BatchChangeValidations(
change.recordName, change.recordName,
change.inputChange.inputName, change.inputChange.inputName,
change.inputChange.typ, change.inputChange.typ,
groupedChanges change.inputChange.record,
groupedChanges,
isApproved
) |+| ) |+|
ownerGroupProvidedIfNeeded(change, None, ownerGroupId) |+| ownerGroupProvidedIfNeeded(change, None, ownerGroupId) |+|
zoneDoesNotRequireManualReview(change, isApproved) zoneDoesNotRequireManualReview(change, isApproved)
validations.map(_ => change) validations.map(_ => change)
} }
@ -477,11 +479,16 @@ class BatchChangeValidations(
recordName: String, recordName: String,
inputName: String, inputName: String,
typ: RecordType, typ: RecordType,
groupedChanges: ChangeForValidationMap recordData: RecordData,
): SingleValidation[Unit] = groupedChanges: ChangeForValidationMap,
groupedChanges.getExistingRecordSet(RecordKey(zoneId, recordName, typ)) match { isApproved: Boolean
case Some(_) => RecordAlreadyExists(inputName).invalidNel ): SingleValidation[Unit] = {
case None => ().validNel 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( def noIncompatibleRecordExists(

View File

@ -68,6 +68,9 @@ object BatchTransformations {
def get(recordKey: RecordKey): Option[RecordSet] = def get(recordKey: RecordKey): Option[RecordSet] =
get(recordKey.zoneId, recordKey.recordName, recordKey.recordType) 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] = def getRecordSetMatch(zoneId: String, name: String): List[RecordSet] =
recordSetMap.getOrElse((zoneId, name.toLowerCase), List()) recordSetMap.getOrElse((zoneId, name.toLowerCase), List())
} }
@ -171,6 +174,9 @@ object BatchTransformations {
def getExistingRecordSet(recordKey: RecordKey): Option[RecordSet] = def getExistingRecordSet(recordKey: RecordKey): Option[RecordSet] =
existingRecordSets.get(recordKey) existingRecordSets.get(recordKey)
def getExistingRecordSetData(recordKeyData: RecordKeyData): Option[RecordSet] =
existingRecordSets.get(recordKeyData)
def getProposedAdds(recordKey: RecordKey): Set[RecordData] = def getProposedAdds(recordKey: RecordKey): Set[RecordData] =
innerMap.get(recordKey).map(_.proposedAdds).toSet.flatten innerMap.get(recordKey).map(_.proposedAdds).toSet.flatten

View File

@ -59,7 +59,9 @@ final case class GroupChangeInfo(
userId: String, userId: String,
oldGroup: Option[GroupInfo] = None, oldGroup: Option[GroupInfo] = None,
id: String = UUID.randomUUID().toString, id: String = UUID.randomUUID().toString,
created: String = DateTime.now.getMillis.toString created: DateTime = DateTime.now,
userName: String,
groupChangeMessage: String
) )
object GroupChangeInfo { object GroupChangeInfo {
@ -69,7 +71,9 @@ object GroupChangeInfo {
userId = groupChange.userId, userId = groupChange.userId,
oldGroup = groupChange.oldGroup.map(GroupInfo.apply), oldGroup = groupChange.oldGroup.map(GroupInfo.apply),
id = groupChange.id, id = groupChange.id,
created = groupChange.created.getMillis.toString created = groupChange.created,
userName = groupChange.userName.getOrElse("unknown user"),
groupChangeMessage = groupChange.groupChangeMessage.getOrElse("")
) )
} }
@ -171,6 +175,8 @@ final case class GroupNotFoundError(msg: String) extends Throwable(msg)
final case class GroupAlreadyExistsError(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 UserNotFoundError(msg: String) extends Throwable(msg)
final case class InvalidGroupError(msg: String) extends Throwable(msg) final case class InvalidGroupError(msg: String) extends Throwable(msg)

View File

@ -57,6 +57,7 @@ class MembershipService(
val adminMembers = inputGroup.adminUserIds val adminMembers = inputGroup.adminUserIds
val nonAdminMembers = inputGroup.memberIds.diff(adminMembers) val nonAdminMembers = inputGroup.memberIds.diff(adminMembers)
for { for {
_ <- groupValidation(newGroup)
_ <- hasMembersAndAdmins(newGroup).toResult _ <- hasMembersAndAdmins(newGroup).toResult
_ <- groupWithSameNameDoesNotExist(newGroup.name) _ <- groupWithSameNameDoesNotExist(newGroup.name)
_ <- usersExist(newGroup.memberIds) _ <- usersExist(newGroup.memberIds)
@ -76,6 +77,7 @@ class MembershipService(
for { for {
existingGroup <- getExistingGroup(groupId) existingGroup <- getExistingGroup(groupId)
newGroup = existingGroup.withUpdates(name, email, description, memberIds, adminUserIds) newGroup = existingGroup.withUpdates(name, email, description, memberIds, adminUserIds)
_ <- groupValidation(newGroup)
_ <- canEditGroup(existingGroup, authPrincipal).toResult _ <- canEditGroup(existingGroup, authPrincipal).toResult
addedAdmins = newGroup.adminUserIds.diff(existingGroup.adminUserIds) addedAdmins = newGroup.adminUserIds.diff(existingGroup.adminUserIds)
// new non-admin members ++ admins converted to non-admins // new non-admin members ++ admins converted to non-admins
@ -214,12 +216,18 @@ class MembershipService(
): ListMyGroupsResponse = { ): ListMyGroupsResponse = {
val allMyGroups = allGroups val allMyGroups = allGroups
.filter(_.status == GroupStatus.Active) .filter(_.status == GroupStatus.Active)
.sortBy(_.id) .sortBy(_.name.toLowerCase)
.map(x => GroupInfo.fromGroup(x, abridged, Some(authPrincipal))) .map(x => GroupInfo.fromGroup(x, abridged, Some(authPrincipal)))
val filtered = allMyGroups val filtered = if(startFrom.isDefined){
.filter(grp => groupNameFilter.forall(grp.name.contains(_))) val prevPageGroup = allMyGroups.filter(_.id == startFrom.get).head.name
.filter(grp => startFrom.forall(grp.id > _)) 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 nextId = if (filtered.length > maxItems) Some(filtered(maxItems - 1).id) else None
val groups = filtered.take(maxItems) val groups = filtered.take(maxItems)
@ -227,6 +235,23 @@ class MembershipService(
ListMyGroupsResponse(groups, groupNameFilter, startFrom, nextId, maxItems, ignoreAccess) 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( def getGroupActivity(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[String],
@ -238,13 +263,65 @@ class MembershipService(
result <- groupChangeRepo result <- groupChangeRepo
.getGroupChanges(groupId, startFrom, maxItems) .getGroupChanges(groupId, startFrom, maxItems)
.toResult[ListGroupChangesResults] .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( } yield ListGroupChangesResponse(
result.changes.map(GroupChangeInfo.apply), groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))),
startFrom, startFrom,
result.lastEvaluatedTimeStamp, result.lastEvaluatedTimeStamp,
maxItems 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 * Retrieves the requested User from the given userIdentifier, which can be a userId or username
* @param userIdentifier The 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")) .orFail(GroupNotFoundError(s"Group with ID $groupId was not found"))
.toResult[Group] .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] = def groupWithSameNameDoesNotExist(name: String): Result[Unit] =
groupRepo groupRepo
.getGroupByName(name) .getGroupByName(name)

View File

@ -39,6 +39,8 @@ trait MembershipServiceAlgebra {
def getGroup(id: String, authPrincipal: AuthPrincipal): Result[Group] def getGroup(id: String, authPrincipal: AuthPrincipal): Result[Group]
def getGroupChange(id: String, authPrincipal: AuthPrincipal): Result[GroupChangeInfo]
def listMyGroups( def listMyGroups(
groupNameFilter: Option[String], groupNameFilter: Option[String],
startFrom: Option[String], startFrom: Option[String],

View File

@ -19,7 +19,7 @@ package vinyldns.api.domain.membership
import vinyldns.api.Interfaces.ensuring import vinyldns.api.Interfaces.ensuring
import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.api.domain.zone.NotAuthorizedError import vinyldns.api.domain.zone.NotAuthorizedError
import vinyldns.core.domain.membership.Group import vinyldns.core.domain.membership.{Group, GroupChange}
object MembershipValidations { object MembershipValidations {
@ -44,4 +44,9 @@ object MembershipValidations {
ensuring(NotAuthorizedError("Not authorized")) { ensuring(NotAuthorizedError("Not authorized")) {
authPrincipal.isGroupMember(groupId) || authPrincipal.isSystemAdmin || canViewGroupDetails authPrincipal.isGroupMember(groupId) || authPrincipal.isSystemAdmin || canViewGroupDetails
} }
def isGroupChangePresent(groupChange: Option[GroupChange]): Either[Throwable, Unit] =
ensuring(InvalidGroupRequestError("Invalid Group Change ID")) {
groupChange.isDefined
}
} }

View File

@ -28,7 +28,7 @@ import vinyldns.core.queue.MessageQueue
import cats.data._ import cats.data._
import cats.effect.IO import cats.effect.IO
import org.xbill.DNS.ReverseMap 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.DomainValidations.{validateIpv4Address, validateIpv6Address}
import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.api.domain.access.AccessValidationsAlgebra
import vinyldns.core.domain.record.NameSort.NameSort import vinyldns.core.domain.record.NameSort.NameSort
@ -46,6 +46,7 @@ object RecordSetService {
backendResolver: BackendResolver, backendResolver: BackendResolver,
validateRecordLookupAgainstDnsBackend: Boolean, validateRecordLookupAgainstDnsBackend: Boolean,
highValueDomainConfig: HighValueDomainConfig, highValueDomainConfig: HighValueDomainConfig,
dottedHostsConfig: DottedHostsConfig,
approvedNameServers: List[Regex], approvedNameServers: List[Regex],
useRecordSetCache: Boolean useRecordSetCache: Boolean
): RecordSetService = ): RecordSetService =
@ -61,6 +62,7 @@ object RecordSetService {
backendResolver, backendResolver,
validateRecordLookupAgainstDnsBackend, validateRecordLookupAgainstDnsBackend,
highValueDomainConfig, highValueDomainConfig,
dottedHostsConfig,
approvedNameServers, approvedNameServers,
useRecordSetCache useRecordSetCache
) )
@ -78,6 +80,7 @@ class RecordSetService(
backendResolver: BackendResolver, backendResolver: BackendResolver,
validateRecordLookupAgainstDnsBackend: Boolean, validateRecordLookupAgainstDnsBackend: Boolean,
highValueDomainConfig: HighValueDomainConfig, highValueDomainConfig: HighValueDomainConfig,
dottedHostsConfig: DottedHostsConfig,
approvedNameServers: List[Regex], approvedNameServers: List[Regex],
useRecordSetCache: Boolean useRecordSetCache: Boolean
) extends RecordSetServiceAlgebra { ) extends RecordSetServiceAlgebra {
@ -88,6 +91,7 @@ class RecordSetService(
def addRecordSet(recordSet: RecordSet, auth: AuthPrincipal): Result[ZoneCommandResult] = def addRecordSet(recordSet: RecordSet, auth: AuthPrincipal): Result[ZoneCommandResult] =
for { for {
zone <- getZone(recordSet.zoneId) zone <- getZone(recordSet.zoneId)
authZones = dottedHostsConfig.zoneAuthConfigs.map(x => x.zone)
change <- RecordSetChangeGenerator.forAdd(recordSet, zone, Some(auth)).toResult change <- RecordSetChangeGenerator.forAdd(recordSet, zone, Some(auth)).toResult
// because changes happen to the RS in forAdd itself, converting 1st and validating on that // because changes happen to the RS in forAdd itself, converting 1st and validating on that
rsForValidations = change.recordSet rsForValidations = change.recordSet
@ -107,13 +111,27 @@ class RecordSetService(
ownerGroup <- getGroupIfProvided(rsForValidations.ownerGroupId) ownerGroup <- getGroupIfProvided(rsForValidations.ownerGroupId)
_ <- canUseOwnerGroup(rsForValidations.ownerGroupId, ownerGroup, auth).toResult _ <- canUseOwnerGroup(rsForValidations.ownerGroupId, ownerGroup, auth).toResult
_ <- noCnameWithNewName(rsForValidations, existingRecordsWithName, zone).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( _ <- typeSpecificValidations(
rsForValidations, rsForValidations,
existingRecordsWithName, existingRecordsWithName,
zone, zone,
None, None,
approvedNameServers approvedNameServers,
recordFqdnDoesNotAlreadyExist,
allowedZoneList,
isRecordTypeAndUserAllowed,
allowedDotsLimit
).toResult ).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] _ <- messageQueue.send(change).toResult[Unit]
} yield change } yield change
@ -143,13 +161,27 @@ class RecordSetService(
validateRecordLookupAgainstDnsBackend validateRecordLookupAgainstDnsBackend
) )
_ <- noCnameWithNewName(rsForValidations, existingRecordsWithName, zone).toResult _ <- 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( _ <- typeSpecificValidations(
rsForValidations, rsForValidations,
existingRecordsWithName, existingRecordsWithName,
zone, zone,
Some(existing), Some(existing),
approvedNameServers approvedNameServers,
true,
allowedZoneList,
isRecordTypeAndUserAllowed,
allowedDotsLimit
).toResult ).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] _ <- messageQueue.send(change).toResult[Unit]
} yield change } yield change
@ -169,6 +201,178 @@ class RecordSetService(
_ <- messageQueue.send(change).toResult[Unit] _ <- messageQueue.send(change).toResult[Unit]
} yield change } 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( def getRecordSet(
recordSetId: String, recordSetId: String,
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal

View File

@ -26,7 +26,7 @@ import vinyldns.core.domain.record.RecordType._
import vinyldns.api.domain.zone._ import vinyldns.api.domain.zone._
import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.membership.Group 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.domain.zone.Zone
import vinyldns.core.Messages._ import vinyldns.core.Messages._
@ -90,6 +90,69 @@ object RecordSetValidations {
!existingRecordsWithName.exists(rs => rs.id != newRecordSet.id && rs.typ == newRecordSet.typ) !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( def isNotDotted(
newRecordSet: RecordSet, newRecordSet: RecordSet,
zone: Zone, zone: Zone,
@ -110,16 +173,20 @@ object RecordSetValidations {
existingRecordsWithName: List[RecordSet], existingRecordsWithName: List[RecordSet],
zone: Zone, zone: Zone,
existingRecordSet: Option[RecordSet], existingRecordSet: Option[RecordSet],
approvedNameServers: List[Regex] approvedNameServers: List[Regex],
recordFqdnDoesNotExist: Boolean,
dottedHostZoneConfig: Set[String],
isRecordTypeAndUserAllowed: Boolean,
allowedDotsLimit: Int = 0
): Either[Throwable, Unit] = ): Either[Throwable, Unit] =
newRecordSet.typ match { newRecordSet.typ match {
case CNAME => cnameValidations(newRecordSet, existingRecordsWithName, zone, existingRecordSet) case CNAME => cnameValidations(newRecordSet, existingRecordsWithName, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
case NS => nsValidations(newRecordSet, zone, existingRecordSet, approvedNameServers) case NS => nsValidations(newRecordSet, zone, existingRecordSet, approvedNameServers, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
case SOA => soaValidations(newRecordSet, zone) case SOA => soaValidations(newRecordSet, zone, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
case PTR => ptrValidations(newRecordSet, zone) case PTR => ptrValidations(newRecordSet, zone)
case SRV | TXT | NAPTR => ().asRight // SRV, TXT and NAPTR do not go through dotted host check case SRV | TXT | NAPTR => ().asRight // SRV, TXT and NAPTR do not go through dotted host check
case DS => dsValidations(newRecordSet, existingRecordsWithName, zone) case DS => dsValidations(newRecordSet, existingRecordsWithName, zone, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
case _ => isNotDotted(newRecordSet, zone, existingRecordSet) case _ => checkForDot(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
} }
def typeSpecificDeleteValidations(recordSet: RecordSet, zone: Zone): Either[Throwable, Unit] = def typeSpecificDeleteValidations(recordSet: RecordSet, zone: Zone): Either[Throwable, Unit] =
@ -140,7 +207,11 @@ object RecordSetValidations {
newRecordSet: RecordSet, newRecordSet: RecordSet,
existingRecordsWithName: List[RecordSet], existingRecordsWithName: List[RecordSet],
zone: Zone, zone: Zone,
existingRecordSet: Option[RecordSet] = None existingRecordSet: Option[RecordSet] = None,
recordFqdnDoesNotExist: Boolean,
dottedHostZoneConfig: Set[String],
isRecordTypeAndUserAllowed: Boolean,
allowedDotsLimit: Int = 0
): Either[Throwable, Unit] = { ): Either[Throwable, Unit] = {
// cannot create a cname record if a record with the same exists // cannot create a cname record if a record with the same exists
val noRecordWithName = { val noRecordWithName = {
@ -173,7 +244,7 @@ object RecordSetValidations {
) )
_ <- noRecordWithName _ <- noRecordWithName
_ <- RDataWithConsecutiveDots _ <- RDataWithConsecutiveDots
_ <- isNotDotted(newRecordSet, zone, existingRecordSet) _ <- checkForDot(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
} yield () } yield ()
} }
@ -181,7 +252,11 @@ object RecordSetValidations {
def dsValidations( def dsValidations(
newRecordSet: RecordSet, newRecordSet: RecordSet,
existingRecordsWithName: List[RecordSet], existingRecordsWithName: List[RecordSet],
zone: Zone zone: Zone,
recordFqdnDoesNotExist: Boolean,
dottedHostZoneConfig: Set[String],
isRecordTypeAndUserAllowed: Boolean,
allowedDotsLimit: Int = 0
): Either[Throwable, Unit] = { ): Either[Throwable, Unit] = {
// see https://tools.ietf.org/html/rfc4035#section-2.4 // see https://tools.ietf.org/html/rfc4035#section-2.4
val nsChecks = existingRecordsWithName.find(_.typ == NS) match { val nsChecks = existingRecordsWithName.find(_.typ == NS) match {
@ -194,7 +269,7 @@ object RecordSetValidations {
} }
for { for {
_ <- isNotDotted(newRecordSet, zone) _ <- checkForDot(newRecordSet, zone, None, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
_ <- isNotOrigin( _ <- isNotOrigin(
newRecordSet, newRecordSet,
zone, zone,
@ -208,10 +283,14 @@ object RecordSetValidations {
newRecordSet: RecordSet, newRecordSet: RecordSet,
zone: Zone, zone: Zone,
oldRecordSet: Option[RecordSet], oldRecordSet: Option[RecordSet],
approvedNameServers: List[Regex] approvedNameServers: List[Regex],
recordFqdnDoesNotExist: Boolean,
dottedHostZoneConfig: Set[String],
isRecordTypeAndUserAllowed: Boolean,
allowedDotsLimit: Int = 0
): Either[Throwable, Unit] = { ): Either[Throwable, Unit] = {
// TODO kept consistency with old validation. Not sure why NS could be dotted in reverse specifically // 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 { for {
_ <- isNotDottedHost _ <- isNotDottedHost
@ -233,9 +312,9 @@ object RecordSetValidations {
} yield () } 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 // 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] = def ptrValidations(newRecordSet: RecordSet, zone: Zone): Either[Throwable, Unit] =
// TODO we don't check for PTR as dotted...not sure why // 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(", "))) .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( def canUseOwnerGroup(
ownerGroupId: Option[String], ownerGroupId: Option[String],
group: Option[Group], group: Option[Group],

View File

@ -16,6 +16,7 @@
package vinyldns.api.domain.zone package vinyldns.api.domain.zone
import cats.effect.IO
import cats.implicits._ import cats.implicits._
import vinyldns.api.domain.access.AccessValidationsAlgebra import vinyldns.api.domain.access.AccessValidationsAlgebra
import vinyldns.api.Interfaces import vinyldns.api.Interfaces
@ -142,13 +143,16 @@ class ZoneService(
accessLevel = getZoneAccess(auth, zone) accessLevel = getZoneAccess(auth, zone)
} yield ZoneInfo(zone, aclInfo, groupName, accessLevel) } 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( def listZones(
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,
nameFilter: Option[String] = None, nameFilter: Option[String] = None,
startFrom: Option[String] = None, startFrom: Option[String] = None,
maxItems: Int = 100, maxItems: Int = 100,
searchByAdminGroup: Boolean = false,
ignoreAccess: Boolean = false ignoreAccess: Boolean = false
): Result[ListZonesResponse] = { ): Result[ListZonesResponse] = {
if(!searchByAdminGroup || nameFilter.isEmpty){
for { for {
listZonesResult <- zoneRepository.listZones( listZonesResult <- zoneRepository.listZones(
authPrincipal, authPrincipal,
@ -169,6 +173,29 @@ class ZoneService(
listZonesResult.maxItems, listZonesResult.maxItems,
listZonesResult.ignoreAccess 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 }.toResult
def zoneSummaryInfoMapping( def zoneSummaryInfoMapping(
@ -242,6 +269,10 @@ class ZoneService(
} yield zoneChange } yield zoneChange
} }
def getGroupsIdsByName(groupName: String): IO[Set[String]] = {
groupRepository.getGroupsByName(groupName).map(x => x.map(_.id))
}
def getBackendIds(): Result[List[String]] = def getBackendIds(): Result[List[String]] =
backendResolver.ids.toList.toResult backendResolver.ids.toList.toResult

View File

@ -42,6 +42,7 @@ trait ZoneServiceAlgebra {
nameFilter: Option[String], nameFilter: Option[String],
startFrom: Option[String], startFrom: Option[String],
maxItems: Int, maxItems: Int,
searchByAdminGroup: Boolean,
ignoreAccess: Boolean ignoreAccess: Boolean
): Result[ListZonesResponse] ): Result[ListZonesResponse]

View File

@ -106,7 +106,7 @@ object RecordSetChangeHandler extends TransactionProvider {
): List[SingleChange] = ): List[SingleChange] =
recordSetChange.status match { recordSetChange.status match {
case RecordSetChangeStatus.Complete => case RecordSetChangeStatus.Complete =>
singleChanges.map(_.complete(recordSetChange.id, recordSetChange.recordSet.id)) singleChanges.map(_.complete(recordSetChange.systemMessage, recordSetChange.id, recordSetChange.recordSet.id))
case RecordSetChangeStatus.Failed => case RecordSetChangeStatus.Failed =>
singleChanges.map(_.withProcessingError(recordSetChange.systemMessage, recordSetChange.id)) singleChanges.map(_.withProcessingError(recordSetChange.systemMessage, recordSetChange.id))
case _ => singleChanges case _ => singleChanges
@ -157,6 +157,15 @@ object RecordSetChangeHandler extends TransactionProvider {
def isDnsMatch(dnsResult: List[RecordSet], recordSet: RecordSet, zoneName: String): Boolean = def isDnsMatch(dnsResult: List[RecordSet], recordSet: RecordSet, zoneName: String): Boolean =
dnsResult.exists(matches(_, recordSet, zoneName)) 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 // Determine processing status by comparing request against disposition of DNS backend
def getProcessingStatus( def getProcessingStatus(
change: RecordSetChange, change: RecordSetChange,
@ -165,9 +174,9 @@ object RecordSetChangeHandler extends TransactionProvider {
change.changeType match { change.changeType match {
case RecordSetChangeType.Create => case RecordSetChangeType.Create =>
if (existingRecords.isEmpty) ReadyToApply(change) if (existingRecords.isEmpty) ReadyToApply(change)
else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name)) else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name) || isRecordExist(existingRecords,change))
AlreadyApplied(change) AlreadyApplied(change) //Record exists in DNS
else Failure(change, "Incompatible record already exists in DNS.") else Failure(change, "Incompatible record in DNS.")
case RecordSetChangeType.Update => case RecordSetChangeType.Update =>
if (isDnsMatch(existingRecords, change.recordSet, change.zone.name)) if (isDnsMatch(existingRecords, change.recordSet, change.zone.name))
@ -390,7 +399,7 @@ object RecordSetChangeHandler extends TransactionProvider {
case Failure(_, message) => case Failure(_, message) =>
Completed( Completed(
change.failed( 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) case Retry(_) => Retrying(change)
@ -430,7 +439,7 @@ object RecordSetChangeHandler extends TransactionProvider {
case Failure(_, message) => case Failure(_, message) =>
Completed( Completed(
change.failed( 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) case _ => Retrying(change)

View File

@ -120,7 +120,9 @@ trait MembershipJsonProtocol extends JsonValidation {
(js \ "userId").required[String]("Missing userId"), (js \ "userId").required[String]("Missing userId"),
(js \ "oldGroup").optional[GroupInfo], (js \ "oldGroup").optional[GroupInfo],
(js \ "id").default[String](UUID.randomUUID().toString), (js \ "id").default[String](UUID.randomUUID().toString),
(js \ "created").default[String](DateTime.now.getMillis.toString) (js \ "created").default[DateTime](DateTime.now),
(js \ "userName").required[String]("Missing userName"),
(js \ "groupChangeMessage").required[String]("Missing groupChangeMessage"),
).mapN(GroupChangeInfo.apply) ).mapN(GroupChangeInfo.apply)
} }
} }

View File

@ -45,6 +45,7 @@ class MembershipRoute(
case GroupNotFoundError(msg) => complete(StatusCodes.NotFound, msg) case GroupNotFoundError(msg) => complete(StatusCodes.NotFound, msg)
case NotAuthorizedError(msg) => complete(StatusCodes.Forbidden, msg) case NotAuthorizedError(msg) => complete(StatusCodes.Forbidden, msg)
case GroupAlreadyExistsError(msg) => complete(StatusCodes.Conflict, msg) case GroupAlreadyExistsError(msg) => complete(StatusCodes.Conflict, msg)
case GroupValidationError(msg) => complete(StatusCodes.BadRequest, msg)
case InvalidGroupError(msg) => complete(StatusCodes.BadRequest, msg) case InvalidGroupError(msg) => complete(StatusCodes.BadRequest, msg)
case UserNotFoundError(msg) => complete(StatusCodes.NotFound, msg) case UserNotFoundError(msg) => complete(StatusCodes.NotFound, msg)
case InvalidGroupRequestError(msg) => complete(StatusCodes.BadRequest, msg) case InvalidGroupRequestError(msg) => complete(StatusCodes.BadRequest, msg)
@ -79,7 +80,7 @@ class MembershipRoute(
} ~ } ~
(get & monitor("Endpoint.listMyGroups")) { (get & monitor("Endpoint.listMyGroups")) {
parameters( parameters(
"startFrom".?, "startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"groupNameFilter".?, "groupNameFilter".?,
"ignoreAccess".as[Boolean].?(false), "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 => path("users" / Segment / "lock") { id =>
(put & monitor("Endpoint.lockUser")) { (put & monitor("Endpoint.lockUser")) {
authenticateAndExecute(membershipService.updateUserLockStatus(id, LockStatus.Locked, _)) { authenticateAndExecute(membershipService.updateUserLockStatus(id, LockStatus.Locked, _)) {

View File

@ -78,12 +78,14 @@ class ZoneRoute(
"nameFilter".?, "nameFilter".?,
"startFrom".as[String].?, "startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"searchByAdminGroup".as[Boolean].?(false),
"ignoreAccess".as[Boolean].?(false) "ignoreAccess".as[Boolean].?(false)
) { ) {
( (
nameFilter: Option[String], nameFilter: Option[String],
startFrom: Option[String], startFrom: Option[String],
maxItems: Int, maxItems: Int,
searchByAdminGroup: Boolean,
ignoreAccess: Boolean ignoreAccess: Boolean
) => ) =>
{ {
@ -94,7 +96,7 @@ class ZoneRoute(
) { ) {
authenticateAndExecute( authenticateAndExecute(
zoneService zoneService
.listZones(_, nameFilter, startFrom, maxItems, ignoreAccess) .listZones(_, nameFilter, startFrom, maxItems, searchByAdminGroup, ignoreAccess)
) { result => ) { result =>
complete(StatusCodes.OK, result) complete(StatusCodes.OK, result)
} }

View File

@ -1481,8 +1481,8 @@ def test_a_recordtype_add_checks(shared_zone_test_context):
# context validations: conflicting recordsets, unauthorized error # 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", 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: " error_messages=[f"RecordName \"{existing_a_fqdn}\" already exists. Your request will be manually reviewed. "
f"cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) 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, assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn,
record_data="1.2.3.4", record_data="1.2.3.4",
error_messages=[f'CNAME Conflict: CNAME record names must be unique. ' 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_delete_fqdn, change_type="DeleteRecordSet"),
get_change_A_AAAA_json(rs_update_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(rs_update_fqdn, ttl=300),
get_change_A_AAAA_json(f"non-existent.{ok_zone_name}", change_type="DeleteRecordSet"),
# input validations failures # input validations failures
get_change_A_AAAA_json("$invalid.host.name.", change_type="DeleteRecordSet"), 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"), get_change_A_AAAA_json("zone.discovery.error.", change_type="DeleteRecordSet"),
# context validation failures: record does not exist, not authorized # 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_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, change_type="DeleteRecordSet"),
get_change_A_AAAA_json(rs_update_dummy_fqdn, ttl=300), 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[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[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[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 # 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", change_type="DeleteRecordSet",
error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, ' 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.']) '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", change_type="DeleteRecordSet",
error_messages=['Invalid Record Type In Reverse Zone: record with name "reverse.zone.in-addr.arpa." and type "A" ' 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.']) '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, ' 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.']) '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", change_type="DeleteRecordSet",
error_messages=['Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, ' 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.']) '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." ' 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.', 'and type "A" is not allowed in a reverse zone.',
'Invalid TTL: "10", must be a number between 30 and 2147483647.']) '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", change_type="DeleteRecordSet",
error_messages=['Invalid Record Type In Reverse Zone: record with name "another.reverse.zone.in-addr.arpa." ' 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.']) 'and type "A" is not allowed in a reverse zone.'])
# zone discovery failure # 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", change_type="DeleteRecordSet",
error_messages=['Zone Discovery Failed: zone for "zone.discovery.error." does not exist in VinylDNS. ' 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.']) 'If zone exists, then it must be connected to in VinylDNS.'])
# context validation failures: record does not exist, not authorized # 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, assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn,
change_type="DeleteRecordSet", 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.']) error_messages=[f'User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes.'])
@ -1731,10 +1728,8 @@ def test_aaaa_recordtype_add_checks(shared_zone_test_context):
f"cannot have multiple \"CNAME\" records with the same name."]) 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}", assert_successful_change_in_error_response(response[6], input_name=f"cname-duplicate.{parent_zone_name}",
record_type="AAAA", record_data="1::1") record_type="AAAA", record_data="1::1")
assert_failed_change_in_error_response(response[7], input_name=existing_aaaa_fqdn, record_type="AAAA", assert_successful_change_in_error_response(response[7], input_name=existing_aaaa_fqdn, record_type="AAAA",
record_data="1::1", 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."])
assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_type="AAAA", assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_type="AAAA",
record_data="1::1", record_data="1::1",
error_messages=[f"CNAME Conflict: CNAME record names must be unique. Existing record with name \"{existing_cname_fqdn}\" " 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_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", 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(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 # input validations failures
get_change_A_AAAA_json(f"invalid-name$.{ok_zone_name}", record_type="AAAA", change_type="DeleteRecordSet"), 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"), get_change_A_AAAA_json("no.zone.at.all.", record_type="AAAA", change_type="DeleteRecordSet"),
# context validation failures # 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(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_delete_dummy_fqdn, record_type="AAAA", change_type="DeleteRecordSet"),
get_change_A_AAAA_json(rs_update_dummy_fqdn, record_type="AAAA", address="1::1"), 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") 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", assert_successful_change_in_error_response(response[2], input_name=rs_update_fqdn, record_type="AAAA",
record_data=None, change_type="DeleteRecordSet") 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 # 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", record_data=None, change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", ' 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.']) 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", record_data=None, change_type="DeleteRecordSet",
error_messages=["Invalid Record Type In Reverse Zone: record with name \"reverse.zone.in-addr.arpa.\" and " 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."]) "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", record_type="AAAA", record_data=None, change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "bad-ttl-and-invalid-name$-update.{ok_zone_name}", ' 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.']) 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", 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.', 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'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.']) f'valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.'])
# zone discovery failure # 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", record_data=None, change_type="DeleteRecordSet",
error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " 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."]) "If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized # 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_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, assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn,
record_type="AAAA", record_data=None, change_type="DeleteRecordSet", 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.', 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}", ' 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.", "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."]) "joined by dots, and terminated with a dot."])
# zone discovery failure # zone discovery failure
assert_failed_change_in_error_response(response[7], input_name="no.zone.com.", record_type="CNAME", 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."]) 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, assert_failed_change_in_error_response(response[14], input_name=existing_cname_fqdn,
record_type="CNAME", record_data="test.com.", record_type="CNAME", record_data="test.com.",
error_messages=[f"Record \"{existing_cname_fqdn}\" Already Exists: cannot add an existing record; to update it, " error_messages=[f"RecordName \"{existing_cname_fqdn}\" already exists. Your request will be manually reviewed. "
f"issue a DeleteRecordSet then an Add.", 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"CNAME Conflict: CNAME record names must be unique. "
f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."]) 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", 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"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}", change_type="DeleteRecordSet"),
get_change_CNAME_json(f"update3.{ok_zone_name}", ttl=300), 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 # valid changes - reverse zone
get_change_CNAME_json(f"200.{ip4_zone_name}", change_type="DeleteRecordSet"), 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"), 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 # 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"non-existent-update.{ok_zone_name}"),
get_change_CNAME_json(f"delete-unauthorized3.{dummy_zone_name}", change_type="DeleteRecordSet"), 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"), 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") change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[2], input_name=f"update3.{ok_zone_name}", record_type="CNAME", ttl=300, assert_successful_change_in_error_response(response[2], input_name=f"update3.{ok_zone_name}", record_type="CNAME", ttl=300,
record_data="test.com.") 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 # 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") 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") 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.") record_type="CNAME", ttl=300, record_data="test.com.")
# ttl, domain name, data # 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", change_type="DeleteRecordSet",
error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, numbers, ' 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.']) '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", record_type="CNAME", change_type="DeleteRecordSet",
error_messages=['Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, ' 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.']) '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.", record_type="CNAME", record_data="$another.invalid.cname.",
error_messages=['Invalid TTL: "20", must be a number between 30 and 2147483647.', 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, ' '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.', '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.']) 'underscores, and hyphens, joined by dots, and terminated with a dot.'])
# zone discovery failure # 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", change_type="DeleteRecordSet",
error_messages=[ 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.']) '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 # 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}", assert_successful_change_in_error_response(response[12], input_name=f"non-existent-update.{ok_zone_name}",
record_type="CNAME", record_data="test.com.") record_type="CNAME", record_data="test.com.")
assert_failed_change_in_error_response(response[13], input_name=f"delete-unauthorized3.{dummy_zone_name}", 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 # 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.", 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.", assert_failed_change_in_error_response(response[12], input_name=f"{ip4_prefix}.199", record_type="PTR", record_data="existing-cname.",
error_messages=[ 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.']) 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}.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", ttl=300, ptrdname="has-updated.ptr."),
get_change_PTR_json(f"{ip4_prefix}.193", change_type="DeleteRecordSet"), 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 # valid changes: delete and add of same record name but different type
get_change_CNAME_json(f"21.{ip4_zone_name}", change_type="DeleteRecordSet"), 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"), get_change_PTR_json("192.1.1.25", change_type="DeleteRecordSet"),
# context validation failures # 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", 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.") record_data="has-updated.ptr.")
assert_successful_change_in_error_response(response[2], input_name=f"{ip4_prefix}.193", record_type="PTR", assert_successful_change_in_error_response(response[2], input_name=f"{ip4_prefix}.193", record_type="PTR",
record_data=None, change_type="DeleteRecordSet") 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 # 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") 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.") 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.") 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") record_data=None, change_type="DeleteRecordSet")
# input validations failures: invalid IP, ttl, and record data # 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", change_type="DeleteRecordSet",
error_messages=['Invalid IP address: "1.1.1".']) 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", change_type="DeleteRecordSet",
error_messages=['Invalid IP address: "192.0.2.".']) 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.", record_data="failed-update$.ptr.",
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.',
'Invalid IP address: "192.0.2.".', '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.']) 'joined by dots, and terminated with a dot.'])
# zone discovery failure # 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", 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, " 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."]) "then it must be connected to in VinylDNS."])
# context validation failures: record does not exist # context validation failures: record does not exist
assert_failed_change_in_error_response(response[11], input_name=f"{ip4_prefix}.199", record_type="PTR", assert_successful_change_in_error_response(response[13], ttl=300, input_name=f"{ip4_prefix}.200", record_type="PTR", record_data="has-updated.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."])
finally: finally:
clear_recordset_list(to_delete, ok_client) 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 # 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", assert_failed_change_in_error_response(response[5], input_name=f"{ip6_prefix}:1000::bbbb", record_type="PTR",
record_data="existing.ptr.", record_data="existing.ptr.",
error_messages=[f"Record \"{ip6_prefix}:1000::bbbb\" Already Exists: cannot add an existing record; " error_messages=[f"RecordName \"{ip6_prefix}:1000::bbbb\" already exists. Your request will be manually reviewed. "
"to update it, issue a DeleteRecordSet then an Add."]) f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add."])
finally: finally:
clear_recordset_list(to_delete, client) 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::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", 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::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 # input validations failures
get_change_PTR_json("fd69:27cc:fe91de::ab", change_type="DeleteRecordSet"), 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"), get_change_PTR_json("fedc:ba98:7654::abc", change_type="DeleteRecordSet"),
# context validation failures # 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", 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.") 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", assert_successful_change_in_error_response(response[2], input_name=f"{ip6_prefix}:1000::62", record_type="PTR",
record_data=None, change_type="DeleteRecordSet") 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 # 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", record_data=None, change_type="DeleteRecordSet",
error_messages=['Invalid IP address: "fd69:27cc:fe91de::ab".']) 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", record_data=None, change_type="DeleteRecordSet",
error_messages=['Invalid IP address: "fd69:27cc:fe91de::ba".']) 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.", record_type="PTR", record_data="failed-update$.ptr.",
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.',
'Invalid IP address: "fd69:27cc:fe91de::ba".', '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.']) 'and hyphens, joined by dots, and terminated with a dot.'])
# zone discovery failure # 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", record_data=None, change_type="DeleteRecordSet",
error_messages=["Zone Discovery Failed: zone for \"fedc:ba98:7654::abc\" does not exist in VinylDNS. " 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."]) "If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, failure on update with double add # 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", assert_successful_change_in_error_response(response[9], ttl=300, input_name=f"{ip6_prefix}:1000::65",
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",
record_type="PTR", record_data="has-updated.ptr.") 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: finally:
clear_recordset_list(to_delete, ok_client) 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."]) f"cannot have multiple \"CNAME\" records with the same name."])
# context validations: conflicting recordsets, unauthorized error # context validations: conflicting recordsets, unauthorized error
assert_failed_change_in_error_response(response[5], input_name=existing_txt_fqdn, record_type="TXT", assert_successful_change_in_error_response(response[5], input_name=existing_txt_fqdn, record_type="TXT",
record_data="test", 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_failed_change_in_error_response(response[6], input_name=existing_cname_fqdn, record_type="TXT", assert_failed_change_in_error_response(response[6], input_name=existing_cname_fqdn, record_type="TXT",
record_data="test", record_data="test",
error_messages=[f"CNAME Conflict: CNAME record names must be unique. " 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_delete_fqdn, change_type="DeleteRecordSet"),
get_change_TXT_json(rs_update_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(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 # input validations failures
get_change_TXT_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet"), 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"), get_change_TXT_json("no.zone.at.all.", change_type="DeleteRecordSet"),
# context validation failures # 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(f"update-nonexistent.{ok_zone_name}", text="test"),
get_change_TXT_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet"), get_change_TXT_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet"),
get_change_TXT_json(rs_update_dummy_fqdn, text="test"), 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[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[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[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 # 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 ' 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.']) 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.']) error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.'])
# zone discovery failure # 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=[ error_messages=[
"Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " "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."]) "If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized # 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_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", 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."]) 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."]) f"cannot have multiple \"CNAME\" records with the same name."])
# context validations: conflicting recordsets, unauthorized error # context validations: conflicting recordsets, unauthorized error
assert_failed_change_in_error_response(response[8], input_name=existing_mx_fqdn, record_type="MX", assert_successful_change_in_error_response(response[8], input_name=existing_mx_fqdn, record_type="MX",
record_data={"preference": 1, "exchange": "foo.bar."}, 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_failed_change_in_error_response(response[9], input_name=existing_cname_fqdn, record_type="MX", assert_failed_change_in_error_response(response[9], input_name=existing_cname_fqdn, record_type="MX",
record_data={"preference": 1, "exchange": "foo.bar."}, record_data={"preference": 1, "exchange": "foo.bar."},
error_messages=["CNAME Conflict: CNAME record names must be unique. " 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_delete_fqdn, change_type="DeleteRecordSet"),
get_change_MX_json(rs_update_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(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 # input validations failures
get_change_MX_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet"), 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"), get_change_MX_json("no.zone.at.all.", change_type="DeleteRecordSet"),
# context validation failures # 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(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_delete_dummy_fqdn, change_type="DeleteRecordSet"),
get_change_MX_json(rs_update_dummy_fqdn, preference=1000, exchange="foo.bar."), 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[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[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[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 # 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", change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be letters, ' 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.']) 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."}, record_data={"preference": 1, "exchange": "foo.bar."},
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) 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."}, record_data={"preference": 1, "exchange": "foo$.bar."},
error_messages=['Invalid domain name: "foo$.bar.", valid domain names must be letters, numbers, ' 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.']) '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."}, record_data={"preference": 1, "exchange": "foo.bar."},
error_messages=[f'Invalid Record Type In Reverse Zone: record with name "mx.{ip4_zone_name}" ' 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.']) f'and type "MX" is not allowed in a reverse zone.'])
# zone discovery failure # 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", record_data=None, change_type="DeleteRecordSet",
error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. " 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."]) "If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized # 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", 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."}) record_data={"preference": 1000, "exchange": "foo.bar."})
assert_failed_change_in_error_response(response[11], input_name=rs_delete_dummy_fqdn, record_type="MX", 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) 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 client = shared_zone_test_context.ok_vinyldns_client
ok_zone_name = shared_zone_test_context.ok_zone["name"] 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 = { batch_change_input = {
"comments": "test delete record failures", "comments": "test delete record failures",
"changes": [ "changes": [
get_change_A_AAAA_json(f"delete-non-existent-record.{ok_zone_name}", change_type="DeleteRecordSet"), 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")
] ]
} }
to_delete = [] response = client.create_batch_change(batch_change_input, status=202)
get_batch = client.get_batch_change(response["id"])
try: assert_that(get_batch["changes"][0]["systemMessage"], is_("This record does not exist." +
create_rs = client.create_recordset(a_delete, status=202) "No further action is required."))
to_delete.append(client.wait_until_recordset_change_status(create_rs, "Complete"))
errors = client.create_batch_change(batch_change_input, status=400) 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")
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)
@pytest.mark.serial @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=0),
get_change_MX_json(f"multi-mx.{ok_zone_name}", preference=1000, exchange="bar.foo."), 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") get_change_A_AAAA_json(rs_fqdn, address="1.1.1.1")
] ],
"ownerGroupId": shared_zone_test_context.ok_group["id"]
} }
try: try:
create_rs = client.create_recordset(rs_to_create, status=202) create_rs = client.create_recordset(rs_to_create, status=202)
to_delete.append(client.wait_until_recordset_change_status(create_rs, "Complete")) 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["changes"][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["changes"][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["changes"][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["changes"][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["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[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"][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["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[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"][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", assert_successful_change_in_error_response(response["changes"][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."])
finally: finally:
clear_recordset_list(to_delete, client) clear_recordset_list(to_delete, client)

View File

@ -26,16 +26,12 @@ def test_list_group_activity_start_from_success(group_activity_context, shared_z
# we grab 3 items, which when sorted by most recent will give the 3 most recent items # we grab 3 items, which when sorted by most recent will give the 3 most recent items
page_one = client.get_group_changes(created_group["id"], max_items=3, status=200) page_one = client.get_group_changes(created_group["id"], max_items=3, status=200)
# our start from will align with the created on the 3rd change in the list
start_from_index = 2
start_from = page_one["changes"][start_from_index]["created"] # start from a known good timestamp
# now, we say give me all changes since the start_from, which should yield 8-7-6-5-4 # 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["changes"], has_length(5))
assert_that(result["maxItems"], is_(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())) assert_that(result["nextId"], is_not(none()))
# we should have, in order, changes 8 7 6 5 4 # we should have, in order, changes 8 7 6 5 4

View File

@ -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 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) results = list_my_groups_context.client.list_my_groups(status=200)
assert_that(results, has_length(3)) # 3 fields assert_that(results, has_length(4)) # 4 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, is_not(has_key("groupNameFilter"))) assert_that(results, is_not(has_key("groupNameFilter")))
assert_that(results, is_not(has_key("startFrom"))) assert_that(results, is_not(has_key("startFrom")))
assert_that(results, is_not(has_key("nextId"))) assert_that(results, is_(has_key("nextId")))
assert_that(results["maxItems"], is_(200)) assert_that(results["maxItems"], is_(100))
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)))
def test_get_my_groups_using_old_account_auth(list_my_groups_context): 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 Test passing in an account will return an empty set
""" """
results = list_my_groups_context.client.list_my_groups(status=200) 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("groupNameFilter")))
assert_that(results, is_not(has_key("startFrom"))) assert_that(results, is_not(has_key("startFrom")))
assert_that(results, is_not(has_key("nextId"))) assert_that(results, is_(has_key("nextId")))
assert_that(results["maxItems"], is_(200)) assert_that(results["maxItems"], is_(100))
def test_list_my_groups_max_items(list_my_groups_context): 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["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("startFrom")))
assert_that(results, is_not(has_key("nextId"))) 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"]) 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 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) results = list_my_groups_context.client.list_my_groups(ignore_access=True, status=200)
assert_that(results, has_length(4)) # 4 fields
# Only count the groups with the group prefix
assert_that(len(results["groups"]), greater_than(50)) 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)) 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): 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 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) 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(len(results["groups"]), greater_than(50))
assert_that(results["maxItems"], is_(200)) assert_that(results["maxItems"], is_(100))
assert_that(results["ignoreAccess"], is_(False)) 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 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) 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(len(results["groups"]), greater_than(50))
assert_that(results["maxItems"], is_(200)) assert_that(results["maxItems"], is_(100))
assert_that(results["ignoreAccess"], is_(True)) assert_that(results["ignoreAccess"], is_(True))

View File

@ -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 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"] zone_name = shared_zone_test_context.parent_zone["name"]
error = client.create_recordset(dotted_host_a_record, status=422) 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 " assert_that(error, is_("Record type is not allowed or the user is not authorized to create a dotted host in the "
"is not allowed in zone " + zone_name)) "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): 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") 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 client = shared_zone_test_context.dummy_vinyldns_client
zone = shared_zone_test_context.parent_zone zone = shared_zone_test_context.dummy_zone
apex_cname_rs = { dotted_host_cname_record = {
"zoneId": zone["id"], "zoneId": zone["id"],
"name": "dot.ted", "name": "dot.ted",
"type": "CNAME", "type": "CNAME",
@ -595,8 +648,37 @@ def test_create_dotted_cname_record_fails(shared_zone_test_context):
"records": [{"cname": "foo.bar."}] "records": [{"cname": "foo.bar."}]
} }
error = client.create_recordset(apex_cname_rs, status=422) error = client.create_recordset(dotted_host_cname_record, 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"]}')) 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): 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"] a_record = client.wait_until_recordset_change_status(a_create, "Complete")["recordSet"]
error = client.create_recordset(cname_rs, status=409) 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: finally:
if a_record: if a_record:
delete_result = client.delete_recordset(a_record["zoneId"], a_record["id"], status=202) 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"] cname_record = client.wait_until_recordset_change_status(cname_create, "Complete")["recordSet"]
error = client.create_recordset(a_rs, status=409) 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: finally:
if cname_record: if cname_record:
delete_result = client.delete_recordset(cname_record["zoneId"], cname_record["id"], status=202) 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) result = client.create_recordset(new_rs, status=202)
assert_that(result["changeType"], is_("Create")) assert_that(result["changeType"], is_("Create"))
assert_that(result["status"], is_("Pending")) assert_that(result["status"], is_("Pending"))
assert_that(result["created"], is_not(none())) 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) result = client.create_recordset(new_rs, status=202)
assert_that(result["changeType"], is_("Create")) assert_that(result["changeType"], is_("Create"))
assert_that(result["status"], is_("Pending")) assert_that(result["status"], is_("Pending"))
assert_that(result["created"], is_not(none())) assert_that(result["created"], is_not(none()))
@ -1743,7 +1825,8 @@ def test_create_high_value_domain_fails(shared_zone_test_context):
} }
error = client.create_recordset(new_rs, status=422) 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): def test_create_high_value_domain_fails_case_insensitive(shared_zone_test_context):
@ -1765,7 +1848,8 @@ def test_create_high_value_domain_fails_case_insensitive(shared_zone_test_contex
} }
error = client.create_recordset(new_rs, status=422) 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): def test_create_high_value_domain_fails_for_ip4_ptr(shared_zone_test_context):
@ -1786,7 +1870,8 @@ def test_create_high_value_domain_fails_for_ip4_ptr(shared_zone_test_context):
} }
error_ptr = client.create_recordset(ptr, status=422) 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): def test_create_high_value_domain_fails_for_ip6_ptr(shared_zone_test_context):
@ -1807,7 +1892,8 @@ def test_create_high_value_domain_fails_for_ip6_ptr(shared_zone_test_context):
} }
error_ptr = client.create_recordset(ptr, status=422) 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): def test_create_with_owner_group_in_private_zone_by_admin_passes(shared_zone_test_context):
@ -1874,7 +1960,8 @@ def test_create_with_owner_group_in_private_zone_by_acl_passes(shared_zone_test_
finally: finally:
clear_ok_acl_rules(shared_zone_test_context) clear_ok_acl_rules(shared_zone_test_context)
if create_rs: 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") shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(delete_result, "Complete")
@ -1900,8 +1987,11 @@ def test_create_with_owner_group_in_shared_zone_by_acl_passes(shared_zone_test_c
finally: finally:
clear_shared_zone_acl_rules(shared_zone_test_context) clear_shared_zone_acl_rules(shared_zone_test_context)
if create_rs: if create_rs:
delete_result = shared_zone_test_context.shared_zone_vinyldns_client.delete_recordset(zone["id"], create_rs["id"], status=202) delete_result = shared_zone_test_context.shared_zone_vinyldns_client.delete_recordset(zone["id"],
shared_zone_test_context.shared_zone_vinyldns_client.wait_until_recordset_change_status(delete_result, "Complete") 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): def test_create_in_shared_zone_without_owner_group_id_succeeds(shared_zone_test_context):
@ -1955,10 +2045,12 @@ def test_create_in_shared_zone_by_unassociated_user_fails_if_record_type_is_not_
zone = shared_zone_test_context.shared_zone zone = shared_zone_test_context.shared_zone
group = shared_zone_test_context.dummy_group 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"] record_json["ownerGroupId"] = group["id"]
error = client.create_recordset(record_json, status=403) 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): def test_create_with_not_found_owner_group_fails(shared_zone_test_context):
@ -1997,7 +2089,8 @@ def test_create_ds_success(shared_zone_test_context):
zone = shared_zone_test_context.ds_zone zone = shared_zone_test_context.ds_zone
record_data = [ record_data = [
{"keytag": 60485, "algorithm": 5, "digesttype": 1, "digest": "2BB183AF5F22588179A53B0A98631FAD1A292118"}, {"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) record_json = create_recordset(zone, "dskey", "DS", record_data, ttl=3600)
result_rs = None result_rs = None
@ -2039,7 +2132,8 @@ def test_create_ds_unknown_algorithm(shared_zone_test_context):
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.ds_zone 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) record_json = create_recordset(zone, "dskey", "DS", record_data)
errors = client.create_recordset(record_json, status=400)["errors"] errors = client.create_recordset(record_json, status=400)["errors"]
assert_that(errors, contains_inanyorder("Algorithm 0 is not a supported DNSSEC algorithm")) assert_that(errors, contains_inanyorder("Algorithm 0 is not a supported DNSSEC algorithm"))
@ -2051,7 +2145,8 @@ def test_create_ds_unknown_digest_type(shared_zone_test_context):
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.ds_zone 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) record_json = create_recordset(zone, "dskey", "DS", record_data)
errors = client.create_recordset(record_json, status=400)["errors"] 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")) assert_that(errors, contains_inanyorder("Digest Type 0 is not a supported DS record digest type"))
@ -2063,10 +2158,12 @@ def test_create_ds_no_ns_fails(shared_zone_test_context):
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.ds_zone 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) record_json = create_recordset(zone, "no-ns-exists", "DS", record_data, ttl=3600)
error = client.create_recordset(record_json, status=422) 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): def test_create_apex_ds_fails(shared_zone_test_context):
@ -2075,7 +2172,8 @@ def test_create_apex_ds_fails(shared_zone_test_context):
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.ds_zone 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) record_json = create_recordset(zone, "@", "DS", record_data, ttl=100)
error = client.create_recordset(record_json, status=422) 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')) assert_that(error, is_(f'Record with name [{zone["name"]}] is an DS record at apex and cannot be added'))
@ -2087,7 +2185,9 @@ def test_create_dotted_ds_fails(shared_zone_test_context):
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.ds_zone 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) record_json = create_recordset(zone, "dotted.ds", "DS", record_data, ttl=100)
error = client.create_recordset(record_json, status=422) 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"]}'))

View File

@ -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) update_response = client.update_recordset(update_rs, status=202)
response = client.wait_until_recordset_change_status(update_response, "Failed") 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.")) f"This record set is out of sync with the DNS backend; sync this zone before attempting to update this record set."))
finally: finally:
try: try:

View File

@ -174,6 +174,15 @@ class SharedZoneTestContext(object):
"shared": False, "shared": False,
"adminGroupId": self.dummy_group["id"], "adminGroupId": self.dummy_group["id"],
"isTest": True, "isTest": True,
"acl": {
"rules": [
{
"accessLevel": "Delete",
"description": "some_test_rule",
"userId": "history-id"
}
]
},
"connection": { "connection": {
"name": "dummy.", "name": "dummy.",
"keyName": VinylDNSTestContext.dns_key_name, "keyName": VinylDNSTestContext.dns_key_name,

View File

@ -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}")) 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): def test_list_zones_max_items_100(shared_zone_test_context):
""" """
Test that the default max items for a list zones request is 100 Test that the default max items for a list zones request is 100

View File

@ -220,7 +220,7 @@ class VinylDNSClient(object):
return data 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 Retrieves my groups
:param start_from: the start key of the page :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) response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data 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 Gets a list of zones that currently exist
:return: a list of zones :return: a list of zones
@ -462,6 +462,9 @@ class VinylDNSClient(object):
if max_items: if max_items:
query.append("maxItems=" + str(max_items)) query.append("maxItems=" + str(max_items))
if search_by_admin_group:
query.append("searchByAdminGroup=" + str(search_by_admin_group))
if ignore_access: if ignore_access:
query.append("ignoreAccess=" + str(ignore_access)) query.append("ignoreAccess=" + str(ignore_access))

View File

@ -18,7 +18,7 @@ package vinyldns.api
import com.comcast.ip4s.IpAddress import com.comcast.ip4s.IpAddress
import org.joda.time.DateTime import org.joda.time.DateTime
import vinyldns.api.config.{BatchChangeConfig, HighValueDomainConfig, LimitsConfig, ManualReviewConfig, ScheduledChangesConfig} import vinyldns.api.config.{ZoneAuthConfigs, BatchChangeConfig, DottedHostsConfig, HighValueDomainConfig, LimitsConfig, ManualReviewConfig, ScheduledChangesConfig}
import vinyldns.api.domain.batch.V6DiscoveryNibbleBoundaries import vinyldns.api.domain.batch.V6DiscoveryNibbleBoundaries
import vinyldns.core.domain.record._ import vinyldns.core.domain.record._
import vinyldns.core.domain.zone._ import vinyldns.core.domain.zone._
@ -40,6 +40,10 @@ trait VinylDNSTestHelpers {
val approvedNameServers: List[Regex] = List(new Regex("some.test.ns.")) 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 defaultTtl: Long = 7200
val manualReviewDomainList: List[Regex] = List(new Regex("needs-review.*")) val manualReviewDomainList: List[Regex] = List(new Regex("needs-review.*"))

View File

@ -19,11 +19,10 @@ package vinyldns.api.domain
import cats.scalatest.ValidatedMatchers import cats.scalatest.ValidatedMatchers
import org.scalacheck._ import org.scalacheck._
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
import org.scalatest._
import org.scalatest.propspec.AnyPropSpec import org.scalatest.propspec.AnyPropSpec
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import vinyldns.api.ValidationTestImprovements._ import vinyldns.api.ValidationTestImprovements._
import vinyldns.core.domain.{InvalidDomainName, InvalidLength} import vinyldns.core.domain.{InvalidDomainName, InvalidCname, InvalidLength}
class DomainValidationsSpec class DomainValidationsSpec
extends AnyPropSpec extends AnyPropSpec
@ -111,4 +110,52 @@ class DomainValidationsSpec
val invalidDesc = "a" * 256 val invalidDesc = "a" * 256
validateStringLength(Some(invalidDesc), None, 255).failWith[InvalidLength] 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]
}
} }

View File

@ -37,6 +37,8 @@ import vinyldns.core.domain.record._
import vinyldns.core.domain.zone.Zone import vinyldns.core.domain.zone.Zone
class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelpers { 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( private def makeSingleAddChange(
name: String, name: String,
@ -160,6 +162,14 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper
makeAddChangeForValidation("mxToUpdate", MXData(1, Fqdn("update.com.")), MX) 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( private val singleChangesOneBad = List(
makeSingleAddChange("one", AData("1.1.1.1")), makeSingleAddChange("one", AData("1.1.1.1")),
makeSingleAddChange("two", AData("1.1.1.2")), makeSingleAddChange("two", AData("1.1.1.2")),
@ -535,6 +545,42 @@ class BatchChangeConverterSpec extends AnyWordSpec with Matchers with CatsHelper
savedBatch shouldBe Some(returnedBatch) 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 { "return error if an unsupported record is received" in {
val batchChangeUnsupported = val batchChangeUnsupported =
BatchChange( BatchChange(

View File

@ -66,8 +66,8 @@ class BatchChangeServiceSpec
private implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) private implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
private val nonFatalError = ZoneDiscoveryError("test") private val nonFatalErrorZoneDiscoveryError = ZoneDiscoveryError("test")
private val fatalError = RecordAlreadyExists("test") private val nonFatalErrorRecordAlreadyExists = RecordAlreadyExists("test", AData("1.1.1.1"), true)
private val validations = new BatchChangeValidations( private val validations = new BatchChangeValidations(
new AccessValidations( new AccessValidations(
@ -747,7 +747,7 @@ class BatchChangeServiceSpec
"succeed if the batchChange is PendingReview and reviewer is authorized" in { "succeed if the batchChange is PendingReview and reviewer is authorized" in {
batchChangeRepo.save(batchChangeNeedsApproval) batchChangeRepo.save(batchChangeNeedsApproval)
val result = val result = {
rightResultOf( rightResultOf(
underTestManualEnabled underTestManualEnabled
.approveBatchChange( .approveBatchChange(
@ -757,6 +757,7 @@ class BatchChangeServiceSpec
) )
.value .value
) )
}
result.userId shouldBe batchChangeNeedsApproval.userId result.userId shouldBe batchChangeNeedsApproval.userId
result.userName shouldBe batchChangeNeedsApproval.userName result.userName shouldBe batchChangeNeedsApproval.userName
@ -1708,8 +1709,8 @@ class BatchChangeServiceSpec
BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete), Some("owner-group-ID")), BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete), Some("owner-group-ID")),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
true true
@ -1745,7 +1746,7 @@ class BatchChangeServiceSpec
None, None,
None, None,
None, None,
List(SingleChangeError(nonFatalError)), List(SingleChangeError(nonFatalErrorZoneDiscoveryError)),
result.changes(1).id result.changes(1).id
) )
result.changes(2) shouldBe SingleDeleteRRSetChange( result.changes(2) shouldBe SingleDeleteRRSetChange(
@ -1759,7 +1760,7 @@ class BatchChangeServiceSpec
None, None,
None, None,
None, None,
List(SingleChangeError(nonFatalError)), List(SingleChangeError(nonFatalErrorZoneDiscoveryError)),
result.changes(2).id result.changes(2).id
) )
} }
@ -1775,8 +1776,8 @@ class BatchChangeServiceSpec
), ),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
allowManualReview = true allowManualReview = true
@ -1809,7 +1810,7 @@ class BatchChangeServiceSpec
List( List(
ZoneDiscoveryError("no.zone.match.").invalidNel, ZoneDiscoveryError("no.zone.match.").invalidNel,
AddChangeForValidation(baseZone, "non-apex", nonApexAddA, 7200L).validNel, AddChangeForValidation(baseZone, "non-apex", nonApexAddA, 7200L).validNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
true true
@ -1825,7 +1826,7 @@ class BatchChangeServiceSpec
ibcr.changeRequestResponses(1) shouldBe Valid( ibcr.changeRequestResponses(1) shouldBe Valid(
AddChangeForValidation(baseZone, "non-apex", nonApexAddA, 7200L) 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 { "return a BatchChangeErrorList if all data inputs are valid/soft failures and manual review is disabled" in {
@ -1835,8 +1836,8 @@ class BatchChangeServiceSpec
BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)), BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
true true
@ -1860,8 +1861,8 @@ class BatchChangeServiceSpec
), ),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
true true
@ -1902,8 +1903,8 @@ class BatchChangeServiceSpec
BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)), BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA, delete)),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
false false
@ -1926,8 +1927,8 @@ class BatchChangeServiceSpec
), ),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel, nonFatalErrorZoneDiscoveryError.invalidNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
allowManualReview = false allowManualReview = false
@ -1945,7 +1946,7 @@ class BatchChangeServiceSpec
BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA), None), BatchChangeInput(None, List(apexAddA, onlyBaseAddAAAA), None),
List( List(
AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel, AddChangeForValidation(apexZone, "apex.test.com.", apexAddA, 7200L).validNel,
nonFatalError.invalidNel nonFatalErrorZoneDiscoveryError.invalidNel
), ),
okAuth, okAuth,
true true
@ -2007,7 +2008,7 @@ class BatchChangeServiceSpec
asAdds.head, asAdds.head,
7200L 7200L
).validNel, ).validNel,
fatalError.invalidNel nonFatalErrorRecordAlreadyExists.invalidNel
), ),
reviewInfo reviewInfo
) )

View File

@ -712,7 +712,7 @@ class BatchChangeValidationsSpec
) )
val result = validateAddChangeInput(change, false) 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 property("""validateAddChangeInput: should fail with InvalidLength
@ -824,10 +824,10 @@ class BatchChangeValidationsSpec
result(0) shouldBe valid result(0) shouldBe valid
result(1) should haveInvalid[DomainValidationError]( result(1) should haveInvalid[DomainValidationError](
RecordAlreadyExists(existingA.inputChange.inputName) RecordAlreadyExists(existingA.inputChange.inputName, existingA.inputChange.record, false)
) )
result(2) should haveInvalid[DomainValidationError]( result(2) should haveInvalid[DomainValidationError](
RecordAlreadyExists(existingCname.inputChange.inputName) RecordAlreadyExists(existingCname.inputChange.inputName, existingCname.inputChange.record, false)
).and( ).and(
haveInvalid[DomainValidationError]( haveInvalid[DomainValidationError](
CnameIsNotUniqueError(existingCname.inputChange.inputName, existingCname.inputChange.typ) CnameIsNotUniqueError(existingCname.inputChange.inputName, existingCname.inputChange.typ)
@ -1004,7 +1004,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 deleteRRSet = makeDeleteUpdateDeleteRRSet("deleteRRSet")
val deleteRecord = makeDeleteUpdateDeleteRRSet("deleteRecord", Some(AData("1.1.1.1"))) val deleteRecord = makeDeleteUpdateDeleteRRSet("deleteRecord", Some(AData("1.1.1.1")))
val deleteNonExistentEntry = makeDeleteUpdateDeleteRRSet("ok", Some(AData("1.1.1.1"))) val deleteNonExistentEntry = makeDeleteUpdateDeleteRRSet("ok", Some(AData("1.1.1.1")))
@ -1026,15 +1026,8 @@ class BatchChangeValidationsSpec
) )
result(0) shouldBe valid result(0) shouldBe valid
result(1) should haveInvalid[DomainValidationError]( result(1) shouldBe valid
RecordDoesNotExist(deleteRRSet.inputChange.inputName) result(3) shouldBe valid
)
result(3) should haveInvalid[DomainValidationError](
RecordDoesNotExist(deleteRecord.inputChange.inputName)
)
result(3) should haveInvalid[DomainValidationError](
RecordDoesNotExist(deleteRecord.inputChange.inputName)
)
result(4) shouldBe valid result(4) shouldBe valid
deleteNonExistentEntry.inputChange.record.foreach { record => deleteNonExistentEntry.inputChange.record.foreach { record =>
result(5) should haveInvalid[DomainValidationError]( result(5) should haveInvalid[DomainValidationError](
@ -1189,7 +1182,7 @@ class BatchChangeValidationsSpec
) )
result(0) should haveInvalid[DomainValidationError]( result(0) should haveInvalid[DomainValidationError](
RecordAlreadyExists(input.inputChange.inputName) RecordAlreadyExists(input.inputChange.inputName, input.inputChange.record, false)
) )
} }
} }
@ -1589,7 +1582,7 @@ class BatchChangeValidationsSpec
} }
property( property(
"""validateChangesWithContext: should fail DeleteChangeForValidation with RecordDoesNotExist """validateChangesWithContext: should complete DeleteChangeForValidation
|if record does not exist""".stripMargin |if record does not exist""".stripMargin
) { ) {
val deleteRRSet = makeDeleteUpdateDeleteRRSet("record-does-not-exist") val deleteRRSet = makeDeleteUpdateDeleteRRSet("record-does-not-exist")
@ -1606,12 +1599,8 @@ class BatchChangeValidationsSpec
None None
) )
result(0) should haveInvalid[DomainValidationError]( result(0) shouldBe valid
RecordDoesNotExist(deleteRRSet.inputChange.inputName) result(1) shouldBe valid
)
result(1) should haveInvalid[DomainValidationError](
RecordDoesNotExist(deleteRecord.inputChange.inputName)
)
} }
property("""validateChangesWithContext: should succeed for DeleteChangeForValidation property("""validateChangesWithContext: should succeed for DeleteChangeForValidation
@ -2164,7 +2153,7 @@ class BatchChangeValidationsSpec
result should haveInvalid[DomainValidationError](InvalidIpv4Address(invalidIp)) 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( val existingMX = rsOk.copy(
zoneId = okZone.id, zoneId = okZone.id,
name = "name-conflict", name = "name-conflict",
@ -2185,7 +2174,7 @@ class BatchChangeValidationsSpec
false, false,
None None
) )
result(0) should haveInvalid[DomainValidationError](RecordAlreadyExists("name-conflict.")) result(0) shouldBe valid
} }
property("validateChangesWithContext: should succeed if duplicate MX records in batch") { property("validateChangesWithContext: should succeed if duplicate MX records in batch") {
@ -2666,4 +2655,21 @@ class BatchChangeValidationsSpec
result(3) shouldBe valid result(3) shouldBe valid
result(4) 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
}
} }

View File

@ -114,6 +114,7 @@ class MembershipServiceSpec
"create a new group" should { "create a new group" should {
"save the group and add the members when the group is valid" in { "save the group and add the members when the group is valid" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@ -141,6 +142,7 @@ class MembershipServiceSpec
"save the groupChange in the groupChangeRepo" in { "save the groupChange in the groupChangeRepo" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@ -168,7 +170,7 @@ class MembershipServiceSpec
adminUserIds = Set(okUserInfo.id, dummyUserInfo.id) adminUserIds = Set(okUserInfo.id, dummyUserInfo.id)
) )
val expectedMembersAdded = 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).groupWithSameNameDoesNotExist(info.name)
doReturn(().toResult).when(underTest).usersExist(any[Set[String]]) doReturn(().toResult).when(underTest).usersExist(any[Set[String]])
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) 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 { "set the current user as a member" in {
val info = groupInfo.copy(memberIds = Set.empty, adminUserIds = Set.empty) val info = groupInfo.copy(memberIds = Set.empty, adminUserIds = Set.empty)
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(info.name)
doReturn(().toResult).when(underTest).usersExist(Set(okAuth.userId)) doReturn(().toResult).when(underTest).usersExist(Set(okAuth.userId))
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) 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 { "return an error if users do not exist" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(result(UserNotFoundError("fail"))) doReturn(result(UserNotFoundError("fail")))
.when(underTest) .when(underTest)
@ -239,6 +243,7 @@ class MembershipServiceSpec
"return an error if fail while saving the group" in { "return an error if fail while saving the group" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.raiseError(new RuntimeException("fail"))).when(mockGroupRepo).save(any[DB], any[Group]) 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 { "return an error if fail while adding the members" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok") 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).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds) doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group]) 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) val error = leftResultOf(underTest.createGroup(groupInfo, okAuth).value)
error shouldBe a[RuntimeException] 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 { "update an existing group" should {
@ -388,6 +408,31 @@ class MembershipServiceSpec
error shouldBe a[GroupAlreadyExistsError] 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 { "return an error if the group is not found" in {
doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(existingGroup.id) doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(existingGroup.id)
@ -597,6 +642,30 @@ class MembershipServiceSpec
ignoreAccess = false 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 { "return only return groups after startFrom" in {
doReturn(IO.pure(listOfDummyGroups.toSet)) doReturn(IO.pure(listOfDummyGroups.toSet))
.when(mockGroupRepo) .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 { "getGroupActivity" should {
"return the group activity" in { "return the group activity" in {
val groupChangeRepoResponse = ListGroupChangesResults( val groupChangeRepoResponse = ListGroupChangesResults(
@ -724,8 +846,13 @@ class MembershipServiceSpec
.when(mockGroupChangeRepo) .when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt) .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] = 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 = val result: ListGroupChangesResponse =
rightResultOf(underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value) rightResultOf(underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value)
@ -744,8 +871,13 @@ class MembershipServiceSpec
.when(mockGroupChangeRepo) .when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt) .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] = 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 = val result: ListGroupChangesResponse =
rightResultOf(underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value) 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 { "listAdmins" should {
"return a list of admins" in { "return a list of admins" in {
val testGroup = val testGroup =

View File

@ -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]
}
}
} }
} }

View File

@ -103,4 +103,15 @@ class RecordSetChangeSpec extends AnyWordSpec with Matchers {
result.systemMessage shouldBe None 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
}
}
} }

View File

@ -24,6 +24,7 @@ import org.scalatestplus.mockito.MockitoSugar
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.BeforeAndAfterEach import org.scalatest.BeforeAndAfterEach
import vinyldns.api.config.{ZoneAuthConfigs, DottedHostsConfig}
import vinyldns.api.{ResultHelpers, VinylDNSTestHelpers} import vinyldns.api.{ResultHelpers, VinylDNSTestHelpers}
import vinyldns.api.domain.access.AccessValidations import vinyldns.api.domain.access.AccessValidations
import vinyldns.api.domain.record.RecordSetHelpers._ import vinyldns.api.domain.record.RecordSetHelpers._
@ -83,6 +84,7 @@ class RecordSetServiceSpec
mockBackendResolver, mockBackendResolver,
false, false,
VinylDNSTestHelpers.highValueDomainConfig, VinylDNSTestHelpers.highValueDomainConfig,
VinylDNSTestHelpers.dottedHostsConfig,
VinylDNSTestHelpers.approvedNameServers, VinylDNSTestHelpers.approvedNameServers,
true true
) )
@ -101,10 +103,57 @@ class RecordSetServiceSpec
mockBackendResolver, mockBackendResolver,
true, true,
VinylDNSTestHelpers.highValueDomainConfig, VinylDNSTestHelpers.highValueDomainConfig,
VinylDNSTestHelpers.dottedHostsConfig,
VinylDNSTestHelpers.approvedNameServers, VinylDNSTestHelpers.approvedNameServers,
true 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 { "addRecordSet" should {
"return the recordSet change as the result" in { "return the recordSet change as the result" in {
val record = aaaa.copy(zoneId = okZone.id) val record = aaaa.copy(zoneId = okZone.id)
@ -115,6 +164,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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 = val result: RecordSetChange =
rightResultOf( rightResultOf(
@ -132,7 +202,6 @@ class RecordSetServiceSpec
val result = leftResultOf(underTest.getRecordSetByZone(aaaa.id, mockZone.id, okAuth).value) val result = leftResultOf(underTest.getRecordSetByZone(aaaa.id, mockZone.id, okAuth).value)
result shouldBe a[ZoneNotFoundError] result shouldBe a[ZoneNotFoundError]
} }
"fail when the account is not authorized" in { "fail when the account is not authorized" in {
doReturn(IO.pure(Some(aaaa))) doReturn(IO.pure(Some(aaaa)))
.when(mockRecordRepo) .when(mockRecordRepo)
@ -155,7 +224,7 @@ class RecordSetServiceSpec
val result = leftResultOf(underTest.addRecordSet(aaaa, okAuth).value) val result = leftResultOf(underTest.addRecordSet(aaaa, okAuth).value)
result shouldBe a[RecordSetAlreadyExists] 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 = val record =
aaaa.copy(name = "new.name", zoneId = okZone.id, status = RecordSetStatus.Active) aaaa.copy(name = "new.name", zoneId = okZone.id, status = RecordSetStatus.Active)
@ -165,10 +234,66 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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) val result = leftResultOf(underTest.addRecordSet(record, okAuth).value)
result shouldBe an[InvalidRequest] 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 { "fail if the record is relative with trailing dot" in {
val record = val record =
aaaa.copy(name = "new.", zoneId = okZone.id, status = RecordSetStatus.Active) aaaa.copy(name = "new.", zoneId = okZone.id, status = RecordSetStatus.Active)
@ -179,6 +304,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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 = val result =
leftResultOf(underTestWithDnsBackendValidations.addRecordSet(record, okAuth).value) leftResultOf(underTestWithDnsBackendValidations.addRecordSet(record, okAuth).value)
@ -204,6 +350,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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( val result: RecordSetChange = rightResultOf(
underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -222,6 +389,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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( val result: RecordSetChange = rightResultOf(
underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -259,6 +447,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(Some(okGroup))) doReturn(IO.pure(Some(okGroup)))
.when(mockGroupRepo) .when(mockGroupRepo)
.getGroup(okGroup.id) .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( val result: RecordSetChange = rightResultOf(
underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -312,6 +521,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, record.name) .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 = val result: RecordSetChange =
rightResultOf( rightResultOf(
@ -326,6 +556,296 @@ class RecordSetServiceSpec
result.status shouldBe RecordSetChangeStatus.Pending 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 { "updateRecordSet" should {
"return the recordSet change as the result" in { "return the recordSet change as the result" in {
@ -341,6 +861,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, newRecord.name) .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( val result: RecordSetChange = rightResultOf(
underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -377,6 +918,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, newRecord.name) .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( val result: RecordSetChange = rightResultOf(
underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -416,6 +978,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, newRecord.name) .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( val result: RecordSetChange = rightResultOf(
underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -438,6 +1021,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, newRecord.name) .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( val result: RecordSetChange = rightResultOf(
underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -460,6 +1064,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(okZone.id, newRecord.name) .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( val result: RecordSetChange = rightResultOf(
underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value
@ -595,6 +1220,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(Some(oneUserDummyGroup))) doReturn(IO.pure(Some(oneUserDummyGroup)))
.when(mockGroupRepo) .when(mockGroupRepo)
.getGroup(oneUserDummyGroup.id) .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( val result = rightResultOf(
underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value
@ -624,6 +1270,27 @@ class RecordSetServiceSpec
doReturn(IO.pure(List(oldRecord))) doReturn(IO.pure(List(oldRecord)))
.when(mockRecordRepo) .when(mockRecordRepo)
.getRecordSetsByName(zone.id, newRecord.name) .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( val result = rightResultOf(
underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value

View File

@ -44,6 +44,8 @@ class RecordSetValidationsSpec
import RecordSetValidations._ import RecordSetValidations._
val dottedHostsConfigZonesAllowed: List[String] = VinylDNSTestHelpers.dottedHostsConfig.zoneAuthConfigs.map(x => x.zone)
"RecordSetValidations" should { "RecordSetValidations" should {
"validRecordTypes" should { "validRecordTypes" should {
"return invalid request when adding a PTR record to a forward zone" in { "return invalid request when adding a PTR record to a forward zone" in {
@ -184,24 +186,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 { "typeSpecificValidations" should {
"Run dotted hosts checks" should { "Run dotted hosts checks" should {
val dottedARecord = rsOk.copy(name = "this.is.a.failure.") val dottedARecord = rsOk.copy(name = "this.is.a.failure.")
"return a failure for any new record with dotted hosts in forward zones" in { "return a failure for any new record with dotted hosts in forward zones" in {
leftValue( leftValue(
typeSpecificValidations(dottedARecord, List(), okZone, None, Nil) typeSpecificValidations(dottedARecord, List(), okZone, None, Nil, true, dottedHostsConfigZonesAllowed.toSet, false)
) shouldBe an[InvalidRequest] ) shouldBe an[InvalidRequest]
} }
"return a failure for any new record with dotted hosts in forward zones (CNAME)" in { "return a failure for any new record with dotted hosts in forward zones (CNAME)" in {
leftValue( 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] ) shouldBe an[InvalidRequest]
} }
"return a failure for any new record with dotted hosts in forward zones (NS)" in { "return a failure for any new record with dotted hosts in forward zones (NS)" in {
leftValue( 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] ) shouldBe an[InvalidRequest]
} }
@ -211,7 +264,10 @@ class RecordSetValidationsSpec
List(), List(),
okZone, okZone,
Some(dottedARecord.copy(ttl = 300)), Some(dottedARecord.copy(ttl = 300)),
Nil Nil,
true,
dottedHostsConfigZonesAllowed.toSet,
false
) should be(right) ) should be(right)
} }
@ -222,7 +278,10 @@ class RecordSetValidationsSpec
List(), List(),
okZone, okZone,
Some(dottedCNAMERecord.copy(ttl = 300)), Some(dottedCNAMERecord.copy(ttl = 300)),
Nil Nil,
true,
dottedHostsConfigZonesAllowed.toSet,
false
) should be(right) ) should be(right)
} }
@ -234,7 +293,10 @@ class RecordSetValidationsSpec
List(), List(),
okZone, okZone,
Some(dottedNSRecord.copy(ttl = 300)), Some(dottedNSRecord.copy(ttl = 300)),
Nil Nil,
true,
dottedHostsConfigZonesAllowed.toSet,
false
) )
) shouldBe an[InvalidRequest] ) shouldBe an[InvalidRequest]
} }
@ -245,35 +307,35 @@ class RecordSetValidationsSpec
val test = srv.copy(name = "_sip._tcp.example.com.") val test = srv.copy(name = "_sip._tcp.example.com.")
val zone = okZone.copy(name = "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 { "return success for an SRV record following convention without FQDN" in {
val test = srv.copy(name = "_sip._tcp") val test = srv.copy(name = "_sip._tcp")
val zone = okZone.copy(name = "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 with a record name" in { "return success for an SRV record following convention with a record name" in {
val test = srv.copy(name = "_sip._tcp.foo.") val test = srv.copy(name = "_sip._tcp.foo.")
val zone = okZone.copy(name = "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 SRV that follows convention" in { "return success on a wildcard SRV that follows convention" in {
val test = srv.copy(name = "*._tcp.example.com.") val test = srv.copy(name = "*._tcp.example.com.")
val zone = okZone.copy(name = "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 { "return success on a wildcard in second position SRV that follows convention" in {
val test = srv.copy(name = "_sip._*.example.com.") val test = srv.copy(name = "_sip._*.example.com.")
val zone = okZone.copy(name = "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 { "Skip dotted checks on NAPTR" should {
@ -281,21 +343,21 @@ class RecordSetValidationsSpec
val test = naptr.copy(name = "sub.naptr.example.com.") val test = naptr.copy(name = "sub.naptr.example.com.")
val zone = okZone.copy(name = "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 { "return success for an NAPTR record without FQDN" in {
val test = naptr.copy(name = "sub.naptr") val test = naptr.copy(name = "sub.naptr")
val zone = okZone.copy(name = "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 NAPTR" in { "return success on a wildcard NAPTR" in {
val test = naptr.copy(name = "*.sub.naptr.example.com.") val test = naptr.copy(name = "*.sub.naptr.example.com.")
val zone = okZone.copy(name = "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)
} }
} }
@ -304,7 +366,7 @@ class RecordSetValidationsSpec
val test = ptrIp4.copy(name = "10.1.2.") val test = ptrIp4.copy(name = "10.1.2.")
val zone = zoneIp4.copy(name = "198.in-addr.arpa.") 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 { "Skip dotted checks on TXT" should {
@ -312,7 +374,7 @@ class RecordSetValidationsSpec
val test = txt.copy(name = "sub.txt.example.com.") val test = txt.copy(name = "sub.txt.example.com.")
val zone = okZone.copy(name = "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)
} }
} }
@ -329,7 +391,7 @@ class RecordSetValidationsSpec
List(SOAData(Fqdn("something"), "other", 1, 2, 3, 5, 6)) 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)
} }
} }
} }
@ -342,29 +404,29 @@ class RecordSetValidationsSpec
records = List(NSData(Fqdn("some.test.ns."))) 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 { "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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if an NS record is the same as the zone" in { "return an InvalidRequest if an NS record is the same as the zone" in {
val invalid = invalidNsApexRs.copy(name = okZone.name) 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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if the NS record being updated is '@'" in { "return an InvalidRequest if the NS record being updated is '@'" in {
val valid = invalidNsApexRs.copy(name = "this-is-not-origin-mate") 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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if an NS record data is not in the approved server list" in { "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 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] error shouldBe an[InvalidRequest]
} }
} }
@ -372,25 +434,35 @@ class RecordSetValidationsSpec
"DSValidations" should { "DSValidations" should {
val matchingNs = ns.copy(zoneId = ds.zoneId, name = ds.name, ttl = ds.ttl) 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 { "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 { "return an InvalidRequest if a DS record is '@'" in {
val apex = ds.copy(name = "@") 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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if a DS record is the same as the zone" in { "return an InvalidRequest if a DS record is the same as the zone" in {
val apex = ds.copy(name = okZone.name) 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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if there is no NS matching the record" in { "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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if the DS is dotted" in { "return an InvalidRequest if the DS is dotted" in {
val error = 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] error shouldBe an[InvalidRequest]
} }
} }
@ -398,54 +470,64 @@ class RecordSetValidationsSpec
"CnameValidations" should { "CnameValidations" should {
val invalidCnameApexRs: RecordSet = cname.copy(name = "@") val invalidCnameApexRs: RecordSet = cname.copy(name = "@")
"return a RecordSetAlreadyExistsError if a record with the same name exists and creating a cname" in { "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] error shouldBe a[RecordSetAlreadyExists]
} }
"return ok if name is not '@'" in { "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 { "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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if a cname record set name is same as zone" in { "return an InvalidRequest if a cname record set name is same as zone" in {
val invalid = invalidCnameApexRs.copy(name = okZone.name) 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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if a cname record set name is dotted" in { "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] error shouldBe an[InvalidRequest]
} }
"return ok if new recordset name does not contain dot" in { "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 right
) )
} }
"return ok if dotted host name doesn't change" in { "return ok if dotted host name doesn't change" in {
val newRecord = cname.copy(name = "dot.ted", ttl = 500) 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 right
) )
} }
"return an InvalidRequest if a cname record set name is updated to '@'" in { "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] error shouldBe an[InvalidRequest]
} }
"return an InvalidRequest if updated cname record set name is same as zone" in { "return an InvalidRequest if updated cname record set name is same as zone" in {
val error = 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] error shouldBe an[InvalidRequest]
} }
"return an RecordSetValidation error if recordset data contain more than one sequential '.'" in { "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] error shouldBe an[RecordSetValidation]
} }
"return ok if recordset data does not contain sequential '.'" in { "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 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 { "isNotHighValueDomain" should {

View File

@ -562,6 +562,45 @@ class ZoneServiceSpec
result.ignoreAccess shouldBe true 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 { "return Unknown group name if zone admin group cannot be found" in {
doReturn(IO.pure(ListZonesResults(List(abcZone, xyzZone)))) doReturn(IO.pure(ListZonesResults(List(abcZone, xyzZone))))
.when(mockZoneRepo) .when(mockZoneRepo)

View File

@ -153,6 +153,7 @@ class RecordSetChangeHandlerSpec
val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id))
val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch =>
ch.copy( ch.copy(
systemMessage= None,
status = SingleChangeStatus.Complete, status = SingleChangeStatus.Complete,
recordChangeId = Some(rsChange.id), recordChangeId = Some(rsChange.id),
recordSetId = Some(rsChange.recordSet.id) recordSetId = Some(rsChange.recordSet.id)
@ -198,6 +199,7 @@ class RecordSetChangeHandlerSpec
val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id))
val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch =>
ch.copy( ch.copy(
systemMessage= None,
status = SingleChangeStatus.Complete, status = SingleChangeStatus.Complete,
recordChangeId = Some(rsChange.id), recordChangeId = Some(rsChange.id),
recordSetId = Some(rsChange.recordSet.id) recordSetId = Some(rsChange.recordSet.id)
@ -602,6 +604,7 @@ class RecordSetChangeHandlerSpec
val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id)) val batchChangeUpdates = await(batchRepo.getBatchChange(batchChange.id))
val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch => val updatedSingleChanges = completeCreateAAAASingleChanges.map { ch =>
ch.copy( ch.copy(
systemMessage= None,
status = SingleChangeStatus.Complete, status = SingleChangeStatus.Complete,
recordChangeId = Some(rsChange.id), recordChangeId = Some(rsChange.id),
recordSetId = Some(rsChange.recordSet.id) recordSetId = Some(rsChange.recordSet.id)
@ -643,7 +646,7 @@ class RecordSetChangeHandlerSpec
changeSet.status shouldBe RecordSetChangeStatus.Failed changeSet.status shouldBe RecordSetChangeStatus.Failed
changeSet.recordSet.status shouldBe RecordSetStatus.Inactive changeSet.recordSet.status shouldBe RecordSetStatus.Inactive
changeSet.systemMessage shouldBe Some( 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 " + s"This record set is out of sync with the DNS backend; sync this zone before attempting to " +
"update this record set." "update this record set."
) )

View File

@ -84,6 +84,14 @@ trait EmptyZoneRepo extends ZoneRepository {
def getZoneByName(zoneName: String): IO[Option[Zone]] = IO.pure(None) 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( def listZones(
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None, zoneNameFilter: Option[String] = None,
@ -111,8 +119,12 @@ trait EmptyGroupRepo extends GroupRepository {
def getGroups(groupIds: Set[String]): IO[Set[Group]] = IO.pure(Set()) 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 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()) def getAllGroups(): IO[Set[Group]] = IO.pure(Set())
} }

View File

@ -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 { "PUT update user lock status" should {
"return a 200 response with the user locked" in { "return a 200 response with the user locked" in {
membershipRoute = superUserRoute membershipRoute = superUserRoute

View File

@ -252,6 +252,7 @@ class ZoneRoutingSpec
nameFilter: Option[String], nameFilter: Option[String],
startFrom: Option[String], startFrom: Option[String],
maxItems: Int, maxItems: Int,
searchByAdminGroup: Boolean = false,
ignoreAccess: Boolean = false ignoreAccess: Boolean = false
): Result[ListZonesResponse] = { ): Result[ListZonesResponse] = {
@ -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 { "return all zones when list all is true" in {
Get(s"/zones?maxItems=5&ignoreAccess=true") ~> zoneRoute ~> check { Get(s"/zones?maxItems=5&ignoreAccess=true") ~> zoneRoute ~> check {
val resp = responseAs[ListZonesResponse] val resp = responseAs[ListZonesResponse]

View File

@ -79,4 +79,6 @@ object Messages {
val NotAuthorizedErrorMsg = val NotAuthorizedErrorMsg =
"User \"%s\" is not authorized. Contact %s owner group: %s at %s to make DNS changes." "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."
} }

View File

@ -52,6 +52,18 @@ final case class InvalidDomainName(param: String) extends DomainValidationError
"joined by dots, and terminated with a dot." "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) final case class InvalidLength(param: String, minLengthInclusive: Int, maxLengthInclusive: Int)
extends DomainValidationError { extends DomainValidationError {
def message: String = 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." "If zone exists, then it must be connected to in VinylDNS."
} }
final case class RecordAlreadyExists(name: String) extends DomainValidationError { final case class RecordAlreadyExists(name: String, recordData: RecordData, isApproved:Boolean,
def message: String = fatal: Boolean = false) extends DomainValidationError(fatal) {
s"""Record "$name" Already Exists: cannot add an existing record; to update it, """ + def message: String = {
"issue a DeleteRecordSet then an Add." 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 { final case class RecordDoesNotExist(name: String) extends DomainValidationError {

View File

@ -30,7 +30,7 @@ object DomainValidationErrorType extends Enumeration {
type DomainValidationErrorType = Value type DomainValidationErrorType = Value
// NOTE: once defined, an error code type cannot be changed! // NOTE: once defined, an error code type cannot be changed!
val ChangeLimitExceeded, BatchChangeIsEmpty, GroupDoesNotExist, NotAMemberOfOwnerGroup, val ChangeLimitExceeded, BatchChangeIsEmpty, GroupDoesNotExist, NotAMemberOfOwnerGroup,
InvalidDomainName, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber, InvalidDomainName, InvalidCname, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber,
InvalidIpv4Address, InvalidIpv6Address, InvalidIPAddress, InvalidTTL, InvalidMxPreference, InvalidIpv4Address, InvalidIpv6Address, InvalidIPAddress, InvalidTTL, InvalidMxPreference,
InvalidBatchRecordType, ZoneDiscoveryError, RecordAlreadyExists, RecordDoesNotExist, InvalidBatchRecordType, ZoneDiscoveryError, RecordAlreadyExists, RecordDoesNotExist,
CnameIsNotUniqueError, UserIsNotAuthorized, UserIsNotAuthorizedError, RecordNameNotUniqueInBatch, CnameIsNotUniqueError, UserIsNotAuthorized, UserIsNotAuthorizedError, RecordNameNotUniqueInBatch,
@ -46,6 +46,7 @@ object DomainValidationErrorType extends Enumeration {
case _: GroupDoesNotExist => GroupDoesNotExist case _: GroupDoesNotExist => GroupDoesNotExist
case _: NotAMemberOfOwnerGroup => NotAMemberOfOwnerGroup case _: NotAMemberOfOwnerGroup => NotAMemberOfOwnerGroup
case _: InvalidDomainName => InvalidDomainName case _: InvalidDomainName => InvalidDomainName
case _: InvalidCname => InvalidCname
case _: InvalidLength => InvalidLength case _: InvalidLength => InvalidLength
case _: InvalidEmail => InvalidEmail case _: InvalidEmail => InvalidEmail
case _: InvalidRecordType => InvalidRecordType case _: InvalidRecordType => InvalidRecordType

View File

@ -17,7 +17,6 @@
package vinyldns.core.domain.batch package vinyldns.core.domain.batch
import java.util.UUID import java.util.UUID
import vinyldns.core.domain.SingleChangeError import vinyldns.core.domain.SingleChangeError
import vinyldns.core.domain.batch.SingleChangeStatus.SingleChangeStatus import vinyldns.core.domain.batch.SingleChangeStatus.SingleChangeStatus
import vinyldns.core.domain.record.RecordData import vinyldns.core.domain.record.RecordData
@ -47,6 +46,13 @@ sealed trait SingleChange {
delete.copy(status = SingleChangeStatus.Failed, systemMessage = Some(error)) 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 = def withProcessingError(message: Option[String], failedRecordChangeId: String): SingleChange =
this match { this match {
case add: SingleAddChange => 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 => case add: SingleAddChange =>
add.copy( add.copy(
status = SingleChangeStatus.Complete, status = SingleChangeStatus.Complete,
systemMessage = message,
recordChangeId = Some(completeRecordChangeId), recordChangeId = Some(completeRecordChangeId),
recordSetId = Some(recordSetId) recordSetId = Some(recordSetId)
) )
case delete: SingleDeleteRRSetChange => case delete: SingleDeleteRRSetChange =>
delete.copy( delete.copy(
status = SingleChangeStatus.Complete, status = SingleChangeStatus.Complete,
systemMessage = message,
recordChangeId = Some(completeRecordChangeId), recordChangeId = Some(completeRecordChangeId),
recordSetId = Some(recordSetId) recordSetId = Some(recordSetId)
) )
@ -140,12 +148,18 @@ object SingleChangeStatus extends Enumeration {
} }
case class RecordKey(zoneId: String, recordName: String, recordType: RecordType) case class RecordKey(zoneId: String, recordName: String, recordType: RecordType)
case class RecordKeyData(zoneId: String, recordName: String, recordType: RecordType, recordData: RecordData)
object RecordKey { object RecordKey {
def apply(zoneId: String, recordName: String, recordType: RecordType): RecordKey = def apply(zoneId: String, recordName: String, recordType: RecordType): RecordKey =
new RecordKey(zoneId, recordName.toLowerCase, recordType) 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 { object OwnerType extends Enumeration {
type OwnerType = Value type OwnerType = Value
val Record, Zone = Value val Record, Zone = Value

View File

@ -34,7 +34,9 @@ case class GroupChange(
userId: String, userId: String,
oldGroup: Option[Group] = None, oldGroup: Option[Group] = None,
id: String = UUID.randomUUID().toString, id: String = UUID.randomUUID().toString,
created: DateTime = DateTime.now created: DateTime = DateTime.now,
userName: Option[String] = None,
groupChangeMessage: Option[String] = None
) )
object GroupChange { object GroupChange {

View File

@ -24,7 +24,8 @@ import vinyldns.core.repository.Repository
trait GroupChangeRepository extends Repository { trait GroupChangeRepository extends Repository {
def save(db: DB, groupChange: GroupChange): IO[GroupChange] 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( def getGroupChanges(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[String],

View File

@ -31,8 +31,12 @@ trait GroupRepository extends Repository {
def getGroups(groupIds: Set[String]): IO[Set[Group]] def getGroups(groupIds: Set[String]): IO[Set[Group]]
def getGroupsByName(groupNames: Set[String]): IO[Set[Group]]
def getGroupByName(groupName: String): IO[Option[Group]] def getGroupByName(groupName: String): IO[Option[Group]]
def getGroupsByName(groupName: String): IO[Set[Group]]
def getAllGroups(): IO[Set[Group]] def getAllGroups(): IO[Set[Group]]
} }

View File

@ -17,7 +17,6 @@
package vinyldns.core.domain.record package vinyldns.core.domain.record
import java.util.UUID import java.util.UUID
import org.joda.time.DateTime import org.joda.time.DateTime
import vinyldns.core.domain.zone.{Zone, ZoneCommand, ZoneCommandResult} import vinyldns.core.domain.zone.{Zone, ZoneCommand, ZoneCommandResult}

View File

@ -35,6 +35,14 @@ trait ZoneRepository extends Repository {
def getZonesByFilters(zoneNames: Set[String]): IO[Set[Zone]] 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( def listZones(
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None, zoneNameFilter: Option[String] = None,

View File

@ -36,6 +36,7 @@ object TestMembershipData {
val dummyUser = User("dummyName", "dummyAccess", "dummySecret") val dummyUser = User("dummyName", "dummyAccess", "dummySecret")
val superUser = User("super", "superAccess", "superSecret", isSuper = true) val superUser = User("super", "superAccess", "superSecret", isSuper = true)
val xyzUser = User("xyz", "xyzAccess", "xyzSecret")
val supportUser = User("support", "supportAccess", "supportSecret", isSupport = true) val supportUser = User("support", "supportAccess", "supportSecret", isSupport = true)
val lockedUser = User("locked", "lockedAccess", "lockedSecret", lockStatus = LockStatus.Locked) val lockedUser = User("locked", "lockedAccess", "lockedSecret", lockStatus = LockStatus.Locked)
val sharedZoneUser = User("sharedZoneAdmin", "sharedAccess", "sharedSecret") val sharedZoneUser = User("sharedZoneAdmin", "sharedAccess", "sharedSecret")
@ -157,4 +158,13 @@ object TestMembershipData {
id = s"$i" 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()
)
} }

View File

@ -34,6 +34,8 @@ object TestZoneData {
adminGroupId = okGroup.id, adminGroupId = okGroup.id,
connection = testConnection 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 abcZone: Zone = Zone("abc.zone.recordsets.", "test@test.com", adminGroupId = abcGroup.id)
val xyzZone: Zone = Zone("xyz.", "abc@xyz.com", adminGroupId = xyzGroup.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) val zoneIp4: Zone = Zone("0.162.198.in-addr.arpa.", "test@test.com", adminGroupId = abcGroup.id)

View File

@ -536,7 +536,66 @@ v6-discovery-nibble-boundaries {
min = 5 min = 5
max = 20 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 ### 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 # true if you want to enable manual review for non-fatal errors
manual-batch-review-enabled = true manual-batch-review-enabled = true

View File

@ -115,7 +115,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec
val pendingBatchChange: BatchChange = randomBatchChange().copy(createdTimestamp = DateTime.now) val pendingBatchChange: BatchChange = randomBatchChange().copy(createdTimestamp = DateTime.now)
val completeBatchChange: BatchChange = randomBatchChangeWithList( val completeBatchChange: BatchChange = randomBatchChangeWithList(
randomBatchChange().changes.map(_.complete("recordChangeId", "recordSetId")) randomBatchChange().changes.map(_.complete(Some("Complete"),"recordChangeId", "recordSetId"))
).copy(createdTimestamp = DateTime.now.plusMillis(1000)) ).copy(createdTimestamp = DateTime.now.plusMillis(1000))
val failedBatchChange: BatchChange = val failedBatchChange: BatchChange =
@ -123,7 +123,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec
.copy(createdTimestamp = DateTime.now.plusMillis(100000)) .copy(createdTimestamp = DateTime.now.plusMillis(100000))
val partialFailureBatchChange: BatchChange = randomBatchChangeWithList( 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")) ++ randomBatchChange().changes.drop(2).map(_.withFailureMessage("failed"))
).copy(createdTimestamp = DateTime.now.plusMillis(1000000)) ).copy(createdTimestamp = DateTime.now.plusMillis(1000000))
@ -410,7 +410,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec
"update single changes" in { "update single changes" in {
val batchChange = randomBatchChange() val batchChange = randomBatchChange()
val completed = batchChange.changes.map(_.complete("aaa", "bbb")) val completed = batchChange.changes.map(_.complete(Some("Complete"),"aaa", "bbb"))
val f = val f =
for { for {
_ <- repo.save(batchChange) _ <- repo.save(batchChange)
@ -429,7 +429,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec
"update some changes in a batch" in { "update some changes in a batch" in {
val batchChange = randomBatchChange() 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 incomplete = batchChange.changes.drop(2)
val f = val f =
for { for {
@ -443,7 +443,7 @@ class MySqlBatchChangeRepositoryIntegrationSpec
"return the batch when updating single changes" in { "return the batch when updating single changes" in {
val batchChange = randomBatchChange() 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 = val f =
for { for {
_ <- repo.save(batchChange) _ <- repo.save(batchChange)

View File

@ -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 { "MySqlGroupRepository.getGroupByName" should {
"retrieve a group" in { "retrieve a group" in {
repo.getGroupByName(groups.head.name).unsafeRunSync() shouldBe Some(groups.head) 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 { "MySqlGroupRepository.getAllGroups" should {
"retrieve all groups" in { "retrieve all groups" in {
repo.getAllGroups().unsafeRunSync() should contain theSameElementsAs groups.toSet repo.getAllGroups().unsafeRunSync() should contain theSameElementsAs groups.toSet

View File

@ -29,14 +29,16 @@ import vinyldns.core.domain.zone._
import vinyldns.core.TestZoneData.okZone import vinyldns.core.TestZoneData.okZone
import vinyldns.core.TestMembershipData._ import vinyldns.core.TestMembershipData._
import vinyldns.core.domain.zone.ZoneRepository.DuplicateZoneError import vinyldns.core.domain.zone.ZoneRepository.DuplicateZoneError
import vinyldns.mysql.TestMySqlInstance import vinyldns.mysql.{TestMySqlInstance, TransactionProvider}
import vinyldns.mysql.TestMySqlInstance.groupRepository
class MySqlZoneRepositoryIntegrationSpec class MySqlZoneRepositoryIntegrationSpec
extends AnyWordSpec extends AnyWordSpec
with BeforeAndAfterAll with BeforeAndAfterAll
with BeforeAndAfterEach with BeforeAndAfterEach
with Matchers with Matchers
with Inspectors { with Inspectors
with TransactionProvider {
private var repo: ZoneRepository = _ private var repo: ZoneRepository = _
@ -221,6 +223,32 @@ class MySqlZoneRepositoryIntegrationSpec
(repo.listZones(dummyAuth).unsafeRunSync().zones should contain).only(testZones.head) (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 { "get all zones" in {
// store all of the zones // store all of the zones
val privateZone = okZone.copy( val privateZone = okZone.copy(
@ -259,6 +287,82 @@ class MySqlZoneRepositoryIntegrationSpec
.zones should contain theSameElementsAs testZones .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 { "get zones that are accessible by everyone" in {
//user and group id being set to None implies EVERYONE access //user and group id being set to None implies EVERYONE access
@ -468,6 +572,27 @@ class MySqlZoneRepositoryIntegrationSpec
(f.unsafeRunSync().zones should contain).theSameElementsInOrderAs(expectedZones) (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 { "support starts with wildcard" in {
val testZones = Seq( val testZones = Seq(

View File

@ -69,6 +69,13 @@ class MySqlGroupRepository extends GroupRepository with GroupProtobufConversions
| WHERE id | WHERE id
""".stripMargin """.stripMargin
private val BASE_GET_GROUPS_BY_NAMES =
"""
|SELECT data
| FROM groups
| WHERE name
""".stripMargin
def save(db: DB, group: Group): IO[Group] = def save(db: DB, group: Group): IO[Group] =
monitor("repo.Group.save") { monitor("repo.Group.save") {
IO { 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]] = def getGroupByName(groupName: String): IO[Option[Group]] =
monitor("repo.Group.getGroupByName") { monitor("repo.Group.getGroupByName") {
IO { 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]] = def getAllGroups(): IO[Set[Group]] =
monitor("repo.Group.getAllGroups") { monitor("repo.Group.getAllGroups") {
IO { IO {

View File

@ -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. * This is somewhat complicated due to how we need to build the SQL.
* *

View File

@ -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/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/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/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/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/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/ // 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'}, {expand: true, flatten: true, cwd: 'node_modules/gentelella', dest: 'mapped/ui', src: '**/jquery.{smartWizard,dataTables.min,mousewheel.min}.js'},

View File

@ -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 => def getUser(id: String): Action[AnyContent] = userAction.async { implicit request =>
val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"users/$id") val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"users/$id")
executeRequest(vinyldnsRequest, request.user).map(response => { 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 => def syncZone(id: String): Action[AnyContent] = userAction.async { implicit request =>
// $COVERAGE-OFF$ // $COVERAGE-OFF$
val vinyldnsRequest = val vinyldnsRequest =

View File

@ -158,10 +158,16 @@
<span ng-if="change.status == 'Cancelled'" class="label label-default">{{batch.status}}</span> <span ng-if="change.status == 'Cancelled'" class="label label-default">{{batch.status}}</span>
</td> </td>
<td class="wrap-long-text"> <td class="wrap-long-text">
<div ng-if="batch.approvalStatus != 'AutoApproved'
&& change.status != 'Rejected'
&& change.status != 'Cancelled'">
<p ng-repeat="error in change.validationErrors"> <p ng-repeat="error in change.validationErrors">
{{error.message ? error.message : error}} {{error.message ? error.message : error}} </p>{{change.systemMessage}}</div>
</p> <div ng-if="batch.approvalStatus =='ManuallyRejected' || batch.approvalStatus =='Cancelled'">
{{change.systemMessage}} </div>
<div ng-if="batch.approvalStatus == 'AutoApproved'
&& change.status =='Complete'">
No further action is required.</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -2,10 +2,10 @@
@content = { @content = {
<!-- PAGE CONTENT --> <!-- PAGE CONTENT -->
<div class="right_col" role="main" > <div class="right_col" role="main" >
<div> <div>
<!-- START BREADCRUMB --> <!-- START BREADCRUMB -->
<ul class="breadcrumb"> <ul class="breadcrumb">
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
@ -29,6 +29,15 @@
</div> </div>
</div> </div>
<!-- START VERTICAL TABS -->
<div class="panel panel-default panel-tabs">
<ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#tab1" data-toggle="tab">Manage Groups</a></li>
<li><a href="#tab2" data-toggle="tab">Change History</a></li>
</ul>
<div class="panel-body tab-content">
<div class="tab-pane active" id="tab1">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<p ng-if="membership.group.description"><strong>Description:</strong> {{membership.group.description}}</p> <p ng-if="membership.group.description"><strong>Description:</strong> {{membership.group.description}}</p>
@ -100,12 +109,174 @@
</div> </div>
</div> </div>
</div>
<div class="tab-pane" id="tab2">
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">All Group Changes {{ getChangePageTitle() }}</h3>
</div>
<div class="panel-body">
<div class="btn-group">
<button class="btn btn-default" ng-click="refreshGroupChanges()"><span class="fa fa-refresh"></span> Refresh</button>
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeNextPageEnabled()" ng-click="changeNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table id="changeDataTable" class="table table-hover table-striped">
<thead>
<tr>
<th>Time</th>
<th>Group Change ID</th>
<th>Change Type</th>
<th>Change Message</th>
<th>Change Info</th>
<th>User</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="change in groupChanges track by $index">
<td>{{change.created}}</td>
<td>{{change.id}}</td>
<td>{{change.changeType}}</td>
<td class="col-md-3 wrap-long-text" ng-bind-html="changeMessage(change.groupChangeMessage)"></td>
<td class="col-md-3 wrap-long-text">
<a ng-if="change.changeType =='Create'" ng-click="viewGroupInfo(change.newGroup)" class="force-cursor">View created group</a>
<div><a ng-if="change.changeType =='Update'" ng-click="viewGroupInfo(change.newGroup)" class="force-cursor">View new group</a></div>
<div><a ng-if="change.changeType =='Update'" ng-click="viewGroupInfo(change.oldGroup)" class="force-cursor">View old group</a></div>
</td>
<td>{{change.userName}}</td>
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeNextPageEnabled()" ng-click="changeNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
<div class="panel-footer"></div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
</div>
</div>
<!-- END VERTICAL TABS -->
</div> </div>
<!-- PAGE CONTENT WRAPPER --> <!-- PAGE CONTENT WRAPPER -->
</div>
</div> </div>
<!-- END PAGE CONTENT -->
<form name="viewGroupForm" role="form" class="form-horizontal" novalidate>
<modal modal-id="group_modal" modal-title="{{ groupModal.title }}">
<modal-body>
<modal-element label="Group ID">
<input id="create-group-id-text" type="text" name="groupID" class="form-control"
ng-model="currentGroup.id"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Group Name">
<input id="create-group-name-text" type="text" name="groupName" class="form-control"
ng-model="currentGroup.name"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Group Description">
<input id="create-group-description-text" type="text" name="groupDescription" class="form-control"
ng-model="currentGroup.description"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Email">
<input id="create-group-email-text" type="text"
name="groupEmail"
class="form-control"
ng-model="currentGroup.email"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Group Created">
<input id="create-group-created-text" type="text"
name="groupCreated"
class="form-control"
ng-model="currentGroup.created"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Group Status">
<input id="create-group-status-text" type="text"
name="groupStatus"
class="form-control"
ng-model="currentGroup.status"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly"/>
</modal-element>
<modal-element label="Group Members IDs (one per line)">
<textarea id="create-group-members-ids-text"
name="groupMembers"
ng-model="currentGroup.memberIds"
rows="5"
class="form-control"
ng-list="&#10;"
ng-trim="false"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly">
</textarea>
</modal-element>
<modal-element label="Group Admins IDs (one per line)">
<textarea id="create-group-admins-ids-text"
name="groupAdmins"
ng-model="currentGroup.adminIds"
rows="5"
class="form-control"
ng-list="&#10;"
ng-trim="false"
ng-class="groupModal.details.class"
ng-readonly="groupModal.details.readOnly">
</textarea>
</modal-element>
</modal-body>
<modal-footer>
<span ng-if="groupModal.action == groupModalState.VIEW_DETAILS">
<button type="button" class="btn btn-default" data-dismiss="modal" ng-click="closeGroupModal()">Close</button>
</span>
</modal-footer>
</modal>
</form>
</div>
<!-- END PAGE CONTENT -->
} }
@plugins = { @plugins = {

View File

@ -26,12 +26,12 @@
<!-- START VERTICAL TABS --> <!-- START VERTICAL TABS -->
<div class="panel panel-default panel-tabs"> <div class="panel panel-default panel-tabs">
<ul class="nav nav-tabs bar_tabs"> <ul class="nav nav-tabs bar_tabs">
<li class="active"><a data-toggle="tab" ng-click="myGroups()">My Groups</a></li> <li class="active"><a href="#myGroups" data-toggle="tab">My Groups</a></li>
<li><a data-toggle="tab" ng-click="allGroups()">All Groups</a></li> <li><a id="tab2-button" href="#allGroups" data-toggle="tab">All Groups</a></li>
</ul> </ul>
<div class="panel-body tab-content"> <div class="panel-body tab-content">
<div class="tab-pane active" id="groups"> <div class="tab-pane active" id="myGroups">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@ -66,7 +66,22 @@
<div id="group-list" class="panel-body"> <div id="group-list" class="panel-body">
<p ng-if="!groupsLoaded">Loading groups...</p> <p ng-if="!groupsLoaded">Loading groups...</p>
<p ng-if="haveNoGroups(groups.items.length)">You don't have any groups yet.</p> <p ng-if="haveNoGroups(groups.items.length)">You don't have any groups yet.</p>
<p ng-if="searchCriteria(groups.items.length)">No groups match the search criteria.</p> <p ng-if="$scope.groupsLoaded && searchCriteria(groups.items.length)">No groups match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getGroupsPageNumber("myGroups") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myGroups')" ng-click="prevPageMyGroups()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myGroups')" ng-click="nextPageMyGroups()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table datatable_simple" ng-if="groups.items.length"> <table class="table datatable_simple" ng-if="groups.items.length">
<thead> <thead>
<tr> <tr>
@ -96,6 +111,118 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getGroupsPageNumber("myGroups") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myGroups')" ng-click="prevPageMyGroups()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myGroups')" ng-click="nextPageMyGroups()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
</div>
</div>
<div class="tab-pane" id="allGroups">
<div class="row">
<div class="col-md-12">
<!-- SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<div class="btn-group">
<button id="open-group-modal-button" class="btn btn-default" ng-click="openModal($event);">
<span class="fa fa-plus"></span> New Group
</button>
<button id="refresh-group-button" class="btn btn-default" ng-click="refresh();">
<span class="fa fa-refresh"></span> Refresh
</button>
</div>
<!-- SEARCH BOX -->
<div class="pull-right">
<form class="input-group" ng-submit="refresh()">
<div class="input-group">
<span class="input-group-btn">
<button id="group-search-button" type="submit" class="btn btn-primary btn-left-round">
<span class="fa fa-search"></span>
</button>
</span>
<input id="group-search-text" ng-model="query" type="text" class="form-control" placeholder="Group Name"/>
</div>
</form>
</div>
<!-- END SEARCH BOX -->
</div>
<div id="group-list" class="panel-body">
<p ng-if="!allGroupsLoaded">Loading groups...</p>
<p ng-if="$scope.allGroupsLoaded && searchCriteria(allGroups.items.length)">No groups match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getGroupsPageNumber("allGroups") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allGroups')" ng-click="prevPageAllGroups()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allGroups')" ng-click="nextPageAllGroups()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table datatable_simple" ng-if="allGroup.items.length">
<thead>
<tr>
<th>Group Name</th>
<th>Email</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="group in allGroup.items | orderBy:'+name'">
<td class="wrap-long-text">
<a ng-href="/groups/{{group.id}}">{{group.name}}</a>
</td>
<td class="wrap-long-text">{{group.email}}</td>
<td class="wrap-long-text">{{group.description}}</td>
<td>
<div class="table-form-group">
<a class="btn btn-info btn-rounded" ng-href="/groups/{{group.id}}">
View</a>
<a ng-if="groupAdmin(group)" class="btn btn-warning btn-rounded" ng-click="editGroup(group);">
Edit</a>
<button ng-if="groupAdmin(group)" id="delete-group-{{group.name}}" class="btn btn-danger btn-rounded" ng-click="confirmDeleteGroup(group);">
Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getGroupsPageNumber("allGroups") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allGroups')" ng-click="prevPageAllGroups()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allGroups')" ng-click="nextPageAllGroups()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div> </div>
</div> </div>
<!-- END SIMPLE DATATABLE --> <!-- END SIMPLE DATATABLE -->

View File

@ -1,6 +1,6 @@
@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any]) @(rootAccountName: String)(implicit request: play.api.mvc.Request[Any])
<div class="top_nav"> <div class="top_nav" id="fixed-top-nav">
<div class="nav_menu"> <div class="nav_menu">
<nav> <nav>
<div class="nav toggle"> <div class="nav toggle">

View File

@ -16,10 +16,12 @@
<!-- CSS INCLUDE --> <!-- CSS INCLUDE -->
<link rel="stylesheet" type="text/css" id="theme" href="/public/css/bootstrap.min.css"/> <link rel="stylesheet" type="text/css" id="theme" href="/public/css/bootstrap.min.css"/>
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
<link rel="stylesheet" type="text/css" href="/public/css/font-awesome.min.css"> <link rel="stylesheet" type="text/css" href="/public/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="/public/css/ui.css" /> <link rel="stylesheet" type="text/css" href="/public/css/ui.css" />
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/theme-overrides.css"/> <link rel="stylesheet" type="text/css" id="custom" href="/public/css/theme-overrides.css"/>
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/vinyldns.css"/> <link rel="stylesheet" type="text/css" id="custom" href="/public/css/vinyldns.css"/>
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
<!-- EOF CSS INCLUDE --> <!-- EOF CSS INCLUDE -->
</head> </head>
@ -35,7 +37,7 @@
<div class="main_container" ng-cloak ng-controller="@controller"> <div class="main_container" ng-cloak ng-controller="@controller">
<!-- START PAGE SIDEBAR --> <!-- START PAGE SIDEBAR -->
<div class="col-md-3 left_col"> <div class="col-md-3 left_col" id="fixed-side-menu">
<div class="left_col scroll-view"> <div class="left_col scroll-view">
<div class="navbar nav_title vinyldns-nav-title"> <div class="navbar nav_title vinyldns-nav-title">
<a href="/index"><span><img src="/assets/images/vinyldns-portal.png" class="vinyldns-logo"/></span></a> <a href="/index"><span><img src="/assets/images/vinyldns-portal.png" class="vinyldns-logo"/></span></a>
@ -149,6 +151,7 @@
<script src="/public/js/moment.min.js"></script> <script src="/public/js/moment.min.js"></script>
<script src="/public/js/jquery.min.js"></script> <script src="/public/js/jquery.min.js"></script>
<script src="/public/js/jquery-ui.js"></script>
<script src="/public/js/bootstrap.min.js"></script> <script src="/public/js/bootstrap.min.js"></script>
<script src="/public/js/angular.min.js"></script> <script src="/public/js/angular.min.js"></script>
<script src="/public/js/ui.js"></script> <script src="/public/js/ui.js"></script>

View File

@ -41,7 +41,8 @@
<ul class="nav nav-tabs bar_tabs"> <ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#tab1" data-toggle="tab">Manage Records</a></li> <li class="active"><a href="#tab1" data-toggle="tab">Manage Records</a></li>
<li><a id="tab2-button" href="#tab2" data-toggle="tab">Manage Zone</a></li> <li><a id="tab2-button" href="#tab2" data-toggle="tab">Manage Zone</a></li>
<li><a href="#tab3" data-toggle="tab">Change History</a></li> <li><a href="#tab3" data-toggle="tab">Record Change History</a></li>
<li><a href="#tab4" data-toggle="tab">Zone Change History</a></li>
</ul> </ul>
<div class="panel-body tab-content"> <div class="panel-body tab-content">
@ -54,6 +55,9 @@
<div class="tab-pane" id="tab3"> <div class="tab-pane" id="tab3">
@changeHistory(request) @changeHistory(request)
</div> </div>
<div class="tab-pane" id="tab4" ng-controller="ManageZonesController">
@zoneChangeHistory(request)
</div>
</div> </div>
</div> </div>
<!-- END VERTICAL TABS --> <!-- END VERTICAL TABS -->

View File

@ -122,7 +122,7 @@
<tr ng-repeat="(recordName, record) in records track by $index"> <tr ng-repeat="(recordName, record) in records track by $index">
<td> <td>
<div ng-if="record.isDotted && record.type != 'TXT' && record.type != 'SRV' && record.type != 'NAPTR'" class="text-danger wrap-long-text" data-toggle="tooltip" data-placement="top" <div ng-if="record.isDotted && record.type != 'TXT' && record.type != 'SRV' && record.type != 'NAPTR'" class="text-danger wrap-long-text" data-toggle="tooltip" data-placement="top"
title="Dotted hosts are invalid! Please delete or update without a '.'"> title="This is a dotted host!">
{{record.name}} <span class="fa fa-warning" /> {{record.name}} <span class="fa fa-warning" />
</div> </div>
<div class="wrap-long-text" ng-if="!record.isDotted || (record.type != 'TXT' || record.type != 'SRV' || record.type != 'NAPTR')"> <div class="wrap-long-text" ng-if="!record.isDotted || (record.type != 'TXT' || record.type != 'SRV' || record.type != 'NAPTR')">

View File

@ -0,0 +1,121 @@
@(implicit request: play.api.mvc.Request[Any])
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Zone Change History</h3>
</div>
<div class="panel-body">
<div class="btn-group">
<button class="btn btn-default" ng-click="refreshZoneChange()"><span class="fa fa-refresh"></span> Refresh</button>
</div>
<table id="zoneChangeDataTable" class="table table-hover table-striped">
<thead>
<tr>
<th>User name</th>
<th>Email</th>
<th>Access</th>
<th>Created</th>
<th>Updated</th>
<th>Change type</th>
<th>Admin group</th>
<th>ACL</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="zoneChange in zoneChanges track by $index">
<td>{{ zoneChange.userName }}</td>
<td>{{ zoneChange.zone.email }}</td>
<td>{{ zoneChange.zone.shared ? "Shared" : "Private" }}</td>
<td>{{ zoneChange.zone.created }}</td>
<td>{{ zoneChange.zone.updated }}</td>
<td>{{ zoneChange.changeType }}</td>
<td><a ng-bind="zoneChange.zone.adminGroupName" href="/groups/{{zoneChange.zone.adminGroupId}}"></a>
</td>
<td>
<button class="btn btn-info btn-sm"
ng-if="zoneChange.zone.acl.rules.length != 0"
ng-click="refreshAclRule($index)">ACL Rules
</button>
</td>
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZoneHistoryPageNumber() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPageZoneHistory()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled()" ng-click="nextPageZoneHistory()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
<div class="panel-footer"></div>
<!-- END SIMPLE DATATABLE -->
<!-- THE ACL RULE MODAL FORM STARTS -->
<form name="aclModalViewForm" role="form" class="form-horizontal" novalidate>
<modal modal-id="aclModalView" modal-title="{{ aclRulesModal.title }}">
<modal-body>
<table id="aclRuleTable" class="table table-hover table-striped">
<thead>
<tr>
<th>User/Group</th>
<th>Access Level</th>
<th>Record Types</th>
<th>Record Mask</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="rule in allAclRules track by $index">
<td class="wrap-long-text">
<a ng-if="rule.groupId != undefined" href="/groups/{{rule.groupId}}">
{{rule.groupName}}
</a>
<span ng-if="rule.groupId == undefined">
{{rule.userName}}
</span>
</td>
<td>
{{rule.accessLevel}}
</td>
<td>
<span ng-if="rule.recordTypes.length == 0">All Types</span>
<ul class="table-cell-list">
<li ng-repeat="item in rule.recordTypes">
{{item}}
</li>
</ul>
</td>
<td class="wrap-long-text">
{{rule.recordMask}}
</td>
<td class="wrap-long-text">
{{rule.description}}
</td>
</tr>
</tbody>
</table>
</modal-body>
<modal-footer>
<span>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-click="closeAclModalView()">Close</button>
</span>
</modal-footer>
</modal>
</form>
<!-- THE ACL RULE MODAL FORM ENDS -->

View File

@ -59,7 +59,12 @@
<span class="fa fa-search"></span> <span class="fa fa-search"></span>
</button> </button>
</span> </span>
<input id="zone-search-text" ng-model="query" type="text" class="form-control" placeholder="Zone Name"/> <input id="zone-search-text" ng-model="query" type="text" class="form-control" placeholder="{{!searchByAdminGroup ? 'Zone Name' : 'Admin Group Name'}}"/>
</div>
<div class="checkbox pull-right">
<label>
<input type="checkbox" ng-model="searchByAdminGroup" ng-change="refreshZones()"> Search by admin group
</label>
</div> </div>
</form> </form>
</div> </div>
@ -72,8 +77,8 @@
<p ng-if="hasZones && zonesLoaded && !zones.length">No zones match the search criteria.</p> <p ng-if="hasZones && zonesLoaded && !zones.length">No zones match the search criteria.</p>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myZones") }}</span> <span class="vinyldns_page_number">{{ getZonesPageNumber("myZones") }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled('myZones')" ng-click="prevPageMyZones()">Previous</a> <a ng-if="prevPageEnabled('myZones')" ng-click="prevPageMyZones()">Previous</a>
@ -123,8 +128,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myZones") }}</span> <span class="vinyldns_page_number">{{ getZonesPageNumber("myZones") }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled('myZones')" ng-click="prevPageMyZones()">Previous</a> <a ng-if="prevPageEnabled('myZones')" ng-click="prevPageMyZones()">Previous</a>
@ -144,6 +149,9 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="loader" tabindex="-1" role="dialog" >
<div class="spinner" ></div>
</div>
<div class="tab-pane" id="allZones"> <div class="tab-pane" id="allZones">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@ -165,7 +173,13 @@
<span class="fa fa-search"></span> <span class="fa fa-search"></span>
</button> </button>
</span> </span>
<input id="all-zones-search-text" ng-model="query" type="text" class="form-control" placeholder="Zone Name"/> <input id="all-zones-search-text" ng-model="query" type="text" class="form-control" placeholder="{{!searchByAdminGroup ? 'Zone Name' : 'Admin Group Name'}}"/>
</div>
<div class="checkbox pull-right">
<label>
<input type="checkbox" ng-model="searchByAdminGroup" ng-change="refreshZones()"> Search by admin group
</label>
</div> </div>
</form> </form>
</div> </div>
@ -177,8 +191,8 @@
<p ng-if="allZonesLoaded && !allZones.length">No zones match the search criteria.</p> <p ng-if="allZonesLoaded && !allZones.length">No zones match the search criteria.</p>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allZones") }}</span> <span class="vinyldns_page_number">{{ getZonesPageNumber("allZones") }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled('allZones')" ng-click="prevPageAllZones()">Previous</a> <a ng-if="prevPageEnabled('allZones')" ng-click="prevPageAllZones()">Previous</a>
@ -231,8 +245,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allZones") }}</span> <span class="vinyldns_page_number">{{ getZonesPageNumber("allZones") }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled('allZones')" ng-click="prevPageAllZones()">Previous</a> <a ng-if="prevPageEnabled('allZones')" ng-click="prevPageAllZones()">Previous</a>

View File

@ -30,6 +30,7 @@ GET /api/zones @controllers.VinylDNS.getZones
GET /api/zones/backendids @controllers.VinylDNS.getBackendIds GET /api/zones/backendids @controllers.VinylDNS.getBackendIds
GET /api/zones/:id @controllers.VinylDNS.getZone(id: String) GET /api/zones/:id @controllers.VinylDNS.getZone(id: String)
GET /api/zones/name/:name @controllers.VinylDNS.getZoneByName(name: String) GET /api/zones/name/:name @controllers.VinylDNS.getZoneByName(name: String)
GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: String)
POST /api/zones @controllers.VinylDNS.addZone POST /api/zones @controllers.VinylDNS.addZone
PUT /api/zones/:id @controllers.VinylDNS.updateZone(id: String) PUT /api/zones/:id @controllers.VinylDNS.updateZone(id: String)
DELETE /api/zones/:id @controllers.VinylDNS.deleteZone(id: String) DELETE /api/zones/:id @controllers.VinylDNS.deleteZone(id: String)
@ -44,6 +45,8 @@ GET /api/zones/:id/recordsetchanges @controllers.VinylDNS.listRecor
GET /api/groups @controllers.VinylDNS.getGroups GET /api/groups @controllers.VinylDNS.getGroups
GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String) GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String)
GET /api/groups/:gid/groupchanges @controllers.VinylDNS.listGroupChanges(gid: String)
GET /api/groups/change/:gcid @controllers.VinylDNS.getGroupChange(gcid: String)
POST /api/groups @controllers.VinylDNS.newGroup POST /api/groups @controllers.VinylDNS.newGroup
PUT /api/groups/:gid @controllers.VinylDNS.updateGroup(gid: String) PUT /api/groups/:gid @controllers.VinylDNS.updateGroup(gid: String)
DELETE /api/groups/:gid @controllers.VinylDNS.deleteGroup(gid: String) DELETE /api/groups/:gid @controllers.VinylDNS.deleteGroup(gid: String)

View File

@ -15,6 +15,8 @@ module.exports = function(config) {
// list of files / patterns to load in the browser // list of files / patterns to load in the browser
files: [ files: [
'js/jquery.min.js', 'js/jquery.min.js',
'js/jquery-ui-dist.js',
'js/jquery-ui.js',
'js/bootstrap.min.js', 'js/bootstrap.min.js',
'js/angular.min.js', 'js/angular.min.js',
'js/moment.min.js', 'js/moment.min.js',

View File

@ -24,6 +24,7 @@
"jasmine-core": "^2.99.1", "jasmine-core": "^2.99.1",
"jasmine-jquery": "2.1.1", "jasmine-jquery": "2.1.1",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"jquery-ui-dist": "^1.13.1",
"karma": "^6.3.17", "karma": "^6.3.17",
"karma-chrome-launcher": "^2.2.0", "karma-chrome-launcher": "^2.2.0",
"karma-jasmine": "^1.0.2", "karma-jasmine": "^1.0.2",

View File

@ -104,7 +104,7 @@ a.action-link {
top: 3px; top: 3px;
left: 50%; left: 50%;
width: 40%; width: 40%;
z-index: 100; z-index: 2000;
} }
.dns-connection-form { .dns-connection-form {
@ -434,12 +434,12 @@ input[type="file"] {
vertical-align: top; vertical-align: top;
} }
.vinyldns_zones_paginate { .vinyldns_paginate {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.vinyldns_zones_page_number { .vinyldns_page_number {
margin-right: 10px; margin-right: 10px;
} }
@ -491,3 +491,34 @@ input[type="file"] {
.modal-backdrop.show { .modal-backdrop.show {
opacity: 0.75; opacity: 0.75;
} }
.ui-menu .ui-menu-item div {
background: white;
width: 100%;
font-size: 14px;
color: black;
border: none;
border-left: 1px;
}
.ui-menu .ui-menu-item div:hover {
background: #f5f5f5;
}
.ui-autocomplete {
max-height: 200px;
width:150px;
overflow-y: auto;
overflow-x: hidden;
}
#fixed-side-menu {
position: fixed;
top: 0;
}
#fixed-top-nav {
position: sticky;
top: 0;
z-index: 1000;
}

View File

@ -14,19 +14,25 @@
* limitations under the License. * limitations under the License.
*/ */
angular.module('controller.groups', []).controller('GroupsController', function ($scope, $log, $location, groupsService, profileService, utilityService) { angular.module('controller.groups', []).controller('GroupsController', function ($scope, $log, $location, groupsService, profileService, utilityService, pagingService, $timeout) {
//registering bootstrap modal close event to refresh data after create group action //registering bootstrap modal close event to refresh data after create group action
angular.element('#modal_new_group').one('hide.bs.modal', function () { angular.element('#modal_new_group').one('hide.bs.modal', function () {
$scope.closeModal(); $scope.closeModal();
}); });
$scope.groups = {items: []}; $scope.groups = {items: []};
$scope.allGroup = {items: []};
$scope.groupsLoaded = false; $scope.groupsLoaded = false;
$scope.allGroupsLoaded = false;
$scope.alerts = []; $scope.alerts = [];
$scope.ignoreAccess = false; $scope.ignoreAccess = false;
$scope.hasGroups = false; // Re-assigned each time groups are fetched without a query $scope.hasGroups = false;
$scope.query = ""; $scope.query = "";
// Paging status for group sets
var groupsPaging = pagingService.getNewPagingParams(100);
var allGroupsPaging = pagingService.getNewPagingParams(100);
function handleError(error, type) { function handleError(error, type) {
var alert = utilityService.failure(error, type); var alert = utilityService.failure(error, type);
$scope.alerts.push(alert); $scope.alerts.push(alert);
@ -64,6 +70,44 @@ angular.module('controller.groups', []).controller('GroupsController', function
return true; return true;
}; };
// Autocomplete for group search
$("#group-search-text").autocomplete({
source: function( request, response ) {
$.ajax({
url: "/api/groups?maxItems=100&abridged=true",
dataType: "json",
data: {groupNameFilter: request.term, ignoreAccess: $scope.ignoreAccess},
success: function(data) {
const search = JSON.parse(JSON.stringify(data));
response($.map(search.groups, function(group) {
return {value: group.name, label: group.name}
}))
}
});
},
minLength: 1,
select: function (event, ui) {
$scope.query = ui.item.value;
$("#group-search-text").val(ui.item.value);
return false;
},
open: function() {
$(this).removeClass("ui-corner-all").addClass("ui-corner-top");
},
close: function() {
$(this).removeClass("ui-corner-top").addClass("ui-corner-all");
}
});
// Autocomplete text-highlight
$.ui.autocomplete.prototype._renderItem = function(ul, item) {
let txt = String(item.label).replace(new RegExp(this.term, "gi"),"<b>$&</b>");
return $("<li></li>")
.data("ui-autocomplete-item", item.value)
.append("<div>" + txt + "</div>")
.appendTo(ul);
};
$scope.createGroup = function (name, email, description) { $scope.createGroup = function (name, email, description) {
//prevent user executing service call multiple times //prevent user executing service call multiple times
//if true prevent, if false allow for execution of rest of code //if true prevent, if false allow for execution of rest of code
@ -104,30 +148,33 @@ angular.module('controller.groups', []).controller('GroupsController', function
}); });
}; };
$scope.allGroups = function () {
$scope.ignoreAccess = true;
$scope.refresh();
}
$scope.myGroups = function () {
$scope.ignoreAccess = false;
$scope.refresh();
}
$scope.refresh = function () { $scope.refresh = function () {
function success(result) { groupsPaging = pagingService.resetPaging(groupsPaging);
$log.log('getGroups:refresh-success', result); allGroupsPaging = pagingService.resetPaging(allGroupsPaging);
groupsService
.getGroupsAbridged(groupsPaging.maxItems, undefined, false, $scope.query)
.then(function (result) {
$log.debug('getGroups:refresh-success', result);
//update groups //update groups
$scope.groups.items = result.groups; groupsPaging.next = result.data.nextId;
$scope.groupsLoaded = true; updateGroupDisplay(result.data.groups);
if (!$scope.query.length) { if (!$scope.query.length) {
$scope.hasGroups = $scope.groups.items.length > 0; $scope.hasGroups = $scope.groups.items.length > 0;
} }
return result; })
} .catch(function (error) {
handleError(error, 'getGroups::refresh-failure');
});
getGroupsAbridged($scope.ignoreAccess) groupsService
.then(success) .getGroupsAbridged(allGroupsPaging.maxItems, undefined, true, $scope.query)
.then(function (result) {
$log.debug('getGroups:refresh-success', result);
//update groups
allGroupsPaging.next = result.data.nextId;
updateAllGroupDisplay(result.data.groups);
})
.catch(function (error) { .catch(function (error) {
handleError(error, 'getGroups::refresh-failure'); handleError(error, 'getGroups::refresh-failure');
}); });
@ -161,20 +208,6 @@ angular.module('controller.groups', []).controller('GroupsController', function
}); });
} }
function getGroupsAbridged() {
function success(response) {
$log.log('groupsService::getGroups-success');
return response.data;
}
return groupsService
.getGroupsAbridged($scope.ignoreAccess, $scope.query)
.then(success)
.catch(function (error) {
handleError(error, 'groupsService::getGroups-failure');
});
}
// Return true if there are no groups created by the user // Return true if there are no groups created by the user
$scope.haveNoGroups = function (groupLength) { $scope.haveNoGroups = function (groupLength) {
if (!$scope.hasGroups && !groupLength && $scope.groupsLoaded && $scope.query.length == "") { if (!$scope.hasGroups && !groupLength && $scope.groupsLoaded && $scope.query.length == "") {
@ -186,7 +219,7 @@ angular.module('controller.groups', []).controller('GroupsController', function
// Return true if no groups are found related to the search query // Return true if no groups are found related to the search query
$scope.searchCriteria = function (groupLength) { $scope.searchCriteria = function (groupLength) {
if ($scope.groupsLoaded && !groupLength && $scope.query.length != "") { if (!groupLength && $scope.query.length != "") {
return true return true
} else { } else {
return false return false
@ -298,4 +331,115 @@ angular.module('controller.groups', []).controller('GroupsController', function
.then(profileSuccess, profileFailure) .then(profileSuccess, profileFailure)
.catch(profileFailure); .catch(profileFailure);
function updateGroupDisplay (groups) {
$scope.groups.items = groups;
$scope.groupsLoaded = true;
$log.debug("Displaying my groups: ", $scope.groups.items);
if($scope.groups.items.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
function updateAllGroupDisplay (groups) {
$scope.allGroup.items = groups;
$scope.allGroupsLoaded = true;
$log.debug("Displaying all groups: ", $scope.allGroup.items);
if($scope.allGroup.items.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
/*
* Group set paging
*/
$scope.getGroupsPageNumber = function(tab) {
switch(tab) {
case 'myGroups':
return pagingService.getPanelTitle(groupsPaging);
case 'allGroups':
return pagingService.getPanelTitle(allGroupsPaging);
}
};
$scope.prevPageEnabled = function(tab) {
switch(tab) {
case 'myGroups':
return pagingService.prevPageEnabled(groupsPaging);
case 'allGroups':
return pagingService.prevPageEnabled(allGroupsPaging);
}
};
$scope.nextPageEnabled = function(tab) {
switch(tab) {
case 'myGroups':
return pagingService.nextPageEnabled(groupsPaging);
case 'allGroups':
return pagingService.nextPageEnabled(allGroupsPaging);
}
};
$scope.prevPageMyGroups = function() {
var startFrom = pagingService.getPrevStartFrom(groupsPaging);
return groupsService
.getGroupsAbridged(groupsPaging.maxItems, startFrom, false, $scope.query)
.then(function(response) {
groupsPaging = pagingService.prevPageUpdate(response.data.nextId, groupsPaging);
updateGroupDisplay(response.data.groups);
})
.catch(function (error) {
handleError(error,'groupsService::prevPageMyGroups-failure');
});
}
$scope.prevPageAllGroups = function() {
var startFrom = pagingService.getPrevStartFrom(allGroupsPaging);
return groupsService
.getGroupsAbridged(allGroupsPaging.maxItems, startFrom, true, $scope.query)
.then(function(response) {
allGroupsPaging = pagingService.prevPageUpdate(response.data.nextId, allGroupsPaging);
updateAllGroupDisplay(response.data.groups);
})
.catch(function (error) {
handleError(error,'groupsService::prevPageAllGroups-failure');
});
}
$scope.nextPageMyGroups = function () {
return groupsService
.getGroupsAbridged(groupsPaging.maxItems, groupsPaging.next, false, $scope.query)
.then(function(response) {
var groupSets = response.data.groups;
groupsPaging = pagingService.nextPageUpdate(groupSets, response.data.nextId, groupsPaging);
if (groupSets.length > 0) {
updateGroupDisplay(response.data.groups);
}
})
.catch(function (error) {
handleError(error,'groupsService::nextPageMyGroups-failure')
});
};
$scope.nextPageAllGroups = function () {
return groupsService
.getGroupsAbridged(allGroupsPaging.maxItems, allGroupsPaging.next, true, $scope.query)
.then(function(response) {
var groupSets = response.data.groups;
allGroupsPaging = pagingService.nextPageUpdate(groupSets, response.data.nextId, allGroupsPaging);
if (groupSets.length > 0) {
updateAllGroupDisplay(response.data.groups);
}
})
.catch(function (error) {
handleError(error,'groupsService::nextPageAllGroups-failure')
});
};
$timeout($scope.refresh, 0);
}); });

View File

@ -19,14 +19,16 @@ describe('Controller: GroupsController', function () {
module('ngMock'), module('ngMock'),
module('service.groups'), module('service.groups'),
module('service.profile'), module('service.profile'),
module('service.utility') module('service.utility'),
module('service.paging'),
module('controller.groups') module('controller.groups')
}); });
beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService) { beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService, pagingService) {
this.scope = $rootScope.$new(); this.scope = $rootScope.$new();
this.groupsService = groupsService; this.groupsService = groupsService;
this.utilityService = utilityService; this.utilityService = utilityService;
this.q = $q; this.q = $q;
this.pagingService = pagingService;
profileService.getAuthenticatedUserData = function() { profileService.getAuthenticatedUserData = function() {
return $q.when('data') return $q.when('data')
@ -38,6 +40,15 @@ describe('Controller: GroupsController', function () {
} }
}) })
}; };
groupsService.getGroupsAbridged = function () {
return $q.when({
data: {
groups: ["all my groups"]
}
});
};
this.controller = $controller('GroupsController', {'$scope': this.scope}); this.controller = $controller('GroupsController', {'$scope': this.scope});
this.mockSuccessAlert = 'success'; this.mockSuccessAlert = 'success';
@ -64,7 +75,7 @@ describe('Controller: GroupsController', function () {
this.scope.refresh(); this.scope.refresh();
this.scope.$digest(); this.scope.$digest();
expect(getGroups.calls.count()).toBe(1); expect(getGroups.calls.count()).toBe(2);
expect(this.scope.groups.items).toBe("all my groups"); expect(this.scope.groups.items).toBe("all my groups");
}); });
@ -111,4 +122,110 @@ describe('Controller: GroupsController', function () {
expect(this.utilityFailure.calls.count()).toBe(1); expect(this.utilityFailure.calls.count()).toBe(1);
expect(this.scope.alerts).toEqual([this.mockFailureAlert]); expect(this.scope.alerts).toEqual([this.mockFailureAlert]);
}); });
it('nextPageMyGroups should call getGroupsAbridged with the correct parameters', function () {
var response = {
data: {
groups: "all my groups"
}
};
var getGroupSets = spyOn(this.groupsService, 'getGroupsAbridged')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.nextPageMyGroups();
expect(getGroupSets.calls.count()).toBe(1);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
});
it('prevPageMyGroups should call getGroupsAbridged with the correct parameters', function () {
var response = {
data: {
groups: "all my groups"
}
};
var getGroupSets = spyOn(this.groupsService, 'getGroupsAbridged')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.prevPageMyGroups();
expect(getGroupSets.calls.count()).toBe(1);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
this.scope.nextPageMyGroups();
this.scope.prevPageMyGroups();
expect(getGroupSets.calls.count()).toBe(3);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
});
it('nextPageAllGroups should call getGroupsAbridged with the correct parameters', function () {
var response = {
data: {
groups: "all groups"
}
};
var getGroupSets = spyOn(this.groupsService, 'getGroupsAbridged')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = true;
this.scope.nextPageAllGroups();
expect(getGroupSets.calls.count()).toBe(1);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
});
it('prevPageAllGroups should call getGroupsAbridged with the correct parameters', function () {
var response = {
data: {
groups: "all groups"
}
};
var getGroupSets = spyOn(this.groupsService, 'getGroupsAbridged')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = true;
this.scope.prevPageAllGroups();
expect(getGroupSets.calls.count()).toBe(1);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
this.scope.nextPageAllGroups();
this.scope.prevPageAllGroups();
expect(getGroupSets.calls.count()).toBe(3);
expect(getGroupSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedIgnoreAccess, expectedQuery]);
});
}); });

View File

@ -16,7 +16,7 @@
angular.module('controller.manageZones', []) angular.module('controller.manageZones', [])
.controller('ManageZonesController', function ($scope, $timeout, $log, recordsService, zonesService, groupsService, .controller('ManageZonesController', function ($scope, $timeout, $log, recordsService, zonesService, groupsService,
profileService, utilityService) { profileService, utilityService, pagingService) {
groupsService.getGroupsStored() groupsService.getGroupsStored()
.then(function (results) { .then(function (results) {
@ -38,6 +38,7 @@ angular.module('controller.manageZones', [])
$scope.alerts = []; $scope.alerts = [];
$scope.zoneInfo = {}; $scope.zoneInfo = {};
$scope.zoneChanges = {};
$scope.updateZoneInfo = {}; $scope.updateZoneInfo = {};
$scope.manageZoneState = { $scope.manageZoneState = {
UPDATE: 0, UPDATE: 0,
@ -60,7 +61,8 @@ angular.module('controller.manageZones', [])
CREATE: 0, CREATE: 0,
UPDATE: 1, UPDATE: 1,
CONFIRM_UPDATE: 2, CONFIRM_UPDATE: 2,
CONFIRM_DELETE: 3 CONFIRM_DELETE: 3,
VIEW_DETAILS: 4
}; };
$scope.aclModalParams = { $scope.aclModalParams = {
readOnly: { readOnly: {
@ -74,6 +76,8 @@ angular.module('controller.manageZones', [])
}; };
$scope.aclRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', 'SRV', 'NAPTR', 'SSHFP', 'TXT']; $scope.aclRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', 'SRV', 'NAPTR', 'SSHFP', 'TXT'];
var zoneHistoryPaging = pagingService.getNewPagingParams(100);
/** /**
* Zone modal control functions * Zone modal control functions
*/ */
@ -276,6 +280,7 @@ angular.module('controller.manageZones', [])
$scope.updateZoneInfo.hiddenTransferKey = ''; $scope.updateZoneInfo.hiddenTransferKey = '';
$scope.currentManageZoneState = $scope.manageZoneState.UPDATE; $scope.currentManageZoneState = $scope.manageZoneState.UPDATE;
$scope.refreshAclRuleDisplay(); $scope.refreshAclRuleDisplay();
$scope.refreshZoneChange();
} }
return recordsService return recordsService
.getZone($scope.zoneId) .getZone($scope.zoneId)
@ -285,6 +290,53 @@ angular.module('controller.manageZones', [])
}); });
}; };
$scope.refreshZoneChange = function() {
zoneHistoryPaging = pagingService.resetPaging(zoneHistoryPaging);
function success(response) {
$log.log('zonesService::getZoneChanges-success');
zoneHistoryPaging.next = response.data.nextId;
$scope.zoneChanges = response.data.zoneChanges;
$scope.updateZoneChangeDisplay(response.data.zoneChanges);
}
return zonesService
.getZoneChanges(zoneHistoryPaging.maxItems, undefined, $scope.zoneId)
.then(success)
.catch(function (error) {
handleError(error, 'zonesService::getZoneChanges-failure');
});
};
$scope.refreshAclRule = function (index) {
$scope.allAclRules = [];
$scope.aclRulesModal = {
action: $scope.aclModalState.VIEW_DETAILS,
title: "ACL Rules Info",
basics: $scope.aclModalParams.readOnly,
details: $scope.aclModalParams.readOnly,
};
if ($scope.zoneChanges[index].zone.acl.rules.length!=0){
for (var length = 0; length < $scope.zoneChanges[index].zone.acl.rules.length; length++) {
$scope.allAclRules.push($scope.zoneChanges[index].zone.acl.rules[length]);
if ($scope.allAclRules[length].hasOwnProperty('userId')){
getAclUser($scope.allAclRules[length].userId, length); }
else{ getAclGroup($scope.allAclRules[length].groupId, length);}
}
$scope.aclModalViewForm.$setPristine();
$("#aclModalView").modal("show");}
else{$("#aclModalView").modal("hide");}
};
$scope.closeAclModalView = function() {
$scope.aclModalViewForm.$setPristine();
};
$scope.updateZoneChangeDisplay = function (zoneChange) {
for (var length = 0; length < zoneChange.length; length++) {
getZoneGroup(zoneChange[length].zone.adminGroupId, length);
getZoneUser(zoneChange[length].userId, length);
}
};
$scope.refreshAclRuleDisplay = function() { $scope.refreshAclRuleDisplay = function() {
$scope.aclRules = []; $scope.aclRules = [];
angular.forEach($scope.zoneInfo.acl.rules, function (rule) { angular.forEach($scope.zoneInfo.acl.rules, function (rule) {
@ -292,6 +344,109 @@ angular.module('controller.manageZones', [])
}); });
}; };
/**
* Get User name and Group Name with Ids for Zone history
*/
function getZoneGroup(groupId, length) {
function success(response) {
$log.log('groupsService::getZoneGroup-success');
$scope.zoneChanges[length].zone.adminGroupName = response.data.name;
}
return groupsService
.getGroup(groupId)
.then(success)
.catch(function (error) {
handleError(error, 'groupsService::getZoneGroup-failure');
});
}
function getZoneUser(userId, length) {
function success(response) {
$log.log('profileService::getZoneUserDataById-success');
$scope.zoneChanges[length].userName = response.data.userName;
}
return profileService
.getUserDataById(userId)
.then(success)
.catch(function (error) {
handleError(error, 'profileService::getZoneUserDataById-failure');
});
};
function getAclGroup(groupId, length) {
function success(response) {
$log.log('groupsService::getAclGroup-success');
$scope.allAclRules[length].groupName = response.data.name;
}
return groupsService
.getGroup(groupId)
.then(success)
.catch(function (error) {
handleError(error, 'groupsService::getAclGroup-failure');
});
}
function getAclUser(userId, length) {
function success(response) {
$log.log('profileService::getAclUserDataById-success');
$scope.allAclRules[length].userName = response.data.userName;
}
return profileService
.getUserDataById(userId)
.then(success)
.catch(function (error) {
handleError(error, 'profileService::getAclUserDataById-failure');
});
};
/**
* Zone history Pagination
*/
$scope.getZoneHistoryPageNumber = function() {
return pagingService.getPanelTitle(zoneHistoryPaging);
};
$scope.prevPageEnabled = function() {
return pagingService.prevPageEnabled(zoneHistoryPaging);
};
$scope.nextPageEnabled = function(tab) {
return pagingService.nextPageEnabled(zoneHistoryPaging);
};
$scope.nextPageZoneHistory = function () {
return zonesService
.getZoneChanges(zoneHistoryPaging.maxItems, zoneHistoryPaging.next, $scope.zoneId )
.then(function(response) {
var zoneChanges = response.data.zoneChanges;
zoneHistoryPaging = pagingService.nextPageUpdate(zoneChanges, response.data.nextId, zoneHistoryPaging);
if (zoneChanges.length > 0) {
$scope.zoneChanges = response.data.zoneChanges;
$scope.updateZoneChangeDisplay(response.data.zoneChanges)
}
})
.catch(function (error) {
handleError(error,'zonesService::nextPage-failure')
});
};
$scope.prevPageZoneHistory = function() {
var startFrom = pagingService.getPrevStartFrom(zoneHistoryPaging);
return zonesService
.getZoneChanges(zoneHistoryPaging.maxItems, startFrom, $scope.zoneId )
.then(function(response) {
zoneHistoryPaging = pagingService.prevPageUpdate(response.data.nextId, zoneHistoryPaging);
$scope.zoneChanges = response.data.zoneChanges;
$scope.updateZoneChangeDisplay(response.data.zoneChanges);
})
.catch(function (error) {
handleError(error,'zonesService::prevPage-failure');
});
};
/** /**
* Service interaction functions * Service interaction functions
*/ */

View File

@ -22,16 +22,18 @@ describe('Controller: ManageZonesController', function () {
module('service.utility'), module('service.utility'),
module('service.zones'), module('service.zones'),
module('service.profile'), module('service.profile'),
module('service.paging'),
module('controller.manageZones') module('controller.manageZones')
}); });
beforeEach(inject(function ($rootScope, $controller, $q, groupsService, recordsService, zonesService, beforeEach(inject(function ($rootScope, $controller, $q, groupsService, recordsService, zonesService,
profileService) { profileService, pagingService) {
this.rootScope = $rootScope; this.rootScope = $rootScope;
this.scope = $rootScope.$new(); this.scope = $rootScope.$new();
this.groupsService = groupsService; this.groupsService = groupsService;
this.zonesService = zonesService; this.zonesService = zonesService;
this.recordsService = recordsService; this.recordsService = recordsService;
this.profileService = profileService; this.profileService = profileService;
this.pagingService = pagingService;
this.q = $q; this.q = $q;
this.groupsService.getGroups = function () { this.groupsService.getGroups = function () {
return $q.when({ return $q.when({
@ -205,11 +207,14 @@ describe('Controller: ManageZonesController', function () {
} }
} }
}; };
var getZone = spyOn(this.recordsService, 'getZone') var getZone = spyOn(this.recordsService, 'getZone')
.and.stub() .and.stub()
.and.returnValue(this.q.when(mockResponse)); .and.returnValue(this.q.when(mockResponse));
var refreshAclRuleDisplay = spyOn(this.scope, 'refreshAclRuleDisplay') var refreshAclRuleDisplay = spyOn(this.scope, 'refreshAclRuleDisplay')
.and.stub(); .and.stub();
var refreshZoneChange = spyOn(this.scope, 'refreshZoneChange')
.and.stub();
this.scope.currentManageZoneState = this.scope.manageZoneState.CONFIRM_UPDATE; this.scope.currentManageZoneState = this.scope.manageZoneState.CONFIRM_UPDATE;
this.scope.updateZoneInfo.hiddenKey = 'some key'; this.scope.updateZoneInfo.hiddenKey = 'some key';
this.scope.updateZoneInfo.hiddenTransferKey = 'some key'; this.scope.updateZoneInfo.hiddenTransferKey = 'some key';
@ -217,6 +222,7 @@ describe('Controller: ManageZonesController', function () {
this.scope.$digest(); this.scope.$digest();
expect(getZone.calls.count()).toBe(1); expect(getZone.calls.count()).toBe(1);
expect(refreshAclRuleDisplay.calls.count()).toBe(1); expect(refreshAclRuleDisplay.calls.count()).toBe(1);
expect(refreshZoneChange.calls.count()).toBe(1);
expect(this.scope.zoneInfo).toEqual(mockResponse.data.zone); expect(this.scope.zoneInfo).toEqual(mockResponse.data.zone);
expect(this.scope.updateZoneInfo. adminGroupId).toEqual('id101112'); expect(this.scope.updateZoneInfo. adminGroupId).toEqual('id101112');
expect(this.scope.updateZoneInfo.hiddenKey).toEqual(''); expect(this.scope.updateZoneInfo.hiddenKey).toEqual('');
@ -554,4 +560,99 @@ describe('Controller: ManageZonesController', function () {
expect(toDisplayAclRule.calls.count()).toBe(3); expect(toDisplayAclRule.calls.count()).toBe(3);
expect(this.scope.aclRules).toEqual(this.scope.zoneInfo.acl.rules); expect(this.scope.aclRules).toEqual(this.scope.zoneInfo.acl.rules);
}); });
it('next page should call listZoneChangesByZoneId with the correct parameters', function () {
var mockZoneChange = {data: {
zoneId: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Active",
created: "2017-02-15T14:58:39Z",
account: "c8234503-bfda-4b80-897f-d74129051eaa",
acl: {rules: []},
adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa",
id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
shared: false,
status: "Active",
latestSync: "2017-02-15T14:58:39Z",
isTest: true
}}],maxItems: 100}};
var getZoneChanges = spyOn(this.zonesService, 'getZoneChanges')
.and.stub()
.and.returnValue(this.q.when(mockZoneChange));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedZoneId = this.scope.zoneId;
this.scope.nextPageZoneHistory();
expect(getZoneChanges.calls.count()).toBe(1);
expect(getZoneChanges.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedZoneId]);
});
it('prev page should call getZoneChanges with the correct parameters', function () {
var mockZoneChange = {data: {
zoneId: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Active",
created: "2017-02-15T14:58:39Z",
account: "c8234503-bfda-4b80-897f-d74129051eaa",
acl: {rules: []},
adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa",
id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
shared: false,
status: "Active",
latestSync: "2017-02-15T14:58:39Z",
isTest: true
}}],maxItems: 100}};
var getZoneChanges = spyOn(this.zonesService, 'getZoneChanges')
.and.stub()
.and.returnValue(this.q.when(mockZoneChange));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedZoneId = this.scope.zoneId;
this.scope.prevPageZoneHistory();
expect(getZoneChanges.calls.count()).toBe(1);
expect(getZoneChanges.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedZoneId]);
});
it('test that we properly get Zone History data', function(){
this.scope.zoneChanges = {};
var mockZoneChange = {data: {
zoneId: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Active",
created: "2017-02-15T14:58:39Z",
account: "c8234503-bfda-4b80-897f-d74129051eaa",
acl: {rules: []},
adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa",
id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
shared: false,
status: "Active",
latestSync: "2017-02-15T14:58:39Z",
isTest: true
}}],maxItems: 100}};
var updateZoneChangeDisplay = spyOn(this.scope, 'updateZoneChangeDisplay')
.and.stub();
var getZoneChanges = spyOn(this.zonesService, 'getZoneChanges')
.and.stub()
.and.returnValue(this.q.when(mockZoneChange));
this.scope.refreshZoneChange();
this.scope.$digest();
expect(getZoneChanges.calls.count()).toBe(1);
expect(this.scope.zoneChanges).toEqual(mockZoneChange.data.zoneChanges);
});
}); });

View File

@ -14,13 +14,35 @@
* limitations under the License. * limitations under the License.
*/ */
angular.module('controller.membership', []).controller('MembershipController', function ($scope, $log, $location, $timeout, angular.module('controller.membership', []).controller('MembershipController', function ($scope, $log, $location, $sce, $timeout, pagingService,
groupsService, profileService, utilityService) { groupsService, profileService, utilityService) {
$scope.membership = { members: [], group: {} }; $scope.membership = { members: [], group: {} };
$scope.membershipLoaded = false; $scope.membershipLoaded = false;
$scope.alerts = []; $scope.alerts = [];
$scope.isGroupAdmin = false; $scope.isGroupAdmin = false;
$scope.groupChanges = {};
$scope.currentGroup = {};
$scope.groupModalState = {
VIEW_DETAILS: 1
};
// read-only data for setting various classes/attributes in group modal
$scope.groupModalParams = {
readOnly: {
class: "",
readOnly: true
}
};
$scope.changeMessage = function (groupChangeMessage) {
message = groupChangeMessage.replaceAll('. ', '.<br>')
return $sce.trustAsHtml(message);
};
// paging status for group changes
var changePaging = pagingService.getNewPagingParams(100);
function handleError(error, type) { function handleError(error, type) {
var alert = utilityService.failure(error, type); var alert = utilityService.failure(error, type);
@ -207,8 +229,113 @@ angular.module('controller.membership', []).controller('MembershipController', f
$scope.resetNewMemberData(); $scope.resetNewMemberData();
$scope.getGroupInfo(id); $scope.getGroupInfo(id);
$scope.refreshGroupChanges(id);
}; };
$scope.refreshGroupChanges = function(id) {
if(!id){
var id = $location.absUrl().toString();
id = id.substring(id.lastIndexOf('/') + 1);
id = id.substring(0, id.indexOf('#'))
}
$log.debug('refreshGroupChanges, loading group with id ', id);
changePaging = pagingService.resetPaging(changePaging);
function success(response) {
$log.debug('groupsService::getGroupChanges-success');
changePaging.next = response.data.nextId;
updateChangeDisplay(response.data.changes)
}
return groupsService
.getGroupChanges(id, changePaging.maxItems, undefined)
.then(success)
.catch(function (error){
handleError(error, 'groupsService::getGroupChanges-failure');
});
};
function updateChangeDisplay(changes) {
var newChanges = [];
angular.forEach(changes, function(change) {
newChanges.push(change);
});
$scope.groupChanges = newChanges;
}
/**
* Group change paging
*/
$scope.getChangePageTitle = function() {
return pagingService.getPanelTitle(changePaging);
};
$scope.changePrevPageEnabled = function() {
return pagingService.prevPageEnabled(changePaging);
};
$scope.changeNextPageEnabled = function() {
return pagingService.nextPageEnabled(changePaging);
};
$scope.changePrevPage = function() {
var startFrom = pagingService.getPrevStartFrom(changePaging);
var id = $location.absUrl().toString();
id = id.substring(id.lastIndexOf('/') + 1);
id = id.substring(0, id.indexOf('#'))
$log.debug('changePrevPage, loading group with id ', id);
return groupsService
.getGroupChanges(id, changePaging.maxItems, startFrom)
.then(function(response) {
changePaging = pagingService.prevPageUpdate(response.data.nextId, changePaging);
updateChangeDisplay(response.data.changes);
})
.catch(function (error) {
handleError(error, 'groupsService::changePrevPage-failure');
});
};
$scope.changeNextPage = function() {
var id = $location.absUrl().toString();
id = id.substring(id.lastIndexOf('/') + 1);
id = id.substring(0, id.indexOf('#'))
$log.debug('changeNextPage, loading group with id ', id);
return groupsService
.getGroupChanges(id, changePaging.maxItems, changePaging.next)
.then(function(response) {
var changes = response.data.changes;
changePaging = pagingService.nextPageUpdate(changes, response.data.nextId, changePaging);
if(changes.length > 0 ){
updateChangeDisplay(changes);
}
})
.catch(function (error) {
handleError(error, 'groupsService::changeNextPage-failure');
});
};
$scope.viewGroupInfo = function(group) {
var newGroup = angular.copy(group);
newGroup.adminIds = [];
angular.forEach(group.admins, function(admin) {
newGroup.adminIds.push(admin.id);
});
newGroup.memberIds = [];
angular.forEach(group.members, function(member) {
newGroup.memberIds.push(member.id);
});
$scope.currentGroup = newGroup;
$scope.groupModal = {
action: $scope.groupModalState.VIEW_DETAILS,
title: "Group Info",
basics: $scope.groupModalParams.readOnly,
details: $scope.groupModalParams.readOnly,
};
$("#group_modal").modal("show");
};
$scope.closeGroupModal = function() {
$scope.viewGroupForm.$setPristine();
};
$timeout($scope.refresh, 0); $timeout($scope.refresh, 0);
}); });

View File

@ -20,14 +20,16 @@ describe('Controller: MembershipController', function () {
module('service.groups'), module('service.groups'),
module('service.profile'), module('service.profile'),
module('service.utility'), module('service.utility'),
module('service.paging'),
module('controller.membership') module('controller.membership')
}); });
beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService) { beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService, pagingService) {
this.rootScope = $rootScope; this.rootScope = $rootScope;
this.scope = $rootScope.$new(); this.scope = $rootScope.$new();
this.groupsService = groupsService; this.groupsService = groupsService;
this.profileService = profileService; this.profileService = profileService;
this.utilityService = utilityService; this.utilityService = utilityService;
this.pagingService = pagingService;
this.q = $q; this.q = $q;
var mockGroup = { var mockGroup = {
data: { data: {
@ -68,6 +70,47 @@ describe('Controller: MembershipController', function () {
this.groupsService.getGroupMemberList = function() { this.groupsService.getGroupMemberList = function() {
return $q.when(mockGroupList); return $q.when(mockGroupList);
}; };
this.groupsService.getGroupChanges = function () {
return $q.when({
data: {
changes: [
{
newGroup: {
id: "f9329f39-595d-45c9-8cdf-ac36e96e085d",
name: "test-group",
email: "test@test.com",
created: "2022-07-20T10:14:49Z",
status: "Active",
members: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
],
admins: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
]
},
changeType: "Delete",
userId: "ea7ec24e-3cc2-4740-b1b8-acde0158271f",
id: "13516a79-1c61-4b9d-b442-0a773fc9c99f",
created: "2022-07-20T10:24:28Z",
userName: "professor"
}
],
maxItems: 100
}
});
};
this.controller = $controller('MembershipController', {'$scope': this.scope}); this.controller = $controller('MembershipController', {'$scope': this.scope});
this.mockSuccessAlert = "success"; this.mockSuccessAlert = "success";
@ -401,4 +444,169 @@ describe('Controller: MembershipController', function () {
expect(this.scope.membership.members).toEqual(expectedMembership); expect(this.scope.membership.members).toEqual(expectedMembership);
expect(this.scope.isGroupAdmin).toBe(true); expect(this.scope.isGroupAdmin).toBe(true);
}); });
it('test that we properly get group change data', function(){
this.scope.groupChanges = {};
var response = {
data: {
changes: [
{
newGroup: {
id: "f9329f39-595d-45c9-8cdf-ac36e96e085d",
name: "test-group",
email: "test@test.com",
created: "2022-07-20T10:14:49Z",
status: "Active",
members: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
],
admins: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
]
},
changeType: "Delete",
userId: "ea7ec24e-3cc2-4740-b1b8-acde0158271f",
id: "13516a79-1c61-4b9d-b442-0a773fc9c99f",
created: "2022-07-20T10:24:28Z",
userName: "professor"
}
],
maxItems: 100
}
};
var getGroupChangesSets = spyOn(this.groupsService, 'getGroupChanges')
.and.stub()
.and.returnValue(this.q.when(response));
this.scope.refresh();
this.scope.$digest();
expect(getGroupChangesSets.calls.count()).toBe(1);
expect(this.scope.groupChanges).toEqual(response.data.changes);
});
it('nextPage should call getGroupChanges with the correct parameters', function () {
var response = {
data: {
changes: [
{
newGroup: {
id: "f9329f39-595d-45c9-8cdf-ac36e96e085d",
name: "test-group",
email: "test@test.com",
created: "2022-07-20T10:14:49Z",
status: "Active",
members: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
],
admins: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
]
},
changeType: "Delete",
userId: "ea7ec24e-3cc2-4740-b1b8-acde0158271f",
id: "13516a79-1c61-4b9d-b442-0a773fc9c99f",
created: "2022-07-20T10:24:28Z",
userName: "professor"
}
],
maxItems: 100
}
};
var getGroupChangesSets = spyOn(this.groupsService, 'getGroupChanges')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedId = "";
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
this.scope.changeNextPage();
expect(getGroupChangesSets.calls.count()).toBe(1);
expect(getGroupChangesSets.calls.mostRecent().args).toEqual(
[expectedId, expectedMaxItems, expectedStartFrom]);
});
it('prevPage should call getGroupChanges with the correct parameters', function () {
var response = {
data: {
changes: [
{
newGroup: {
id: "f9329f39-595d-45c9-8cdf-ac36e96e085d",
name: "test-group",
email: "test@test.com",
created: "2022-07-20T10:14:49Z",
status: "Active",
members: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
],
admins: [
{
id: "ea7ec24e-3cc2-4740-b1b8-acde0158271f"
},
{
id: "5bda099e-be26-4aac-a310-ecf221ee2451"
}
]
},
changeType: "Delete",
userId: "ea7ec24e-3cc2-4740-b1b8-acde0158271f",
id: "13516a79-1c61-4b9d-b442-0a773fc9c99f",
created: "2022-07-20T10:24:28Z",
userName: "professor"
}
],
maxItems: 100
}
};
var getGroupChangesSets = spyOn(this.groupsService, 'getGroupChanges')
.and.stub()
.and.returnValue(this.q.when(response));
var expectedId = "";
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
this.scope.changePrevPage();
expect(getGroupChangesSets.calls.count()).toBe(1);
expect(getGroupChangesSets.calls.mostRecent().args).toEqual(
[expectedId, expectedMaxItems, expectedStartFrom]);
this.scope.changeNextPage();
this.scope.changePrevPage();
expect(getGroupChangesSets.calls.count()).toBe(3);
expect(getGroupChangesSets.calls.mostRecent().args).toEqual(
[expectedId, expectedMaxItems, expectedStartFrom]);
});
}); });

View File

@ -53,7 +53,7 @@ angular.module('controller.zones', [])
$scope.currentZone.transferConnection = {}; $scope.currentZone.transferConnection = {};
}; };
groupsService.getGroupsAbridged(true, "").then(function (results) { groupsService.getGroups(true, "").then(function (results) {
if (results.data) { if (results.data) {
// Get all groups where the group members include the current user // Get all groups where the group members include the current user
$scope.myGroups = results.data.groups.filter(grp => grp.members.findIndex(mem => mem.id === $scope.profile.id) >= 0); $scope.myGroups = results.data.groups.filter(grp => grp.members.findIndex(mem => mem.id === $scope.profile.id) >= 0);
@ -87,7 +87,7 @@ angular.module('controller.zones', [])
allZonesPaging = pagingService.resetPaging(allZonesPaging); allZonesPaging = pagingService.resetPaging(allZonesPaging);
zonesService zonesService
.getZones(zonesPaging.maxItems, undefined, $scope.query) .getZones(zonesPaging.maxItems, undefined, $scope.query, $scope.searchByAdminGroup)
.then(function (response) { .then(function (response) {
$log.log('zonesService::getZones-success (' + response.data.zones.length + ' zones)'); $log.log('zonesService::getZones-success (' + response.data.zones.length + ' zones)');
zonesPaging.next = response.data.nextId; zonesPaging.next = response.data.nextId;
@ -101,7 +101,7 @@ angular.module('controller.zones', [])
}); });
zonesService zonesService
.getZones(zonesPaging.maxItems, undefined, $scope.query, true) .getZones(zonesPaging.maxItems, undefined, $scope.query, $scope.searchByAdminGroup, true)
.then(function (response) { .then(function (response) {
$log.log('zonesService::getZones-success (' + response.data.zones.length + ' zones)'); $log.log('zonesService::getZones-success (' + response.data.zones.length + ' zones)');
allZonesPaging.next = response.data.nextId; allZonesPaging.next = response.data.nextId;
@ -207,7 +207,7 @@ angular.module('controller.zones', [])
$scope.prevPageMyZones = function() { $scope.prevPageMyZones = function() {
var startFrom = pagingService.getPrevStartFrom(zonesPaging); var startFrom = pagingService.getPrevStartFrom(zonesPaging);
return zonesService return zonesService
.getZones(zonesPaging.maxItems, startFrom, $scope.query, false) .getZones(zonesPaging.maxItems, startFrom, $scope.query, $scope.searchByAdminGroup, false)
.then(function(response) { .then(function(response) {
zonesPaging = pagingService.prevPageUpdate(response.data.nextId, zonesPaging); zonesPaging = pagingService.prevPageUpdate(response.data.nextId, zonesPaging);
updateZoneDisplay(response.data.zones); updateZoneDisplay(response.data.zones);
@ -220,7 +220,7 @@ angular.module('controller.zones', [])
$scope.prevPageAllZones = function() { $scope.prevPageAllZones = function() {
var startFrom = pagingService.getPrevStartFrom(allZonesPaging); var startFrom = pagingService.getPrevStartFrom(allZonesPaging);
return zonesService return zonesService
.getZones(allZonesPaging.maxItems, startFrom, $scope.query, true) .getZones(allZonesPaging.maxItems, startFrom, $scope.query, $scope.searchByAdminGroup, true)
.then(function(response) { .then(function(response) {
allZonesPaging = pagingService.prevPageUpdate(response.data.nextId, allZonesPaging); allZonesPaging = pagingService.prevPageUpdate(response.data.nextId, allZonesPaging);
updateAllZonesDisplay(response.data.zones); updateAllZonesDisplay(response.data.zones);
@ -232,7 +232,7 @@ angular.module('controller.zones', [])
$scope.nextPageMyZones = function () { $scope.nextPageMyZones = function () {
return zonesService return zonesService
.getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query, false) .getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query, $scope.searchByAdminGroup, false)
.then(function(response) { .then(function(response) {
var zoneSets = response.data.zones; var zoneSets = response.data.zones;
zonesPaging = pagingService.nextPageUpdate(zoneSets, response.data.nextId, zonesPaging); zonesPaging = pagingService.nextPageUpdate(zoneSets, response.data.nextId, zonesPaging);
@ -248,7 +248,7 @@ angular.module('controller.zones', [])
$scope.nextPageAllZones = function () { $scope.nextPageAllZones = function () {
return zonesService return zonesService
.getZones(allZonesPaging.maxItems, allZonesPaging.next, $scope.query, true) .getZones(allZonesPaging.maxItems, allZonesPaging.next, $scope.query, $scope.searchByAdminGroup, true)
.then(function(response) { .then(function(response) {
var zoneSets = response.data.zones; var zoneSets = response.data.zones;
allZonesPaging = pagingService.nextPageUpdate(zoneSets, response.data.nextId, allZonesPaging); allZonesPaging = pagingService.nextPageUpdate(zoneSets, response.data.nextId, allZonesPaging);

View File

@ -39,7 +39,7 @@ describe('Controller: ZonesController', function () {
profileService.getAuthenticatedUserData = function() { profileService.getAuthenticatedUserData = function() {
return $q.when({data: {id: "userId"}}); return $q.when({data: {id: "userId"}});
}; };
groupsService.getGroupsAbridged = function () { groupsService.getGroups = function () {
return $q.when({ return $q.when({
data: { data: {
groups: [{id: "all my groups", members: [{id: "userId"}]}] groups: [{id: "all my groups", members: [{id: "userId"}]}]
@ -76,13 +76,14 @@ describe('Controller: ZonesController', function () {
var expectedMaxItems = 100; var expectedMaxItems = 100;
var expectedStartFrom = undefined; var expectedStartFrom = undefined;
var expectedQuery = this.scope.query; var expectedQuery = this.scope.query;
var expectedSearchByAdminGroup = this.scope.searchByAdminGroup;
var expectedignoreAccess = false; var expectedignoreAccess = false;
this.scope.nextPageMyZones(); this.scope.nextPageMyZones();
expect(getZoneSets.calls.count()).toBe(1); expect(getZoneSets.calls.count()).toBe(1);
expect(getZoneSets.calls.mostRecent().args).toEqual( expect(getZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedignoreAccess]); [expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess]);
}); });
it('prevPageMyZones should call getZones with the correct parameters', function () { it('prevPageMyZones should call getZones with the correct parameters', function () {
@ -93,19 +94,20 @@ describe('Controller: ZonesController', function () {
var expectedMaxItems = 100; var expectedMaxItems = 100;
var expectedStartFrom = undefined; var expectedStartFrom = undefined;
var expectedQuery = this.scope.query; var expectedQuery = this.scope.query;
var expectedSearchByAdminGroup = this.scope.searchByAdminGroup;
var expectedignoreAccess = false; var expectedignoreAccess = false;
this.scope.prevPageMyZones(); this.scope.prevPageMyZones();
expect(getZoneSets.calls.count()).toBe(1); expect(getZoneSets.calls.count()).toBe(1);
expect(getZoneSets.calls.mostRecent().args).toEqual( expect(getZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedignoreAccess]); [expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess]);
this.scope.nextPageMyZones(); this.scope.nextPageMyZones();
this.scope.prevPageMyZones(); this.scope.prevPageMyZones();
expect(getZoneSets.calls.count()).toBe(3); expect(getZoneSets.calls.count()).toBe(3);
expect(getZoneSets.calls.mostRecent().args).toEqual( expect(getZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedignoreAccess]); [expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess]);
}); });
}); });

View File

@ -31,15 +31,57 @@
// paging status for recordsets // paging status for recordsets
var recordsPaging = pagingService.getNewPagingParams(100); var recordsPaging = pagingService.getNewPagingParams(100);
var recordType = [];
var recordName = [];
$( "#record-search-text" ).autocomplete({
source: function( request, response ) {
$.ajax({
url: "/api/recordsets?maxItems=100",
dataType: "json",
data: "recordNameFilter="+request.term+"%25&nameSort=asc",
success: function( data ) {
const recordSearch = JSON.parse(JSON.stringify(data));
response($.map(recordSearch.recordSets, function(item) {
return {value: item.fqdn +' | '+ item.type , label: 'name: ' + item.fqdn + ' | type: ' + item.type }}))}
});
},
minLength: 2,
select: function (event, ui) {
$scope.query = ui.item.value;
$("#record-search-text").val(ui.item.value);
return false;
},
open: function() {
$( this ).removeClass( "ui-corner-all" ).addClass( "ui-corner-top" );
},
close: function() {
$( this ).removeClass( "ui-corner-top" ).addClass( "ui-corner-all" );
}
});
$.ui.autocomplete.prototype._renderItem = function( ul, item ) {
let recordSet = String(item.label).replace(new RegExp(this.term, "gi"),"<b>$&</b>");
return $("<li></li>")
.data("ui-autocomplete-item", item.value)
.append("<div>" + recordSet + "</div>")
.appendTo(ul); };
$scope.refreshRecords = function() { $scope.refreshRecords = function() {
if($scope.query.includes("|")) {
const queryRecord = $scope.query.split('|');
recordName = queryRecord[0].trim();
recordType = queryRecord[1].trim(); }
else { recordName = $scope.query;
recordType = $scope.selectedRecordTypes.toString(); }
recordsPaging = pagingService.resetPaging(recordsPaging); recordsPaging = pagingService.resetPaging(recordsPaging);
function success(response) { function success(response) {
recordsPaging.next = response.data.nextId; recordsPaging.next = response.data.nextId;
updateRecordDisplay(response.data['recordSets']); updateRecordDisplay(response.data['recordSets']);
} }
return recordsService return recordsService
.listRecordSetData(recordsPaging.maxItems, undefined, $scope.query, $scope.selectedRecordTypes.toString(), $scope.nameSort, $scope.ownerGroupFilter) .listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter)
.then(success) .then(success)
.catch(function (error) { .catch(function (error) {
handleError(error, 'dnsChangesService::getRecordSet-failure'); handleError(error, 'dnsChangesService::getRecordSet-failure');
@ -79,6 +121,7 @@
} }
}; };
function updateRecordDisplay(records) { function updateRecordDisplay(records) {
var newRecords = []; var newRecords = [];
angular.forEach(records, function(record) { angular.forEach(records, function(record) {

View File

@ -75,7 +75,7 @@ angular.module('service.groups', [])
query = null; query = null;
} }
var params = { var params = {
"maxItems": 1500, "maxItems": 3000,
"groupNameFilter": query, "groupNameFilter": query,
"ignoreAccess": ignoreAccess "ignoreAccess": ignoreAccess
}; };
@ -84,12 +84,13 @@ angular.module('service.groups', [])
return $http.get(url); return $http.get(url);
}; };
this.getGroupsAbridged = function (ignoreAccess, query) { this.getGroupsAbridged = function (limit, startFrom, ignoreAccess, query) {
if (query == "") { if (query == "") {
query = null; query = null;
} }
var params = { var params = {
"maxItems": 1500, "maxItems": limit,
"startFrom": startFrom,
"groupNameFilter": query, "groupNameFilter": query,
"ignoreAccess": ignoreAccess, "ignoreAccess": ignoreAccess,
"abridged": true "abridged": true
@ -105,6 +106,12 @@ angular.module('service.groups', [])
return $http.get(url); return $http.get(url);
}; };
this.getGroupChanges = function (groupId, count, startFrom) {
var url = '/api/groups/' + groupId + '/groupchanges';
url = this.urlBuilder(url, { 'startFrom': startFrom, 'maxItems': count });
return $http.get(url);
};
this.getGroupsStored = function () { this.getGroupsStored = function () {
if (_refreshMyGroups || _myGroupsPromise == undefined) { if (_refreshMyGroups || _myGroupsPromise == undefined) {
_myGroupsPromise = this.getGroups().then( _myGroupsPromise = this.getGroups().then(

View File

@ -26,6 +26,10 @@ angular.module('service.profile', [])
return $http.get('/api/users/lookupuser/' + username); return $http.get('/api/users/lookupuser/' + username);
} }
this.getUserDataById = function(userId){
return $http.get('/api/users/' + userId);
}
this.regenerateCredentials = function(){ this.regenerateCredentials = function(){
return $http.post('/regenerate-creds', {}, {headers: utilityService.getCsrfHeader()}); return $http.post('/regenerate-creds', {}, {headers: utilityService.getCsrfHeader()});
} }

View File

@ -43,6 +43,10 @@ describe('Service: profileService', function () {
expect(this.profileService.getUserDataByUsername).toBeDefined(); expect(this.profileService.getUserDataByUsername).toBeDefined();
}); });
it('should have getUserDataById method', function () {
expect(this.profileService.getUserDataByUsername).toBeDefined();
});
it('should have regenerateCredentials method', function () { it('should have regenerateCredentials method', function () {
expect(this.profileService.regenerateCredentials()).toBeDefined(); expect(this.profileService.regenerateCredentials()).toBeDefined();
}); });
@ -119,6 +123,39 @@ describe('Service: profileService', function () {
this.$httpBackend.flush(); this.$httpBackend.flush();
}); });
it('getUserDataByUserId method should return 200 with valid user', function (done) {
this.$httpBackend.expectGET('/api/users/userId').respond('success');
this.profileService.getUserDataById('userId')
.then(function (response) {
expect(response.status).toBe(200);
expect(response.data).toBe('success');
done();
}, function (error) {
fail('lookupUserAccount expected 200, but got ' + error.status.toString());
done();
});
this.$httpBackend.flush();
});
it('getUserDataByUserId method should return 400 with invalid user', function (done) {
var url = '/api/users/:userId';
this.$httpBackend.whenRoute('GET', url)
.respond(function () {
return [400, 'response body', {}, 'TestPhrase'];
});
this.profileService.getUserDataById('badUserId')
.then(function (response) {
fail('lookupUserAccount expected 400, but got ' + response.status.toString());
done();
}, function (error) {
expect(error.status).toBe(400);
done();
});
this.$httpBackend.flush();
});
it('regenerateCredentials method should return 400 with invalid user', function (done) { it('regenerateCredentials method should return 400 with invalid user', function (done) {
var url = '/regenerate-creds'; var url = '/regenerate-creds';
this.$httpBackend.whenRoute('POST', url).respond(400); this.$httpBackend.whenRoute('POST', url).respond(400);

View File

@ -19,7 +19,7 @@
angular.module('service.zones', []) angular.module('service.zones', [])
.service('zonesService', function ($http, groupsService, $log, utilityService) { .service('zonesService', function ($http, groupsService, $log, utilityService) {
this.getZones = function (limit, startFrom, query, ignoreAccess) { this.getZones = function (limit, startFrom, query, searchByAdminGroup, ignoreAccess) {
if (query == "") { if (query == "") {
query = null; query = null;
} }
@ -27,9 +27,28 @@ angular.module('service.zones', [])
"maxItems": limit, "maxItems": limit,
"startFrom": startFrom, "startFrom": startFrom,
"nameFilter": query, "nameFilter": query,
"searchByAdminGroup": searchByAdminGroup,
"ignoreAccess": ignoreAccess "ignoreAccess": ignoreAccess
}; };
var url = groupsService.urlBuilder("/api/zones", params); var url = groupsService.urlBuilder("/api/zones", params);
let loader = $("#loader");
loader.modal({
backdrop: "static", //remove ability to close modal with click
keyboard: false, //remove option to close with keyboard
show: true //Display loader!
})
let promis = $http.get(url);
// Hide loader when api gets response
promis.then(()=>loader.modal("hide"), ()=>loader.modal("hide"))
return promis
};
this.getZoneChanges = function (limit, startFrom, zoneId) {
var params = {
"maxItems": limit,
"startFrom": startFrom
}
var url = utilityService.urlBuilder ( "/api/zones/" + zoneId + "/changes", params);
return $http.get(url); return $http.get(url);
}; };

View File

@ -27,14 +27,23 @@ describe('Service: zoneService', function () {
})); }));
it('http backend gets called properly when getting zones', function () { it('http backend gets called properly when getting zones', function () {
this.$httpBackend.expectGET('/api/zones?maxItems=100&startFrom=start&nameFilter=someQuery&ignoreAccess=false').respond('zone returned'); this.$httpBackend.expectGET('/api/zones?maxItems=100&startFrom=start&nameFilter=someQuery&searchByAdminGroup=false&ignoreAccess=false').respond('zone returned');
this.zonesService.getZones('100', 'start', 'someQuery', false) this.zonesService.getZones('100', 'start', 'someQuery', false, false)
.then(function(response) { .then(function(response) {
expect(response.data).toBe('zone returned'); expect(response.data).toBe('zone returned');
}); });
this.$httpBackend.flush(); this.$httpBackend.flush();
}); });
it('http backend gets called properly when getting zoneChanges', function () {
this.$httpBackend.expectGET('/api/zones/zoneid/changes?maxItems=100&startFrom=start').respond('zoneChanges returned');
this.zonesService.getZoneChanges('100', 'start', 'zoneid', false)
.then(function(response) {
expect(response.data).toBe('zoneChanges returned');
});
this.$httpBackend.flush();
});
it('http backend gets called properly when sending zone', function (done) { it('http backend gets called properly when sending zone', function (done) {
this.$httpBackend.expectPOST('/api/zones').respond('zone sent'); this.$httpBackend.expectPOST('/api/zones').respond('zone sent');
this.zonesService.sendZone('zone payload') this.zonesService.sendZone('zone payload')

View File

@ -145,6 +145,48 @@ trait TestApplicationData { this: Mockito =>
| } | }
""".stripMargin) """.stripMargin)
val hobbitGroupChangeId = "b6018a9b-c893-40e9-aa25-4ccfee460c18"
val hobbitGroupChange: JsValue = Json.parse(s"""{
| "newGroup": {
| "id": "$hobbitGroupId",
| "name": "hobbits",
| "email": "hobbitAdmin@shire.me",
| "description": "Hobbits of the shire",
| "members": [ { "id": "${frodoUser.id}" }, { "id": "samwise-userId" } ],
| "admins": [ { "id": "${frodoUser.id}" } ]
| },
| "changeType": "Create",
| "userId": "${frodoUser.id}",
| "oldGroup": {},
| "id": "b6018a9b-c893-40e9-aa25-4ccfee460c18",
| "created": "2022-07-22T08:19:22Z",
| "userName": "$frodoUser",
| "groupChangeMessage": ""
| }
""".stripMargin)
val hobbitGroupChanges: JsValue = Json.parse(s"""{
| "changes": [{
| "newGroup": {
| "id": "$hobbitGroupId",
| "name": "hobbits",
| "email": "hobbitAdmin@shire.me",
| "description": "Hobbits of the shire",
| "members": [ { "id": "${frodoUser.id}" }, { "id": "samwise-userId" } ],
| "admins": [ { "id": "${frodoUser.id}" } ]
| },
| "changeType": "Create",
| "userId": "${frodoUser.id}",
| "oldGroup": {},
| "id": "b6018a9b-c893-40e9-aa25-4ccfee460c18",
| "created": "2022-07-22T08:19:22Z",
| "userName": "$frodoUser",
| "groupChangeMessage": ""
| }],
| "maxItems": 100
| }
""".stripMargin)
val ringbearerGroup: JsValue = Json.parse( val ringbearerGroup: JsValue = Json.parse(
s"""{ s"""{
| "id": "ringbearer-group-uuid", | "id": "ringbearer-group-uuid",
@ -196,6 +238,25 @@ trait TestApplicationData { this: Mockito =>
| } | }
""".stripMargin) """.stripMargin)
val hobbitZoneChange: JsValue = Json.parse(s"""{
| "zoneId": "$hobbitZoneId",
| "zoneChanges":
| [{ "zone": {
| "name": "$hobbitZoneName",
| "email": "hobbitAdmin@shire.me",
| "status": "Active",
| "account": "system",
| "acl": "rules",
| "adminGroupId": "$hobbitGroupId",
| "id": "$hobbitZoneId",
| "shared": false,
| "status": "Active",
| "isTest": true
| }}],
| "maxItems": 100
| }
""".stripMargin)
val hobbitZoneRequest: JsValue = Json.parse(s"""{ val hobbitZoneRequest: JsValue = Json.parse(s"""{
| "name": "hobbits", | "name": "hobbits",
| "email": "hobbitAdmin@shire.me", | "email": "hobbitAdmin@shire.me",

View File

@ -800,6 +800,154 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
} }
} }
".getGroupChange" should {
tag("slow")
"return the group change if it is found - status ok (200)" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/groups/change/${hobbitGroupChangeId}" =>
defaultActionBuilder { Results.Ok(hobbitGroupChange) }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getGroupChange(hobbitGroupChangeId)(
FakeRequest(GET, s"/groups/change/$hobbitGroupChangeId")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(OK)
hasCacheHeaders(result)
contentAsJson(result) must beEqualTo(hobbitGroupChange)
}
"return authentication failed (401) when auth fails in the backend" in new WithApplication(
app
) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/groups/change/${hobbitGroupChangeId}" =>
defaultActionBuilder { Results.Unauthorized("Invalid credentials") }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getGroupChange(hobbitGroupChangeId)(
FakeRequest(GET, s"/groups/change/$hobbitGroupChangeId")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(UNAUTHORIZED)
hasCacheHeaders(result)
}
"return a not found (404) if the group change does not exist" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == "http://localhost:9001/groups/change/not-hobbits" =>
defaultActionBuilder { Results.NotFound }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result = underTest.getGroupChange("not-hobbits")(
FakeRequest(GET, "/groups/change/not-hobbits")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(NOT_FOUND)
hasCacheHeaders(result)
}
"return status forbidden (403) if the user account is locked" in new WithApplication(app) {
val client = mock[WSClient]
val underTest =
TestVinylDNS(
testConfigLdap,
mockLdapAuthenticator,
mockLockedUserAccessor,
client,
components,
crypto,
mockOidcAuth
)
val result =
underTest.getGroupChange(hobbitGroupChangeId)(
FakeRequest(GET, s"/groups/change/$hobbitGroupChangeId")
.withSession(
"username" -> lockedFrodoUser.userName,
"accessKey" -> lockedFrodoUser.accessKey
)
)
status(result) mustEqual 403
hasCacheHeaders(result)
contentAsString(result) must beEqualTo(
s"User account for `${lockedFrodoUser.userName}` is locked."
)
}
"return unauthorized (401) if user is not logged in" in new WithApplication(app) {
val client = mock[WSClient]
val underTest = withClient(client)
val result =
underTest.getGroupChange(hobbitGroupChangeId)(FakeRequest(GET, s"/groups/change/$hobbitGroupChangeId"))
status(result) must beEqualTo(401)
contentAsString(result) must beEqualTo("You are not logged in. Please login to continue.")
hasCacheHeaders(result)
}
}
".listGroupChanges" should {
"return group changes - status ok (200)" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/groups/${hobbitGroupId}/activity" =>
defaultActionBuilder { Results.Ok(hobbitGroupChanges) }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.listGroupChanges(hobbitGroupId)(
FakeRequest(GET, s"/groups/$hobbitGroupId/activity")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(OK)
hasCacheHeaders(result)
contentAsJson(result) must beEqualTo(hobbitGroupChanges)
}
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient]
val underTest = withClient(client)
val result =
underTest.listGroupChanges(hobbitGroupId)(
FakeRequest(GET, s"/api/groups/$hobbitGroupId/activity")
)
status(result) mustEqual 401
hasCacheHeaders(result)
contentAsString(result) must beEqualTo("You are not logged in. Please login to continue.")
}
"return forbidden (403) if user account is locked" in new WithApplication(app) {
val client = mock[WSClient]
val underTest = withLockedClient(client)
val result = underTest.listGroupChanges(hobbitGroupId)(
FakeRequest(GET, s"/api/groups/$hobbitGroupId/activity").withSession(
"username" -> lockedFrodoUser.userName,
"accessKey" -> lockedFrodoUser.accessKey
)
)
status(result) mustEqual 403
hasCacheHeaders(result)
contentAsString(result) must beEqualTo(
s"User account for `${lockedFrodoUser.userName}` is locked."
)
}
}
".deleteGroup" should { ".deleteGroup" should {
"return ok with no content (204) when delete is successful" in new WithApplication(app) { "return ok with no content (204) when delete is successful" in new WithApplication(app) {
val client = MockWS { val client = MockWS {
@ -1502,6 +1650,78 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
} }
} }
".getZoneChange" should {
"return ok (200) if the zoneChanges is found" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/$hobbitZoneId/changes" =>
defaultActionBuilder { Results.Ok(hobbitZoneChange) }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getZoneChange(hobbitZoneId)(
FakeRequest(GET, s"/zones/$hobbitZoneId/changes")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(OK)
hasCacheHeaders(result)
contentAsJson(result) must beEqualTo(hobbitZoneChange)
}
"return a not found (404) if the zoneChanges does not exist" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/not-hobbits/changes" =>
defaultActionBuilder { Results.NotFound }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getZoneChange("not-hobbits")(
FakeRequest(GET, "/zones/not-hobbits/changes")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(NOT_FOUND)
hasCacheHeaders(result)
}
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient]
val underTest = withClient(client)
val result =
underTest.getZoneChange(hobbitZoneId)(
FakeRequest(GET, s"/api/zones/$hobbitZoneId/changes")
)
status(result) mustEqual 401
hasCacheHeaders(result)
contentAsString(result) must beEqualTo("You are not logged in. Please login to continue.")
}
"return forbidden (403) if user account is locked" in new WithApplication(app) {
val client = mock[WSClient]
val underTest = withLockedClient(client)
val result = underTest.getZoneChange(hobbitZoneId)(
FakeRequest(GET, s"/api/zones/$hobbitZoneId/changes").withSession(
"username" -> lockedFrodoUser.userName,
"accessKey" -> lockedFrodoUser.accessKey
)
)
status(result) mustEqual 403
hasCacheHeaders(result)
contentAsString(result) must beEqualTo(
s"User account for `${lockedFrodoUser.userName}` is locked."
)
}
}
".getZoneByName" should { ".getZoneByName" should {
"return ok (200) if the zone is found" in new WithApplication(app) { "return ok (200) if the zone is found" in new WithApplication(app) {
val client = MockWS { val client = MockWS {

View File

@ -1 +1 @@
version in ThisBuild := "0.14.0" version in ThisBuild := "0.16.2"