2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-29 13:27:43 +00:00

Merge branch 'master' into show_recordsets_count

This commit is contained in:
Arpit Shah 2023-09-26 15:51:41 -04:00 committed by GitHub
commit fb6db99685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1691 additions and 135 deletions

View File

@ -57,6 +57,8 @@ class Route53ApiIntegrationSpec
"test", "test",
Some("access"), Some("access"),
Some("secret"), Some("secret"),
None,
None,
sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"), sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"),
"us-east-1" "us-east-1"
) )

View File

@ -158,8 +158,8 @@ final case class ListAdminsResponse(admins: Seq[UserInfo])
final case class ListGroupChangesResponse( final case class ListGroupChangesResponse(
changes: Seq[GroupChangeInfo], changes: Seq[GroupChangeInfo],
startFrom: Option[String] = None, startFrom: Option[Int] = None,
nextId: Option[String] = None, nextId: Option[Int] = None,
maxItems: Int maxItems: Int
) )

View File

@ -266,7 +266,7 @@ class MembershipService(
def getGroupActivity( def getGroupActivity(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[Int],
maxItems: Int, maxItems: Int,
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse] = ): Result[ListGroupChangesResponse] =
@ -285,7 +285,7 @@ class MembershipService(
} yield ListGroupChangesResponse( } yield ListGroupChangesResponse(
groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))), groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))),
startFrom, startFrom,
result.lastEvaluatedTimeStamp, result.nextId,
maxItems maxItems
) )

View File

@ -63,7 +63,7 @@ trait MembershipServiceAlgebra {
def getGroupActivity( def getGroupActivity(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[Int],
maxItems: Int, maxItems: Int,
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse] ): Result[ListGroupChangesResponse]

View File

@ -38,6 +38,15 @@ object ListZoneChangesResponse {
) )
} }
case class ListDeletedZoneChangesResponse(
zonesDeletedInfo: List[ZoneChangeDeletedInfo],
zoneChangeFilter: Option[String] = None,
nextId: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
)
case class ListFailedZoneChangesResponse( case class ListFailedZoneChangesResponse(
failedZoneChanges: List[ZoneChange] = Nil, failedZoneChanges: List[ZoneChange] = Nil,
nextId: Int, nextId: Int,

View File

@ -22,7 +22,7 @@ import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType
import vinyldns.core.domain.record.RecordSetStatus.RecordSetStatus import vinyldns.core.domain.record.RecordSetStatus.RecordSetStatus
import vinyldns.core.domain.record.RecordType.RecordType import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record.{RecordData, RecordSet, RecordSetChange} import vinyldns.core.domain.record.{RecordData, RecordSet, RecordSetChange}
import vinyldns.core.domain.zone.{ACLRuleInfo, AccessLevel, Zone, ZoneACL, ZoneConnection} import vinyldns.core.domain.zone.{ACLRuleInfo, AccessLevel, Zone, ZoneACL, ZoneChange, ZoneConnection}
import vinyldns.core.domain.zone.AccessLevel.AccessLevel import vinyldns.core.domain.zone.AccessLevel.AccessLevel
import vinyldns.core.domain.zone.ZoneStatus.ZoneStatus import vinyldns.core.domain.zone.ZoneStatus.ZoneStatus
@ -145,6 +145,27 @@ object ZoneSummaryInfo {
) )
} }
case class ZoneChangeDeletedInfo(
zoneChange: ZoneChange,
adminGroupName: String,
userName: String,
accessLevel: AccessLevel
)
object ZoneChangeDeletedInfo {
def apply(zoneChange: List[ZoneChange],
groupName: String,
userName: String,
accessLevel: AccessLevel)
: ZoneChangeDeletedInfo =
ZoneChangeDeletedInfo(
zoneChange= zoneChange,
groupName = groupName,
userName = userName,
accessLevel = accessLevel
)
}
case class RecordSetListInfo( case class RecordSetListInfo(
zoneId: String, zoneId: String,
name: String, name: String,

View File

@ -23,7 +23,7 @@ import vinyldns.api.Interfaces
import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.api.repository.ApiDataAccessor import vinyldns.api.repository.ApiDataAccessor
import vinyldns.core.crypto.CryptoAlgebra import vinyldns.core.crypto.CryptoAlgebra
import vinyldns.core.domain.membership.{Group, GroupRepository, User, UserRepository} import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository}
import vinyldns.core.domain.zone._ import vinyldns.core.domain.zone._
import vinyldns.core.queue.MessageQueue import vinyldns.core.queue.MessageQueue
import vinyldns.core.domain.DomainHelpers.ensureTrailingDot import vinyldns.core.domain.DomainHelpers.ensureTrailingDot
@ -226,6 +226,58 @@ class ZoneService(
} }
}.toResult }.toResult
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): Result[ListDeletedZoneChangesResponse] = {
for {
listZonesChangeResult <- zoneChangeRepository.listDeletedZones(
authPrincipal,
nameFilter,
startFrom,
maxItems,
ignoreAccess
)
zoneChanges = listZonesChangeResult.zoneDeleted
groupIds = zoneChanges.map(_.zone.adminGroupId).toSet
groups <- groupRepository.getGroups(groupIds)
userId = zoneChanges.map(_.userId).toSet
users <- userRepository.getUsers(userId,None,None)
zoneDeleteSummaryInfos = ZoneChangeDeletedInfoMapping(zoneChanges, authPrincipal, groups, users)
} yield {
ListDeletedZoneChangesResponse(
zoneDeleteSummaryInfos,
listZonesChangeResult.zoneChangeFilter,
listZonesChangeResult.nextId,
listZonesChangeResult.startFrom,
listZonesChangeResult.maxItems,
listZonesChangeResult.ignoreAccess
)
}
}.toResult
private def ZoneChangeDeletedInfoMapping(
zoneChange: List[ZoneChange],
auth: AuthPrincipal,
groups: Set[Group],
users: ListUsersResults
): List[ZoneChangeDeletedInfo] =
zoneChange.map { zc =>
val groupName = groups.find(_.id == zc.zone.adminGroupId) match {
case Some(group) => group.name
case None => "Unknown group name"
}
val userName = users.users.find(_.id == zc.userId) match {
case Some(user) => user.userName
case None => "Unknown user name"
}
val zoneAccess = getZoneAccess(auth, zc.zone)
ZoneChangeDeletedInfo(zc, groupName,userName, zoneAccess)
}
def zoneSummaryInfoMapping( def zoneSummaryInfoMapping(
zones: List[Zone], zones: List[Zone],
auth: AuthPrincipal, auth: AuthPrincipal,

View File

@ -49,6 +49,14 @@ trait ZoneServiceAlgebra {
includeReverse: Boolean includeReverse: Boolean
): Result[ListZonesResponse] ): Result[ListZonesResponse]
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
): Result[ListDeletedZoneChangesResponse]
def listZoneChanges( def listZoneChanges(
zoneId: String, zoneId: String,
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,

View File

@ -162,8 +162,8 @@ class MembershipRoute(
} ~ } ~
path("groups" / Segment / "activity") { groupId => path("groups" / Segment / "activity") { groupId =>
(get & monitor("Endpoint.groupActivity")) { (get & monitor("Endpoint.groupActivity")) {
parameters("startFrom".?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[String], maxItems: Int) => (startFrom: Option[Int], maxItems: Int) =>
handleRejections(invalidQueryHandler) { handleRejections(invalidQueryHandler) {
validate( validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT, 0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,

View File

@ -249,33 +249,6 @@ class RecordSetRoute(
} }
} }
} ~ } ~
path("recordsetchange" / "history") {
(get & monitor("Endpoint.listRecordSetChangeHistory")) {
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) {
(startFrom: Option[Int], maxItems: Int, fqdn: Option[String], recordType: Option[String]) =>
handleRejections(invalidQueryHandler) {
val errorMessage = if(fqdn.isEmpty || recordType.isEmpty) {
"recordType and fqdn cannot be empty"
} else {
s"maxItems was $maxItems, maxItems must be between 0 exclusive " +
s"and $DEFAULT_MAX_ITEMS inclusive"
}
val isValid = (0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS) && (fqdn.nonEmpty && recordType.nonEmpty)
validate(
check = isValid,
errorMsg = errorMessage
){
authenticateAndExecute(
recordSetService
.listRecordSetChangeHistory(None, startFrom, maxItems, fqdn, RecordType.find(recordType.get), _)
) { changes =>
complete(StatusCodes.OK, changes)
}
}
}
}
}
} ~
path("metrics" / "health" / "zones" / Segment / "recordsetchangesfailure") {zoneId => path("metrics" / "health" / "zones" / Segment / "recordsetchangesfailure") {zoneId =>
(get & monitor("Endpoint.listFailedRecordSetChanges")) { (get & monitor("Endpoint.listFailedRecordSetChanges")) {
parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {

View File

@ -111,6 +111,38 @@ class ZoneRoute(
} }
} }
} ~ } ~
path("zones" / "deleted" / "changes") {
(get & monitor("Endpoint.listDeletedZones")) {
parameters(
"nameFilter".?,
"startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"ignoreAccess".as[Boolean].?(false)
) {
(
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
) =>
{
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,
s"maxItems was $maxItems, maxItems must be between 0 and $MAX_ITEMS_LIMIT"
) {
authenticateAndExecute(
zoneService
.listDeletedZones(_, nameFilter, startFrom, maxItems, ignoreAccess)
) { result =>
complete(StatusCodes.OK, result)
}
}
}
}
}
}
} ~
path("zones" / "backendids") { path("zones" / "backendids") {
(get & monitor("Endpoint.getBackendIds")) { (get & monitor("Endpoint.getBackendIds")) {
authenticateAndExecute(_ => zoneService.getBackendIds()) { ids => authenticateAndExecute(_ => zoneService.getBackendIds()) { ids =>

View File

@ -26,12 +26,6 @@ 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
# using epoch time to start from a timestamp
epoch = datetime.utcfromtimestamp(0)
# start from a known good timestamp
start_from = str(int((datetime.strptime(page_one["changes"][start_from_index]["created"], "%Y-%m-%dT%H:%M:%S.%fZ") - epoch).total_seconds() * 1000))
# now, we say give me all changes since the start_from, which should yield 8-7-6-5-4 # 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=page_one["nextId"], max_items=5, status=200) result = client.get_group_changes(created_group["id"], start_from=page_one["nextId"], max_items=5, status=200)
@ -50,26 +44,19 @@ def test_list_group_activity_start_from_success(group_activity_context, shared_z
assert_that(result["changes"][i]["oldGroup"], is_(updated_groups[expected_start - i - 1])) assert_that(result["changes"][i]["oldGroup"], is_(updated_groups[expected_start - i - 1]))
def test_list_group_activity_start_from_fake_time(group_activity_context, shared_zone_test_context): def test_list_group_activity_start_from_random_number(group_activity_context, shared_zone_test_context):
""" """
Test that we can start from a fake time stamp Test that we can start from a random number, but it returns no changes
""" """
client = shared_zone_test_context.ok_vinyldns_client client = shared_zone_test_context.ok_vinyldns_client
created_group = group_activity_context["created_group"] created_group = group_activity_context["created_group"]
updated_groups = group_activity_context["updated_groups"] updated_groups = group_activity_context["updated_groups"]
start_from = "9999999999999" # start from a random timestamp far in the future
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=999, max_items=5, status=200)
# there are 10 updates, proceeded by 1 create # there are 10 updates, proceeded by 1 create
assert_that(result["changes"], has_length(5)) assert_that(result["changes"], has_length(0))
assert_that(result["maxItems"], is_(5)) assert_that(result["maxItems"], is_(5))
assert_that(result["startFrom"], is_(start_from))
assert_that(result["nextId"], is_not(none()))
for i in range(0, 5):
assert_that(result["changes"][i]["newGroup"], is_(updated_groups[9 - i]))
assert_that(result["changes"][i]["oldGroup"], is_(updated_groups[9 - i - 1]))
def test_list_group_activity_max_item_success(group_activity_context, shared_zone_test_context): def test_list_group_activity_max_item_success(group_activity_context, shared_zone_test_context):

View File

@ -1013,11 +1013,11 @@ class MembershipServiceSpec
"return the group activity" in { "return the group activity" in {
val groupChangeRepoResponse = ListGroupChangesResults( val groupChangeRepoResponse = ListGroupChangesResults(
listOfDummyGroupChanges.take(100), listOfDummyGroupChanges.take(100),
Some(listOfDummyGroupChanges(100).id) Some(listOfDummyGroupChanges.size)
) )
doReturn(IO.pure(groupChangeRepoResponse)) doReturn(IO.pure(groupChangeRepoResponse))
.when(mockGroupChangeRepo) .when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt) .getGroupChanges(anyString, any[Option[Int]], anyInt)
doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1"))))
.when(mockUserRepo) .when(mockUserRepo)
@ -1031,18 +1031,18 @@ class MembershipServiceSpec
underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value.unsafeRunSync().toOption.get underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value.unsafeRunSync().toOption.get
result.changes should contain theSameElementsAs expected result.changes should contain theSameElementsAs expected
result.maxItems shouldBe 100 result.maxItems shouldBe 100
result.nextId shouldBe Some(listOfDummyGroupChanges(100).id) result.nextId shouldBe Some(listOfDummyGroupChanges.size)
result.startFrom shouldBe None result.startFrom shouldBe None
} }
"return group activity even if the user is not authorized" in { "return group activity even if the user is not authorized" in {
val groupChangeRepoResponse = ListGroupChangesResults( val groupChangeRepoResponse = ListGroupChangesResults(
listOfDummyGroupChanges.take(100), listOfDummyGroupChanges.take(100),
Some(listOfDummyGroupChanges(100).id) Some(listOfDummyGroupChanges.size)
) )
doReturn(IO.pure(groupChangeRepoResponse)) doReturn(IO.pure(groupChangeRepoResponse))
.when(mockGroupChangeRepo) .when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt) .getGroupChanges(anyString, any[Option[Int]], anyInt)
doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1"))))
.when(mockUserRepo) .when(mockUserRepo)
@ -1056,7 +1056,7 @@ class MembershipServiceSpec
underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value.unsafeRunSync().toOption.get underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value.unsafeRunSync().toOption.get
result.changes should contain theSameElementsAs expected result.changes should contain theSameElementsAs expected
result.maxItems shouldBe 100 result.maxItems shouldBe 100
result.nextId shouldBe Some(listOfDummyGroupChanges(100).id) result.nextId shouldBe Some(listOfDummyGroupChanges.size)
result.startFrom shouldBe None result.startFrom shouldBe None
} }
} }

View File

@ -57,6 +57,8 @@ class ZoneServiceSpec
private val badConnection = ZoneConnection("bad", "bad", Encrypted("bad"), "bad") private val badConnection = ZoneConnection("bad", "bad", Encrypted("bad"), "bad")
private val abcZoneSummary = ZoneSummaryInfo(abcZone, abcGroup.name, AccessLevel.Delete) private val abcZoneSummary = ZoneSummaryInfo(abcZone, abcGroup.name, AccessLevel.Delete)
private val xyzZoneSummary = ZoneSummaryInfo(xyzZone, xyzGroup.name, AccessLevel.NoAccess) private val xyzZoneSummary = ZoneSummaryInfo(xyzZone, xyzGroup.name, AccessLevel.NoAccess)
private val abcDeletedZoneSummary = ZoneChangeDeletedInfo(abcDeletedZoneChange, abcGroup.name, okUser.userName, AccessLevel.Delete)
private val xyzDeletedZoneSummary = ZoneChangeDeletedInfo(xyzDeletedZoneChange, xyzGroup.name, okUser.userName, AccessLevel.NoAccess)
private val zoneIp4ZoneSummary = ZoneSummaryInfo(zoneIp4, abcGroup.name, AccessLevel.Delete) private val zoneIp4ZoneSummary = ZoneSummaryInfo(zoneIp4, abcGroup.name, AccessLevel.Delete)
private val zoneIp6ZoneSummary = ZoneSummaryInfo(zoneIp6, abcGroup.name, AccessLevel.Delete) private val zoneIp6ZoneSummary = ZoneSummaryInfo(zoneIp6, abcGroup.name, AccessLevel.Delete)
private val mockMembershipRepo = mock[MembershipRepository] private val mockMembershipRepo = mock[MembershipRepository]
@ -1084,6 +1086,195 @@ class ZoneServiceSpec
} }
} }
"ListDeletedZones" should {
"not fail with no zones returned" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List())))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(abcGroup))).when(mockGroupRepo).getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List()
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe false
}
"return the appropriate zones" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange))))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(abcGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary)
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe false
}
"return all zones" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange, xyzDeletedZoneChange), ignoreAccess = true)))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, true)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, ignoreAccess = true).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary,xyzDeletedZoneSummary)
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe true
}
"return Unknown group name if zone admin group cannot be found" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange, xyzDeletedZoneChange))))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(okGroup))).when(mockGroupRepo).getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
val expectedZones =
List(abcDeletedZoneSummary, xyzDeletedZoneSummary).map(_.copy(adminGroupName = "Unknown group name"))
result.zonesDeletedInfo shouldBe expectedZones
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
}
"set the nextId appropriately" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
maxItems = 2,
nextId = Some("zone2."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.maxItems shouldBe 2
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe Some("zone2.")
}
"set the nameFilter when provided" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
zoneChangeFilter = Some("foo"),
maxItems = 2,
nextId = Some("zone2."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, Some("foo"), None, 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, nameFilter = Some("foo"), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.zoneChangeFilter shouldBe Some("foo")
result.nextId shouldBe Some("zone2.")
result.maxItems shouldBe 2
}
"set the startFrom when provided" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
startFrom = Some("zone4."),
maxItems = 2,
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, Some("zone4."), 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, startFrom = Some("zone4."), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.startFrom shouldBe Some("zone4.")
}
"set the nextId to be the current result set size plus the start from" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
startFrom = Some("zone4."),
maxItems = 2,
nextId = Some("zone6."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, Some("zone4."), 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, startFrom = Some("zone4."), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.nextId shouldBe Some("zone6.")
}
}
"listZoneChanges" should { "listZoneChanges" should {
"retrieve the zone changes" in { "retrieve the zone changes" in {
doReturn(IO.pure(Some(okZone))) doReturn(IO.pure(Some(okZone)))

View File

@ -705,14 +705,14 @@ class MembershipRoutingSpec
) )
doReturn(result(expected)) doReturn(result(expected))
.when(membershipService) .when(membershipService)
.getGroupActivity(anyString, any[Option[String]], anyInt, any[AuthPrincipal]) .getGroupActivity(anyString, any[Option[Int]], anyInt, any[AuthPrincipal])
Get(s"/groups/pageSize/activity") ~> Route.seal(membershipRoute) ~> check { Get(s"/groups/pageSize/activity") ~> Route.seal(membershipRoute) ~> check {
status shouldBe StatusCodes.OK status shouldBe StatusCodes.OK
val maxItemsCaptor = ArgumentCaptor.forClass(classOf[Int]) val maxItemsCaptor = ArgumentCaptor.forClass(classOf[Int])
verify(membershipService).getGroupActivity( verify(membershipService).getGroupActivity(
anyString, anyString,
any[Option[String]], any[Option[Int]],
maxItemsCaptor.capture(), maxItemsCaptor.capture(),
any[AuthPrincipal] any[AuthPrincipal]
) )

View File

@ -117,6 +117,26 @@ class ZoneRoutingSpec
Zone("zone6.in-addr.arpa.", "zone6@test.com", ZoneStatus.Active, adminGroupId = xyzGroup.id) Zone("zone6.in-addr.arpa.", "zone6@test.com", ZoneStatus.Active, adminGroupId = xyzGroup.id)
private val zoneSummaryInfo6 = ZoneSummaryInfo(zone6, xyzGroup.name, AccessLevel.NoAccess) private val zoneSummaryInfo6 = ZoneSummaryInfo(zone6, xyzGroup.name, AccessLevel.NoAccess)
private val error = Zone("error.", "error@test.com") private val error = Zone("error.", "error@test.com")
private val deletedZone1 = Zone("ok1.", "ok1@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange1 = ZoneChange(deletedZone1, "ok1", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo1 = ZoneChangeDeletedInfo(
deletedZoneChange1, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone2 = Zone("ok2.", "ok2@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange2 = ZoneChange(deletedZone2, "ok2", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo2 = ZoneChangeDeletedInfo(
deletedZoneChange2, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone3 = Zone("ok3.", "ok3@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange3 = ZoneChange(deletedZone3, "ok3", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo3= ZoneChangeDeletedInfo(
deletedZoneChange3, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone4 = Zone("ok4.", "ok4@test.com", ZoneStatus.Deleted , acl = zoneAcl, adminGroupId = xyzGroup.id)
private val deletedZoneChange4 = ZoneChange(deletedZone4, "ok4", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo4 = ZoneChangeDeletedInfo(
deletedZoneChange4, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone5 = Zone("ok5.", "ok5@test.com", ZoneStatus.Deleted , acl = zoneAcl, adminGroupId = xyzGroup.id)
private val deletedZoneChange5 = ZoneChange(deletedZone5, "ok5", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo5 = ZoneChangeDeletedInfo(
deletedZoneChange5, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val missingFields: JValue = private val missingFields: JValue =
("invalidField" -> "randomValue") ~~ ("invalidField" -> "randomValue") ~~
@ -395,6 +415,92 @@ class ZoneRoutingSpec
outcome.toResult outcome.toResult
} }
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean = false
): Result[ListDeletedZoneChangesResponse] = {
val outcome = (authPrincipal, nameFilter, startFrom, maxItems, ignoreAccess) match {
case (_, None, Some("zone3."), 3, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = Some("zone3."),
nextId = Some("zone6."),
maxItems = 3,
ignoreAccess = false
)
)
case (_, None, Some("zone4."), 4, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = Some("zone4."),
nextId = None,
maxItems = 4,
ignoreAccess = false
)
)
case (_, None, None, 3, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = None,
nextId = Some("zone3."),
maxItems = 3,
ignoreAccess = false
)
)
case (_, None, None, 5, true) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo =
List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3, ZoneChangeDeletedInfo4,ZoneChangeDeletedInfo5),
zoneChangeFilter = None,
startFrom = None,
nextId = None,
maxItems = 5,
ignoreAccess = true
)
)
case (_, Some(filter), Some("zone4."), 4, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = Some(filter),
startFrom = Some("zone4."),
nextId = None,
maxItems = 4,
ignoreAccess = false
)
)
case (_, None, None, _, _) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = None,
nextId = None,
ignoreAccess = false
)
)
case _ => Left(InvalidRequest("shouldnt get here"))
}
outcome.toResult
}
def listZoneChanges( def listZoneChanges(
zoneId: String, zoneId: String,
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,
@ -1078,6 +1184,83 @@ class ZoneRoutingSpec
} }
} }
"GET Deleted zones" should {
"return the next id when more results exist" in {
Get(s"/zones/deleted/changes?startFrom=zone3.&maxItems=3") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe Some("zone6.")
resp.maxItems shouldBe 3
resp.startFrom shouldBe Some("zone3.")
}
}
"not return the next id when there are no more results" in {
Get(s"/zones/deleted/changes?startFrom=zone4.&maxItems=4") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 4
resp.startFrom shouldBe Some("zone4.")
resp.ignoreAccess shouldBe false
}
}
"not return the start from when not provided" in {
Get(s"/zones/deleted/changes?maxItems=3") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe Some("zone3.")
resp.maxItems shouldBe 3
resp.startFrom shouldBe None
resp.ignoreAccess shouldBe false
}
}
"return the name filter when provided" in {
Get(s"/zones/deleted/changes?nameFilter=foo&startFrom=zone4.&maxItems=4") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 4
resp.startFrom shouldBe Some("zone4.")
resp.zoneChangeFilter shouldBe Some("foo")
resp.ignoreAccess shouldBe false
}
}
"return all zones when list all is true" in {
Get(s"/zones/deleted/changes?maxItems=5&ignoreAccess=true") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id, deletedZone4.id, deletedZone5.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 5
resp.startFrom shouldBe None
resp.zoneChangeFilter shouldBe None
resp.ignoreAccess shouldBe true
}
}
"return an error if the max items is out of range" in {
Get(s"/zones/deleted/changes?maxItems=700") ~> zoneRoute ~> check {
status shouldBe BadRequest
responseEntity.toString should include(
"maxItems was 700, maxItems must be between 0 and 100"
)
}
}
}
"GET zone changes" should { "GET zone changes" should {
"return the zone changes" in { "return the zone changes" in {
Get(s"/zones/${ok.id}/changes") ~> zoneRoute ~> check { Get(s"/zones/${ok.id}/changes") ~> zoneRoute ~> check {

View File

@ -28,7 +28,7 @@ trait GroupChangeRepository extends Repository {
def getGroupChanges( def getGroupChanges(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[Int],
maxItems: Int maxItems: Int
): IO[ListGroupChangesResults] ): IO[ListGroupChangesResults]

View File

@ -18,5 +18,5 @@ package vinyldns.core.domain.membership
final case class ListGroupChangesResults( final case class ListGroupChangesResults(
changes: Seq[GroupChange], changes: Seq[GroupChange],
lastEvaluatedTimeStamp: Option[String] nextId: Option[Int]
) )

View File

@ -23,6 +23,15 @@ case class ListZoneChangesResults(
maxItems: Int = 100 maxItems: Int = 100
) )
case class ListDeletedZonesChangeResults(
zoneDeleted: List[ZoneChange] = List[ZoneChange](),
nextId: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false,
zoneChangeFilter: Option[String] = None
)
case class ListFailedZoneChangesResults( case class ListFailedZoneChangesResults(
items: List[ZoneChange] = List[ZoneChange](), items: List[ZoneChange] = List[ZoneChange](),
nextId: Int = 0, nextId: Int = 0,

View File

@ -17,6 +17,7 @@
package vinyldns.core.domain.zone package vinyldns.core.domain.zone
import cats.effect._ import cats.effect._
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.repository.Repository import vinyldns.core.repository.Repository
trait ZoneChangeRepository extends Repository { trait ZoneChangeRepository extends Repository {
@ -29,6 +30,14 @@ trait ZoneChangeRepository extends Repository {
maxItems: Int = 100 maxItems: Int = 100
): IO[ListZoneChangesResults] ): IO[ListZoneChangesResults]
def listDeletedZones(
authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): IO[ListDeletedZonesChangeResults]
def listFailedZoneChanges( def listFailedZoneChanges(
maxItems: Int = 100, maxItems: Int = 100,
startFrom: Int= 0 startFrom: Int= 0

View File

@ -52,9 +52,21 @@ object TestZoneData {
connection = testConnection connection = testConnection
) )
val abcZoneDeleted: Zone = Zone("abc.zone.recordsets.", "test@test.com", adminGroupId = abcGroup.id, status = ZoneStatus.Deleted)
val xyzZoneDeleted: Zone = Zone("xyz.zone.recordsets.", "abc@xyz.com", adminGroupId = xyzGroup.id, status = ZoneStatus.Deleted)
val zoneDeleted: Zone = Zone( val zoneDeleted: Zone = Zone(
"some.deleted.zone.", "some.deleted.zone.",
"test@test.com", "test@test.com",
adminGroupId = abcGroup.id,
status = ZoneStatus.Deleted,
connection = testConnection
)
val zoneDeletedOkGroup: Zone = Zone(
"some.deleted.zone.",
"test@test.com",
adminGroupId = okGroup.id,
status = ZoneStatus.Deleted, status = ZoneStatus.Deleted,
connection = testConnection connection = testConnection
) )
@ -89,6 +101,22 @@ object TestZoneData {
val zoneUpdate: ZoneChange = zoneChangePending.copy(status = ZoneChangeStatus.Synced) val zoneUpdate: ZoneChange = zoneChangePending.copy(status = ZoneChangeStatus.Synced)
val abcDeletedZoneChange: ZoneChange = ZoneChange(
abcZoneDeleted,
"ok",
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)
val xyzDeletedZoneChange: ZoneChange = ZoneChange(
xyzZoneDeleted,
"ok",
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)
def makeTestPendingZoneChange(zone: Zone): ZoneChange = def makeTestPendingZoneChange(zone: Zone): ZoneChange =
ZoneChange(zone, "userId", ZoneChangeType.Update, ZoneChangeStatus.Pending) ZoneChange(zone, "userId", ZoneChangeType.Update, ZoneChangeStatus.Pending)

View File

@ -102,7 +102,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
} }
"MySqlGroupChangeRepository.getGroupChanges" should { "MySqlGroupChangeRepository.getGroupChanges" should {
"don't return lastEvaluatedTimeStamp if page size < maxItems" in { "don't return nextId if page size < maxItems" in {
val groupId = "group-id-1" val groupId = "group-id-1"
val changes = generateGroupChanges(groupId, 50) val changes = generateGroupChanges(groupId, 50)
changes.map(saveGroupChangeData(repo, _).unsafeRunSync()) changes.map(saveGroupChangeData(repo, _).unsafeRunSync())
@ -113,7 +113,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
val listResponse = repo.getGroupChanges(groupId, None, 100).unsafeRunSync() val listResponse = repo.getGroupChanges(groupId, None, 100).unsafeRunSync()
listResponse.changes shouldBe expectedChanges listResponse.changes shouldBe expectedChanges
listResponse.lastEvaluatedTimeStamp shouldBe None listResponse.nextId shouldBe None
} }
"get group changes properly using a maxItems of 1" in { "get group changes properly using a maxItems of 1" in {
@ -130,9 +130,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
val listResponse = val listResponse =
repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync() repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync()
listResponse.changes shouldBe expectedChanges listResponse.changes shouldBe expectedChanges
listResponse.lastEvaluatedTimeStamp shouldBe Some( listResponse.nextId shouldBe Some(1)
expectedChanges.head.created.toEpochMilli.toString
)
} }
"page group changes using a startFrom and maxItems" in { "page group changes using a startFrom and maxItems" in {
@ -145,33 +143,33 @@ class MySqlGroupChangeRepositoryIntegrationSpec
.reverse .reverse
val expectedPageOne = Seq(changesSorted(0)) val expectedPageOne = Seq(changesSorted(0))
val expectedPageOneNext = Some(changesSorted(0).created.toEpochMilli.toString) val expectedPageOneNext = Some(1)
val expectedPageTwo = Seq(changesSorted(1)) val expectedPageTwo = Seq(changesSorted(1))
val expectedPageTwoNext = Some(changesSorted(1).created.toEpochMilli.toString) val expectedPageTwoNext = Some(2)
val expectedPageThree = Seq(changesSorted(2)) val expectedPageThree = Seq(changesSorted(2))
val expectedPageThreeNext = Some(changesSorted(2).created.toEpochMilli.toString) val expectedPageThreeNext = Some(3)
// get first page // get first page
val pageOne = val pageOne =
repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync() repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync()
pageOne.changes shouldBe expectedPageOne pageOne.changes shouldBe expectedPageOne
pageOne.lastEvaluatedTimeStamp shouldBe expectedPageOneNext pageOne.nextId shouldBe expectedPageOneNext
// get second page // get second page
val pageTwo = val pageTwo =
repo repo
.getGroupChanges(groupId, startFrom = pageOne.lastEvaluatedTimeStamp, maxItems = 1) .getGroupChanges(groupId, startFrom = pageOne.nextId, maxItems = 1)
.unsafeRunSync() .unsafeRunSync()
pageTwo.changes shouldBe expectedPageTwo pageTwo.changes shouldBe expectedPageTwo
pageTwo.lastEvaluatedTimeStamp shouldBe expectedPageTwoNext pageTwo.nextId shouldBe expectedPageTwoNext
// get final page // get final page
val pageThree = val pageThree =
repo repo
.getGroupChanges(groupId, startFrom = pageTwo.lastEvaluatedTimeStamp, maxItems = 1) .getGroupChanges(groupId, startFrom = pageTwo.nextId, maxItems = 1)
.unsafeRunSync() .unsafeRunSync()
pageThree.changes shouldBe expectedPageThree pageThree.changes shouldBe expectedPageThree
pageThree.lastEvaluatedTimeStamp shouldBe expectedPageThreeNext pageThree.nextId shouldBe expectedPageThreeNext
} }
} }
} }

View File

@ -33,6 +33,9 @@ import vinyldns.core.TestZoneData.okZone
import vinyldns.core.TestZoneData.testConnection import vinyldns.core.TestZoneData.testConnection
import vinyldns.core.domain.Encrypted import vinyldns.core.domain.Encrypted
import vinyldns.mysql.TestMySqlInstance import vinyldns.mysql.TestMySqlInstance
import vinyldns.core.TestZoneData.{okZone, testConnection}
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.TestMembershipData.{dummyAuth, dummyGroup, okGroup, okUser}
import scala.concurrent.duration._ import scala.concurrent.duration._
import scala.util.Random import scala.util.Random
@ -92,6 +95,7 @@ class MySqlZoneChangeRepositoryIntegrationSpec
status= ZoneChangeStatus.Failed, status= ZoneChangeStatus.Failed,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000)) created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000))
) )
val successChanges val successChanges
: IndexedSeq[ZoneChange] = for { zone <- zones } yield ZoneChange( : IndexedSeq[ZoneChange] = for { zone <- zones } yield ZoneChange(
zone, zone,
@ -100,6 +104,84 @@ class MySqlZoneChangeRepositoryIntegrationSpec
status= ZoneChangeStatus.Synced, status= ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000)) created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000))
) )
val groups = (11 until 20)
.map(num => okGroup.copy(name = num.toString, id = UUID.randomUUID().toString))
.toList
// generate some ACLs
private val groupAclRules = groups.map(
g =>
ACLRule(
accessLevel = AccessLevel.Read,
groupId = Some(g.id)
)
)
private val userOnlyAclRule =
ACLRule(
accessLevel = AccessLevel.Read,
userId = Some(okUser.id)
)
// the zone acl rule will have the user rule and all of the group rules
private val testZoneAcl = ZoneACL(
rules = Set(userOnlyAclRule) ++ groupAclRules
)
private val testZoneAdminGroupId = "foo"
val dummyAclRule =
ACLRule(
accessLevel = AccessLevel.Read,
groupId = Some(dummyGroup.id)
)
val testZone = (11 until 20).map { num =>
val z =
okZone.copy(
name = num.toString + ".",
id = UUID.randomUUID().toString,
adminGroupId = testZoneAdminGroupId,
acl = testZoneAcl
)
// add the dummy acl rule to the first zone
if (num == 11) z.addACLRule(dummyAclRule) else z
}
val deletedZoneChanges
: IndexedSeq[ZoneChange] = for { testZone <- testZone } yield {
ZoneChange(
testZone.copy(status = ZoneStatus.Deleted),
testZone.account,
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)}
def saveZones(zones: Seq[Zone]): IO[Unit] =
zones.foldLeft(IO.unit) {
case (acc, z) =>
acc.flatMap { _ =>
zoneRepo.save(z).map(_ => ())
}
}
def deleteZones(zones: Seq[Zone]): IO[Unit] =
zones.foldLeft(IO.unit) {
case (acc, z) =>
acc.flatMap { _ =>
zoneRepo.deleteTx(z).map(_ => ())
}
}
def saveZoneChanges(zoneChanges: Seq[ZoneChange]): IO[Unit] =
zoneChanges.foldLeft(IO.unit) {
case (acc, zc) =>
acc.flatMap { _ =>
repo.save(zc).map(_ => ())
}
}
} }
import TestData._ import TestData._
@ -303,5 +385,145 @@ class MySqlZoneChangeRepositoryIntegrationSpec
pageThree.nextId should equal(None) pageThree.nextId should equal(None)
pageThree.startFrom should equal(pageTwo.nextId) pageThree.startFrom should equal(pageTwo.nextId)
} }
"get authorized zones" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// 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)
)
repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain theSameElementsAs deletedZoneChanges
// dummy user only has access to one zone
(repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted should contain).only(deletedZoneChanges.head)
}
"page deleted zones using a startFrom and maxItems" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// 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)
)
val listDeletedZones = repo.listDeletedZones(okUserAuth).unsafeRunSync()
val expectedPageOne = List(listDeletedZones.zoneDeleted(0))
val expectedPageOneNext = Some(listDeletedZones.zoneDeleted(1).zone.id)
val expectedPageTwo = List(listDeletedZones.zoneDeleted(1))
val expectedPageTwoNext = Some(listDeletedZones.zoneDeleted(2).zone.id)
val expectedPageThree = List(listDeletedZones.zoneDeleted(2))
val expectedPageThreeNext = Some(listDeletedZones.zoneDeleted(3).zone.id)
// get first page
val pageOne = repo.listDeletedZones(okUserAuth,startFrom = None, maxItems = 1 ).unsafeRunSync()
pageOne.zoneDeleted.size should equal(1)
pageOne.zoneDeleted should equal(expectedPageOne)
pageOne.nextId should equal(expectedPageOneNext)
pageOne.startFrom should equal(None)
// get second page
val pageTwo =
repo.listDeletedZones(okUserAuth, startFrom = pageOne.nextId, maxItems = 1).unsafeRunSync()
pageTwo.zoneDeleted.size should equal(1)
pageTwo.zoneDeleted should equal(expectedPageTwo)
pageTwo.nextId should equal(expectedPageTwoNext)
pageTwo.startFrom should equal(pageOne.nextId)
// get final page
// next id should be none now
val pageThree =
repo.listDeletedZones(okUserAuth, startFrom = pageTwo.nextId, maxItems = 1).unsafeRunSync()
pageThree.zoneDeleted.size should equal(1)
pageThree.zoneDeleted should equal(expectedPageThree)
pageThree.nextId should equal(expectedPageThreeNext)
pageThree.startFrom should equal(pageTwo.nextId)
}
"return empty in deleted zone if zone is created again" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// 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)
)
repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain theSameElementsAs List()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
}
"return an empty list of zones if the user is not authorized to any" in {
val unauthorized = AuthPrincipal(
signedInUser = User("not-authorized", "not-authorized", Encrypted("not-authorized")),
memberGroupIds = Seq.empty
)
val f =
for {
_ <- saveZones(testZone)
_ <- deleteZones(testZone)
_ <- saveZoneChanges(deletedZoneChanges)
zones <- repo.listDeletedZones(unauthorized)
} yield zones
f.unsafeRunSync().zoneDeleted shouldBe empty
deleteZones(testZone).unsafeRunSync()
}
"not return zones when access is revoked" in {
// ok user can access both zones, dummy can only access first zone
val zones = testZone.take(2)
val zoneChange = deletedZoneChanges.take(2)
val addACL = saveZones(zones)
val deleteZone= deleteZones(zones)
val addACLZc = saveZoneChanges(zoneChange)
val okUserAuth = AuthPrincipal(
signedInUser = okUser,
memberGroupIds = groups.map(_.id)
)
addACL.unsafeRunSync()
deleteZone.unsafeRunSync()
addACLZc.unsafeRunSync()
(repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain). allElementsOf(zoneChange)
// dummy user only has access to first zone
(repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted should contain).only(zoneChange.head)
// revoke the access for the dummy user
val revoked = zones(0).deleteACLRule(dummyAclRule)
val revokedZc = zoneChange(0).copy(zone=revoked)
zoneRepo.save(revoked).unsafeRunSync()
repo.save(revokedZc).unsafeRunSync()
// ok user can still access zones
(repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain).allElementsOf(Seq( zoneChange(1)))
// dummy user can not access the revoked zone
repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted shouldBe empty
}
} }
} }

View File

@ -952,12 +952,13 @@ class MySqlZoneRepositoryIntegrationSpec
"check if an id has an ACL rule for at least one of the zones" in { "check if an id has an ACL rule for at least one of the zones" in {
val zoneId = UUID.randomUUID().toString val zoneId = UUID.randomUUID().toString
val adminId = UUID.randomUUID().toString
val testZones = (1 until 3).map { num => val testZones = (1 until 3).map { num =>
okZone.copy( okZone.copy(
name = num.toString + ".", name = num.toString + ".",
id = zoneId, id = zoneId,
adminGroupId = testZoneAdminGroupId, adminGroupId = adminId,
acl = testZoneAcl acl = testZoneAcl
) )
} }
@ -965,7 +966,7 @@ class MySqlZoneRepositoryIntegrationSpec
val f = val f =
for { for {
_ <- saveZones(testZones) _ <- saveZones(testZones)
zones <- repo.getFirstOwnedZoneAclGroupId(testZoneAdminGroupId) zones <- repo.getFirstOwnedZoneAclGroupId(adminId)
} yield zones } yield zones
f.unsafeRunSync() shouldBe Some(zoneId) f.unsafeRunSync() shouldBe Some(zoneId)

View File

@ -0,0 +1,8 @@
CREATE SCHEMA IF NOT EXISTS ${dbName};
USE ${dbName};
ALTER TABLE zone_access
DROP CONSTRAINT fk_zone_access,
ADD zone_status CHAR(36) NOT NULL
;

View File

@ -237,10 +237,7 @@ CREATE TABLE IF NOT EXISTS zone_access
( (
accessor_id char(36) not null, accessor_id char(36) not null,
zone_id char(36) not null, zone_id char(36) not null,
primary key (accessor_id, zone_id), primary key (accessor_id, zone_id)
constraint fk_zone_access_zone_id
foreign key (zone_id) references zone (id)
on delete cascade
); );
CREATE INDEX IF NOT EXISTS zone_access_accessor_id_index CREATE INDEX IF NOT EXISTS zone_access_accessor_id_index

View File

@ -47,9 +47,9 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
sql""" sql"""
|SELECT data |SELECT data
| FROM group_change | FROM group_change
| WHERE group_id = {groupId} AND created_timestamp < {startFrom} | WHERE group_id = {groupId}
| ORDER BY created_timestamp DESC | ORDER BY created_timestamp DESC
| LIMIT {maxItems} | LIMIT {maxItems} OFFSET {startFrom}
""".stripMargin """.stripMargin
private final val LIST_GROUP_CHANGE_NO_START = private final val LIST_GROUP_CHANGE_NO_START =
@ -100,7 +100,7 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
def getGroupChanges( def getGroupChanges(
groupId: String, groupId: String,
startFrom: Option[String], startFrom: Option[Int],
maxItems: Int maxItems: Int
): IO[ListGroupChangesResults] = ): IO[ListGroupChangesResults] =
monitor("repo.GroupChange.getGroupChanges") { monitor("repo.GroupChange.getGroupChanges") {
@ -112,21 +112,25 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
val query = startFrom match { val query = startFrom match {
case Some(start) => case Some(start) =>
LIST_GROUP_CHANGES_WITH_START LIST_GROUP_CHANGES_WITH_START
.bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> maxItems) .bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> (maxItems + 1))
case None => case None =>
LIST_GROUP_CHANGE_NO_START LIST_GROUP_CHANGE_NO_START
.bindByName('groupId -> groupId, 'maxItems -> maxItems) .bindByName('groupId -> groupId, 'maxItems -> (maxItems + 1))
} }
val queryResult = query val queryResult = query
.map(toGroupChange(1)) .map(toGroupChange(1))
.list() .list()
.apply() .apply()
val nextId = val maxQueries = queryResult.take(maxItems)
if (queryResult.size < maxItems) None val startValue = startFrom.getOrElse(0)
else queryResult.lastOption.map(_.created.toEpochMilli.toString)
ListGroupChangesResults(queryResult, nextId) val nextId = queryResult match {
case _ if queryResult.size <= maxItems | queryResult.isEmpty => None
case _ => Some(startValue + maxItems)
}
ListGroupChangesResults(maxQueries, nextId)
} }
} }
} }

View File

@ -17,10 +17,13 @@
package vinyldns.mysql.repository package vinyldns.mysql.repository
import cats.effect.IO import cats.effect.IO
import java.time.Instant import java.time.Instant
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import scalikejdbc._ import scalikejdbc._
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.membership.User
import vinyldns.core.domain.zone._ import vinyldns.core.domain.zone._
import vinyldns.core.protobuf._ import vinyldns.core.protobuf._
import vinyldns.core.route.Monitored import vinyldns.core.route.Monitored
@ -32,12 +35,26 @@ class MySqlZoneChangeRepository
with Monitored { with Monitored {
private final val logger = LoggerFactory.getLogger(classOf[MySqlZoneChangeRepository]) private final val logger = LoggerFactory.getLogger(classOf[MySqlZoneChangeRepository])
private final val MAX_ACCESSORS = 30
private final val PUT_ZONE_CHANGE = private final val PUT_ZONE_CHANGE =
sql""" sql"""
|REPLACE INTO zone_change (change_id, zone_id, data, created_timestamp) |REPLACE INTO zone_change (change_id, zone_id, data, created_timestamp)
| VALUES ({change_id}, {zone_id}, {data}, {created_timestamp}) | VALUES ({change_id}, {zone_id}, {data}, {created_timestamp})
""".stripMargin """.stripMargin
private final val BASE_ZONE_CHANGE_SEARCH_SQL =
"""
|SELECT zc.data
| FROM zone_change zc
""".stripMargin
private final val BASE_GET_ZONES_SQL =
"""
|SELECT z.data
| FROM zone z
""".stripMargin
private final val LIST_ZONES_CHANGES = private final val LIST_ZONES_CHANGES =
sql""" sql"""
|SELECT zc.data |SELECT zc.data
@ -110,6 +127,106 @@ class MySqlZoneChangeRepository
} }
} }
private def withAccessors(
user: User,
groupIds: Seq[String],
ignoreAccessZones: Boolean
): (String, Seq[Any]) =
// Super users do not need to join across to check zone access as they have access to all of the zones
if (ignoreAccessZones || user.isSuper || user.isSupport) {
(BASE_ZONE_CHANGE_SEARCH_SQL, Seq.empty)
} else {
// User is not super or support,
// let's join across to the zone access table so we return only zones a user has access to
val accessors = buildZoneSearchAccessorList(user, groupIds)
val questionMarks = List.fill(accessors.size)("?").mkString(",")
val withAccessorCheck = BASE_ZONE_CHANGE_SEARCH_SQL +
s"""
| JOIN zone_access za ON zc.zone_id = za.zone_id
| AND za.accessor_id IN ($questionMarks)
""".stripMargin
(withAccessorCheck, accessors)
}
/* Limit the accessors so that we don't have boundless parameterized queries */
private def buildZoneSearchAccessorList(user: User, groupIds: Seq[String]): Seq[String] = {
val allAccessors = user.id +: groupIds
if (allAccessors.length > MAX_ACCESSORS) {
logger.warn(
s"User ${user.userName} with id ${user.id} is in more than $MAX_ACCESSORS groups, no all zones maybe returned!"
)
}
// Take the top 30 accessors, but add "EVERYONE" to the list so that we include zones that have everyone access
allAccessors.take(MAX_ACCESSORS) :+ "EVERYONE"
}
def listDeletedZones(
authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): IO[ListDeletedZonesChangeResults] =
monitor("repo.ZoneChange.listDeletedZoneInZoneChanges") {
IO {
DB.readOnly { implicit s =>
val (withAccessorCheck, accessors) =
withAccessors(authPrincipal.signedInUser, authPrincipal.memberGroupIds, ignoreAccess)
val sb = new StringBuilder
sb.append(withAccessorCheck)
val query = sb.toString
val zoneChangeResults: List[ZoneChange] =
SQL(query)
.bind(accessors: _*)
.map(extractZoneChange(1))
.list()
.apply()
val zoneResults: List[Zone] =
SQL(BASE_GET_ZONES_SQL)
.map(extractZone(1))
.list()
.apply()
val zoneNotInZoneChange: List[ZoneChange] =
zoneChangeResults.filter(z=> !zoneResults.map(_.name).contains(z.zone.name) && z.zone.status != ZoneStatus.Active)
val deletedZoneResults: List[ZoneChange] =
zoneNotInZoneChange.filter(_.zone.status.equals(ZoneStatus.Deleted)).distinct.sortBy(_.zone.updated).reverse
val results: List[ZoneChange] =
if (zoneNameFilter.nonEmpty) {
deletedZoneResults.filter(r => r.zone.name.contains(zoneNameFilter.getOrElse("not found")))
} else {
deletedZoneResults
}
val deletedZonesWithStartFrom: List[ZoneChange] = startFrom match {
case Some(zoneId) => results.dropWhile(_.zone.id != zoneId)
case None => results
}
val deletedZonesWithMaxItems = deletedZonesWithStartFrom.take(maxItems + 1)
val (newResults, nextId) =
if (deletedZonesWithMaxItems.size > maxItems)
(deletedZonesWithMaxItems.dropRight(1), deletedZonesWithMaxItems.lastOption.map(_.zone.id))
else (deletedZonesWithMaxItems, None)
ListDeletedZonesChangeResults(
zoneDeleted = newResults,
nextId = nextId,
startFrom = startFrom,
maxItems = maxItems,
zoneChangeFilter = zoneNameFilter,
ignoreAccess = ignoreAccess
)
}}}
def listFailedZoneChanges(maxItems: Int, startFrom: Int): IO[ListFailedZoneChangesResults] = def listFailedZoneChanges(maxItems: Int, startFrom: Int): IO[ListFailedZoneChangesResults] =
monitor("repo.ZoneChange.listFailedZoneChanges") { monitor("repo.ZoneChange.listFailedZoneChanges") {
IO { IO {
@ -130,4 +247,8 @@ class MySqlZoneChangeRepository
private def extractZoneChange(colIndex: Int): WrappedResultSet => ZoneChange = res => { private def extractZoneChange(colIndex: Int): WrappedResultSet => ZoneChange = res => {
fromPB(VinylDNSProto.ZoneChange.parseFrom(res.bytes(colIndex))) fromPB(VinylDNSProto.ZoneChange.parseFrom(res.bytes(colIndex)))
} }
private def extractZone(columnIndex: Int): WrappedResultSet => Zone = res => {
fromPB(VinylDNSProto.Zone.parseFrom(res.bytes(columnIndex)))
}
} }

View File

@ -71,10 +71,17 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
*/ */
private final val PUT_ZONE_ACCESS = private final val PUT_ZONE_ACCESS =
sql""" sql"""
|REPLACE INTO zone_access(accessor_id, zone_id) |REPLACE INTO zone_access(accessor_id, zone_id, zone_status)
| VALUES ({accessorId}, {zoneId}) | VALUES ({accessorId}, {zoneId}, {zoneStatus})
""".stripMargin """.stripMargin
private final val UPDATE_ZONE_ACCESS =
sql"""
|UPDATE zone_access
| SET zone_status = {zoneStatus}
| WHERE zone_id = {zoneId}
""".stripMargin
private final val DELETE_ZONE_ACCESS = private final val DELETE_ZONE_ACCESS =
sql""" sql"""
|DELETE |DELETE
@ -127,8 +134,8 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
private final val GET_ZONE_ACCESS_BY_ADMIN_GROUP_ID = private final val GET_ZONE_ACCESS_BY_ADMIN_GROUP_ID =
sql""" sql"""
|SELECT zone_id |SELECT zone_id
| FROM zone_access z | FROM zone_access za
| WHERE z.accessor_id = (?) | WHERE za.accessor_id = (?) AND za.zone_status <> 'Deleted'
| LIMIT 1 | LIMIT 1
""".stripMargin """.stripMargin
@ -503,10 +510,10 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
val sqlParameters: Seq[Seq[(Symbol, Any)]] = val sqlParameters: Seq[Seq[(Symbol, Any)]] =
zone.acl.rules.toSeq zone.acl.rules.toSeq
.map(r => r.userId.orElse(r.groupId).getOrElse("EVERYONE")) // if the user and group are empty, assert everyone .map(r => r.userId.orElse(r.groupId).getOrElse("EVERYONE")) // if the user and group are empty, assert everyone
.map(userOrGroupId => Seq('accessorId -> userOrGroupId, 'zoneId -> zone.id)) .map(userOrGroupId => Seq('accessorId -> userOrGroupId, 'zoneId -> zone.id, 'zoneStatus -> zone.status.toString))
// we MUST make sure that we put the admin group id as an accessor to this zone // we MUST make sure that we put the admin group id as an accessor to this zone
val allAccessors = sqlParameters :+ Seq('accessorId -> zone.adminGroupId, 'zoneId -> zone.id) val allAccessors = sqlParameters :+ Seq('accessorId -> zone.adminGroupId, 'zoneId -> zone.id,'zoneStatus -> zone.status.toString)
// make sure that we do a distinct, so that we don't generate unnecessary inserts // make sure that we do a distinct, so that we don't generate unnecessary inserts
PUT_ZONE_ACCESS.batchByName(allAccessors.distinct: _*).apply() PUT_ZONE_ACCESS.batchByName(allAccessors.distinct: _*).apply()
@ -518,6 +525,12 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
zone zone
} }
private def updateZoneAccess(zone: Zone)(implicit session: DBSession): Zone = {
UPDATE_ZONE_ACCESS.bindByName(
'zoneStatus ->zone.status.toString, 'zoneId ->zone.id).update().apply()
zone
}
private def deleteZoneAccess(zone: Zone)(implicit session: DBSession): Zone = { private def deleteZoneAccess(zone: Zone)(implicit session: DBSession): Zone = {
DELETE_ZONE_ACCESS.bind(zone.id).update().apply() DELETE_ZONE_ACCESS.bind(zone.id).update().apply()
zone zone
@ -532,6 +545,7 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
IO { IO {
DB.localTx { implicit s => DB.localTx { implicit s =>
deleteZone(zone) deleteZone(zone)
updateZoneAccess(zone)
} }
} }
} }

View File

@ -486,6 +486,20 @@ class VinylDNS @Inject() (
}) })
} }
def getDeletedZones: 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", "zones/deleted/changes", parameters = queryParameters)
executeRequest(vinyldnsRequest, request.user).map(response => {
Status(response.status)(response.body)
.withHeaders(cacheHeaders: _*)
})
// $COVERAGE-ON$
}
def getZoneChange(id: String): Action[AnyContent] = userAction.async { implicit request => def getZoneChange(id: String): Action[AnyContent] = userAction.async { implicit request =>
val queryParameters = new HashMap[String, java.util.List[String]]() val queryParameters = new HashMap[String, java.util.List[String]]()
for { for {

View File

@ -12,7 +12,7 @@
<!-- END BREADCRUMB --> <!-- END BREADCRUMB -->
<!-- PAGE TITLE --> <!-- PAGE TITLE -->
<div class="page-title"><h3><span class="fa fa-list-ol"></span> DNS Changes {{ getPageTitle() }}</h3></div> <div class="page-title"><h3><span class="fa fa-list-ol"></span> DNS Changes</h3></div>
<!-- END PAGE TITLE --> <!-- END PAGE TITLE -->
<!-- PAGE CONTENT WRAPPER --> <!-- PAGE CONTENT WRAPPER -->
@ -58,7 +58,8 @@
<div class="panel-body"> <div class="panel-body">
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getPageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a type="button" ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a> <a type="button" ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a>
@ -125,7 +126,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getPageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a> <a ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a>

View File

@ -121,7 +121,7 @@
<!-- START SIMPLE DATATABLE --> <!-- START SIMPLE DATATABLE -->
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title">All Group Changes {{ getChangePageTitle() }}</h3> <h3 class="panel-title">All Group Changes</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="btn-group"> <div class="btn-group">
@ -129,7 +129,8 @@
</div> </div>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a> <a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
@ -171,7 +172,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a> <a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>

View File

@ -426,7 +426,8 @@
</div> </div>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getRecordChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a> <a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
@ -476,7 +477,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getRecordChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a> <a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>

View File

@ -3,7 +3,7 @@
<!-- START SIMPLE DATATABLE --> <!-- START SIMPLE DATATABLE -->
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title">All Record Changes {{ getChangePageTitle() }}</h3> <h3 class="panel-title">All Record Changes</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="btn-group"> <div class="btn-group">
@ -11,7 +11,8 @@
</div> </div>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a> <a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
@ -61,7 +62,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a> <a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>

View File

@ -5,13 +5,13 @@
<div class="panel panel-default x_panel" ng-init="sharedDisplayEnabled = @meta.sharedDisplayEnabled; defaultTtl = @meta.defaultTtl"> <div class="panel panel-default x_panel" ng-init="sharedDisplayEnabled = @meta.sharedDisplayEnabled; defaultTtl = @meta.defaultTtl">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">
<a class="collapse-link"> <a class="collapse-link" href data-toggle="collapse" data-target="#recordChangePreviewTableCollapse" aria-expanded="false" aria-controls="recordChangePreviewTableCollapse">
<i class="fa fa-chevron-down"></i> <i class="fa fa-chevron-down"></i>
Recent Record Changes Recent Record Changes
</a> </a>
</h3> </h3>
</div> </div>
<div class="panel-collapse collapse in" id="recordChangePreviewTableCollapse">
<div class="panel-body x_content panel-body-open"> <div class="panel-body x_content panel-body-open">
<button class="btn btn-default" ng-click="refreshRecordChangesPreview()"><span class="fa fa-refresh"></span> Refresh</button> <button class="btn btn-default" ng-click="refreshRecordChangesPreview()"><span class="fa fa-refresh"></span> Refresh</button>
<table id="recordChangePreviewTable" class="table table-condensed"> <table id="recordChangePreviewTable" class="table table-condensed">
@ -52,6 +52,7 @@
</table> </table>
<!-- <button type="button" class="btn btn-info btn-sm">View full change log</button> --> <!-- <button type="button" class="btn btn-info btn-sm">View full change log</button> -->
</div> </div>
</div>
</div> </div>
<!-- START RECENT RECORDSET CHANGES TABLE PANEL --> <!-- START RECENT RECORDSET CHANGES TABLE PANEL -->
<!-- END ACCORDION --> <!-- END ACCORDION -->

View File

@ -75,7 +75,7 @@
<label class="col-md-3 control-label">Access</label> <label class="col-md-3 control-label">Access</label>
<div class="col-md-9"> <div class="col-md-9">
<div> <div>
<select class="form-control" ng-model="updateZoneInfo.shared"> <select class="form-control" ng-model="updateZoneInfo.shared" ng-disabled="!isZoneAdmin">
<option ng-value="true" ng-model="updateZoneInfo.shared" <option ng-value="true" ng-model="updateZoneInfo.shared"
ng-selected="updateZoneInfo.shared == true"> ng-selected="updateZoneInfo.shared == true">
Shared</option> Shared</option>

View File

@ -9,6 +9,21 @@
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-default" ng-click="refreshZoneChange()"><span class="fa fa-refresh"></span> Refresh</button> <button class="btn btn-default" ng-click="refreshZoneChange()"><span class="fa fa-refresh"></span> Refresh</button>
</div> </div>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_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 -->
<table id="zoneChangeDataTable" class="table table-hover table-striped"> <table id="zoneChangeDataTable" class="table table-hover table-striped">
<thead> <thead>
<tr> <tr>
@ -46,8 +61,8 @@
</table> </table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate"> <div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_zones_page_number">{{ getZoneHistoryPageNumber() }}</span> <span class="vinyldns_page_number">{{ getZoneHistoryPageNumber() }}</span>
<ul class="pagination"> <ul class="pagination">
<li class="paginate_button previous"> <li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPageZoneHistory()" class="paginate_button">Previous</a> <a ng-if="prevPageEnabled()" ng-click="prevPageZoneHistory()" class="paginate_button">Previous</a>

View File

@ -29,6 +29,7 @@
<ul class="nav nav-tabs bar_tabs"> <ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#myZones" data-toggle="tab" ng-click="myZonesAccess()">My Zones</a></li> <li class="active"><a href="#myZones" data-toggle="tab" ng-click="myZonesAccess()">My Zones</a></li>
<li><a id="tab2-button" href="#allZones" data-toggle="tab" ng-click="allZonesAccess()">All Zones</a></li> <li><a id="tab2-button" href="#allZones" data-toggle="tab" ng-click="allZonesAccess()">All Zones</a></li>
<li><a id="tab3-button" href="#deletedZones" data-toggle="tab">Abandoned Zones</a></li>
</ul> </ul>
<div class="panel-body tab-content"> <div class="panel-body tab-content">
@ -278,6 +279,209 @@
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane" id="deletedZones">
<div class="row">
<div class="col-md-12">
<!-- SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<button id="zone-refresh-button" class="btn btn-default" ng-click="refreshZones()">
<span class="fa fa-refresh"></span> Refresh
</button>
<!-- SEARCH BOX -->
<div class="pull-right">
<form class="input-group" ng-submit="refreshZones()">
<div class="input-group">
<span class="input-group-btn">
<button id="my-deleted-zones-search-button" type="submit" class="btn btn-primary btn-left-round">
<span class="fa fa-search"></span>
</button>
</span>
<input id="deleted-zones-search-text" ng-model="query" type="text" class="form-control" placeholder="Zone Name"/>
</div>
</form>
</div>
<!-- END SEARCH BOX -->
<!-- DELETED ZONES TABS -->
<div class="panel panel-default panel-tabs">
<ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#myDeletedZones" data-toggle="tab">My Zones</a></li>
<li><a id="tab2-button" href="#allDeletedZones" data-toggle="tab">All Zones</a></li>
</ul>
<div class="panel-body tab-content">
<div class="tab-pane active" id="myDeletedZones">
<div id="zone-list-table" class="panel-body">
<p ng-if="!myDeletedZonesLoaded">Loading zones...</p>
<p ng-if="myDeletedZonesLoaded && !myDeletedZones.length">No zones match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myDeletedZones')" ng-click="prevPageMyDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myDeletedZones')" ng-click="nextPageMyDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table" ng-if="myDeletedZones.length">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Admin Group</th>
<th>Created</th>
<th>Abandoned</th>
<th>Status</th>
<th>Abandoned By</th>
@if(meta.sharedDisplayEnabled) {
<th>Access</th>
}
</tr>
</thead>
<tbody>
<tr ng-repeat="deletedZone in myDeletedZones">
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.name">
</td>
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.email">
</td>
<td>
<a ng-if="canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
href="/groups/{{deletedZone.zoneChange.zone.adminGroupId}}"></a>
<span ng-if="!canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
style="line-height: 0"></span>
</td>
<td>
{{deletedZone.zoneChange.zone.created}}
</td>
<td>
{{deletedZone.zoneChange.zone.updated}}
</td>
<td ng-bind="deletedZone.zoneChange.zone.status"></td>
<td>
{{deletedZone.userName}}
</td>
@if(meta.sharedDisplayEnabled) {
<td>{{zone.shared ? "Shared" : "Private"}}</td>
}
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myDeletedZones')" ng-click="prevPageMyDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myDeletedZones')" ng-click="nextPageMyDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
<div class="tab-pane" id="allDeletedZones">
<div id="zone-list-table" class="panel-body">
<p ng-if="!allDeletedZonesLoaded">Loading zones...</p>
<p ng-if="allDeletedZonesLoaded && !allDeletedZones.length">No zones match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allDeletedZones')" ng-click="prevPageAllDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allDeletedZones')" ng-click="nextPageAllDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table" ng-if="allDeletedZones.length">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Admin Group</th>
<th>Created</th>
<th>Abandoned</th>
<th>Status</th>
<th>Abandoned By</th>
@if(meta.sharedDisplayEnabled) {
<th>Access</th>
}
</tr>
</thead>
<tbody>
<tr ng-repeat="deletedZone in allDeletedZones">
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.name">
</td>
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.email">
</td>
<td>
<a ng-if="canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
href="/groups/{{deletedZone.zoneChange.zone.adminGroupId}}"></a>
<span ng-if="!canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
style="line-height: 0"></span>
</td>
<td>
{{deletedZone.zoneChange.zone.created}}
</td>
<td>
{{deletedZone.zoneChange.zone.updated}}
</td>
<td ng-bind="deletedZone.zoneChange.zone.status"></td>
<td>
{{deletedZone.userName}}
</td>
@if(meta.sharedDisplayEnabled) {
<td>{{zone.shared ? "Shared" : "Private"}}</td>
}
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allDeletedZones')" ng-click="prevPageAllDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allDeletedZones')" ng-click="nextPageAllDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
</div>
</div>
<!-- END DELETED ZONES TABS -->
</div>
<div class="panel-footer"></div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- END VERTICAL TABS --> <!-- END VERTICAL TABS -->

View File

@ -31,6 +31,7 @@ GET /api/zones/backendids @controllers.VinylDNS.getBacken
GET /api/zones/:id @controllers.VinylDNS.getZone(id: String) GET /api/zones/:id @controllers.VinylDNS.getZone(id: String)
GET /api/zones/:id/details @controllers.VinylDNS.getCommonZoneDetails(id: String) GET /api/zones/:id/details @controllers.VinylDNS.getCommonZoneDetails(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/deleted/changes @controllers.VinylDNS.getDeletedZones
GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: 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)

View File

@ -42,6 +42,13 @@ describe('Controller: ManageZonesController', function () {
} }
}); });
}; };
zonesService.getDeletedZones = function() {
return $q.when({
data: {
zonesDeletedInfo: ["all my deleted zones"]
}
});
};
zonesService.getBackendIds = function() { zonesService.getBackendIds = function() {
return $q.when({ return $q.when({
data: ['backend-1', 'backend-2'] data: ['backend-1', 'backend-2']

View File

@ -23,6 +23,8 @@ angular.module('controller.zones', [])
$scope.allZonesLoaded = false; $scope.allZonesLoaded = false;
$scope.hasZones = false; // Re-assigned each time zones are fetched without a query $scope.hasZones = false; // Re-assigned each time zones are fetched without a query
$scope.allGroups = []; $scope.allGroups = [];
$scope.myDeletedZones = [];
$scope.allDeletedZones = [];
$scope.ignoreAccess = false; $scope.ignoreAccess = false;
$scope.validEmailDomains= []; $scope.validEmailDomains= [];
$scope.allZonesAccess = function () { $scope.allZonesAccess = function () {
@ -41,6 +43,8 @@ angular.module('controller.zones', [])
// Paging status for zone sets // Paging status for zone sets
var zonesPaging = pagingService.getNewPagingParams(100); var zonesPaging = pagingService.getNewPagingParams(100);
var allZonesPaging = pagingService.getNewPagingParams(100); var allZonesPaging = pagingService.getNewPagingParams(100);
var myDeleteZonesPaging = pagingService.getNewPagingParams(100);
var allDeleteZonesPaging = pagingService.getNewPagingParams(100);
profileService.getAuthenticatedUserData().then(function (results) { profileService.getAuthenticatedUserData().then(function (results) {
if (results.data) { if (results.data) {
$scope.profile = results.data; $scope.profile = results.data;
@ -197,8 +201,53 @@ angular.module('controller.zones', [])
.catch(function (error) { .catch(function (error) {
handleError(error, 'zonesService::getZones-failure'); handleError(error, 'zonesService::getZones-failure');
}); });
zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, undefined, $scope.query, false)
.then(function (response) {
$log.debug('zonesService::getMyDeletedZones-success (' + response.data.zonesDeletedInfo.length + ' zones)');
myDeleteZonesPaging.next = response.data.nextId;
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error, 'zonesService::getDeletedZones-failure');
});
zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, undefined, $scope.query, true)
.then(function (response) {
$log.debug('zonesService::getAllDeletedZones-success (' + response.data.zonesDeletedInfo.length + ' zones)');
allDeleteZonesPaging.next = response.data.nextId;
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error, 'zonesService::getDeletedZones-failure');
});
}; };
function updateMyDeletedZoneDisplay (myDeletedZones) {
$scope.myDeletedZones = myDeletedZones;
$scope.myDeletedZonesLoaded = true;
$log.debug("Displaying my Deleted zones: ", $scope.myDeletedZones);
if($scope.myDeletedZones.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
function updateAllDeletedZoneDisplay (allDeletedZones) {
$scope.allDeletedZones = allDeletedZones;
$scope.allDeletedZonesLoaded = true;
$log.debug("Displaying all Deleted zones: ", $scope.allDeletedZones);
if($scope.allDeletedZones.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
function updateZoneDisplay (zones) { function updateZoneDisplay (zones) {
$scope.zones = zones; $scope.zones = zones;
$scope.myZoneIds = zones.map(function(zone) {return zone['id']}); $scope.myZoneIds = zones.map(function(zone) {return zone['id']});
@ -283,6 +332,10 @@ angular.module('controller.zones', [])
return pagingService.getPanelTitle(zonesPaging); return pagingService.getPanelTitle(zonesPaging);
case 'allZones': case 'allZones':
return pagingService.getPanelTitle(allZonesPaging); return pagingService.getPanelTitle(allZonesPaging);
case 'myDeletedZones':
return pagingService.getPanelTitle(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.getPanelTitle(allDeleteZonesPaging);
} }
}; };
@ -292,6 +345,10 @@ angular.module('controller.zones', [])
return pagingService.prevPageEnabled(zonesPaging); return pagingService.prevPageEnabled(zonesPaging);
case 'allZones': case 'allZones':
return pagingService.prevPageEnabled(allZonesPaging); return pagingService.prevPageEnabled(allZonesPaging);
case 'myDeletedZones':
return pagingService.prevPageEnabled(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.prevPageEnabled(allDeleteZonesPaging);
} }
}; };
@ -301,6 +358,10 @@ angular.module('controller.zones', [])
return pagingService.nextPageEnabled(zonesPaging); return pagingService.nextPageEnabled(zonesPaging);
case 'allZones': case 'allZones':
return pagingService.nextPageEnabled(allZonesPaging); return pagingService.nextPageEnabled(allZonesPaging);
case 'myDeletedZones':
return pagingService.nextPageEnabled(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.nextPageEnabled(allDeleteZonesPaging);
} }
}; };
@ -330,6 +391,32 @@ angular.module('controller.zones', [])
}); });
} }
$scope.prevPageMyDeletedZones = function() {
var startFrom = pagingService.getPrevStartFrom(myDeleteZonesPaging);
return zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, startFrom, $scope.query, false)
.then(function(response) {
myDeleteZonesPaging = pagingService.prevPageUpdate(response.data.nextId, myDeleteZonesPaging);
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error,'zonesService::prevPage-failure');
});
}
$scope.prevPageAllDeletedZones = function() {
var startFrom = pagingService.getPrevStartFrom(allDeleteZonesPaging);
return zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, startFrom, $scope.query, true)
.then(function(response) {
allDeleteZonesPaging = pagingService.prevPageUpdate(response.data.nextId, allDeleteZonesPaging);
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error,'zonesService::prevPage-failure');
});
}
$scope.nextPageMyZones = function () { $scope.nextPageMyZones = function () {
return zonesService return zonesService
.getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query, $scope.searchByAdminGroup, false, true) .getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query, $scope.searchByAdminGroup, false, true)
@ -362,5 +449,37 @@ angular.module('controller.zones', [])
}); });
}; };
$scope.nextPageMyDeletedZones = function () {
return zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, myDeleteZonesPaging.next, $scope.query, false)
.then(function(response) {
var myDeletedZoneSets = response.data.zonesDeletedInfo;
myDeleteZonesPaging = pagingService.nextPageUpdate(myDeletedZoneSets, response.data.nextId, myDeleteZonesPaging);
if (myDeletedZoneSets.length > 0) {
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
}
})
.catch(function (error) {
handleError(error,'zonesService::nextPage-failure')
});
};
$scope.nextPageAllDeletedZones = function () {
return zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, allDeleteZonesPaging.next, $scope.query, false)
.then(function(response) {
var allDeletedZoneSets = response.data.zonesDeletedInfo;
allDeleteZonesPaging = pagingService.nextPageUpdate(allDeletedZoneSets, response.data.nextId, allDeleteZonesPaging);
if (allDeletedZoneSets.length > 0) {
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
}
})
.catch(function (error) {
handleError(error,'zonesService::nextPage-failure')
});
};
$timeout($scope.refreshZones, 0); $timeout($scope.refreshZones, 0);
}); });

View File

@ -114,4 +114,77 @@ describe('Controller: ZonesController', function () {
expect(getZoneSets.calls.mostRecent().args).toEqual( expect(getZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess, expectedincludeReverse]); [expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess, expectedincludeReverse]);
}); });
it('nextPageZones should call getDeletedZones with the correct parameters', function () {
mockDeletedZone = {zonesDeletedInfo:[ {
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Deleted",
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 getDeletedZoneSets = spyOn(this.zonesService, 'getDeletedZones')
.and.stub()
.and.returnValue(this.zonesService.q.when(mockDeletedZone));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.nextPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(1);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
});
it('prevPageZones should call getDeletedZones with the correct parameters', function () {
mockDeletedZone = {zonesDeletedInfo:[ {
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Deleted",
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 getDeletedZoneSets = spyOn(this.zonesService, 'getDeletedZones')
.and.stub()
.and.returnValue(this.zonesService.q.when(mockDeletedZone));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.prevPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(1);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
this.scope.nextPageMyDeletedZones();
this.scope.prevPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(3);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
});
}); });

View File

@ -264,6 +264,9 @@
/** /**
* Record change history paging * Record change history paging
*/ */
$scope.getRecordChangePageTitle = function() {
return pagingService.getPanelTitle(changePaging);
};
$scope.changeHistoryPrevPageEnabled = function() { $scope.changeHistoryPrevPageEnabled = function() {
return pagingService.prevPageEnabled(changePaging); return pagingService.prevPageEnabled(changePaging);

View File

@ -53,6 +53,20 @@ angular.module('service.zones', [])
return $http.get(url); return $http.get(url);
}; };
this.getDeletedZones = function (limit, startFrom, query, ignoreAccess) {
if (query == "") {
query = null;
}
var params = {
"maxItems": limit,
"startFrom": startFrom,
"nameFilter": query,
"ignoreAccess": ignoreAccess
};
var url = groupsService.urlBuilder("/api/zones/deleted/changes", params);
return $http.get(url);
};
this.getBackendIds = function() { this.getBackendIds = function() {
var url = "/api/zones/backendids"; var url = "/api/zones/backendids";
return $http.get(url); return $http.get(url);

View File

@ -54,6 +54,24 @@ describe('Service: zoneService', function () {
this.$httpBackend.flush(); this.$httpBackend.flush();
}); });
it('http backend gets called properly when getting my deleted zones', function () {
this.$httpBackend.expectGET('/api/zones/deleted/changes?maxItems=100&startFrom=start&nameFilter=someQuery&ignoreAccess=true').respond('deleted my zone returned');
this.zonesService.getDeletedZones('100', 'start', 'someQuery', true)
.then(function(response) {
expect(response.data).toBe('deleted my zone returned');
});
this.$httpBackend.flush();
});
it('http backend gets called properly when getting all deleted zones', function () {
this.$httpBackend.expectGET('/api/zones/deleted/changes?maxItems=100&startFrom=start&nameFilter=someQuery&ignoreAccess=false').respond('deleted all zone returned');
this.zonesService.getDeletedZones('100', 'start', 'someQuery', false)
.then(function(response) {
expect(response.data).toBe('deleted all zone returned');
});
this.$httpBackend.flush();
});
it('http backend gets called properly when deleting zone', function (done) { it('http backend gets called properly when deleting zone', function (done) {
this.$httpBackend.expectDELETE('/api/zones/id').respond('zone deleted'); this.$httpBackend.expectDELETE('/api/zones/id').respond('zone deleted');
this.zonesService.delZone('id') this.zonesService.delZone('id')

View File

@ -240,6 +240,24 @@ trait TestApplicationData { this: Mockito =>
| } | }
""".stripMargin) """.stripMargin)
val hobbitDeletedZoneChange: 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": "Deleted"
| }}],
| "maxItems": 100
| }
""".stripMargin)
val hobbitZoneChange: JsValue = Json.parse(s"""{ val hobbitZoneChange: JsValue = Json.parse(s"""{
| "zoneId": "$hobbitZoneId", | "zoneId": "$hobbitZoneId",
| "zoneChanges": | "zoneChanges":

View File

@ -1621,6 +1621,74 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
} }
} }
".getDeletedZones" should {
"return ok (200) if the DeletedZones is found" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/deleted/changes" =>
defaultActionBuilder { Results.Ok(hobbitDeletedZoneChange) }
}
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.getDeletedZones()(
FakeRequest(GET, s"/zones/deleted/changes")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(OK)
hasCacheHeaders(result)
contentAsJson(result) must beEqualTo(hobbitDeletedZoneChange)
}
"return a not found (404) if the DeletedZones does not exist" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/deleted/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.getDeletedZones()(
FakeRequest(GET, "zones/deleted/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.getDeletedZones()(FakeRequest(GET, s"/api/zones/deleted/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.getDeletedZones()(
FakeRequest(GET, s"/api/zones/deleted/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."
)
}
}
".getZone" should { ".getZone" should {
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) { "return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient] val client = mock[WSClient]

View File

@ -30,7 +30,7 @@ import vinyldns.core.domain.{Fqdn, record}
import vinyldns.core.domain.record.{RecordSet, RecordType} import vinyldns.core.domain.record.{RecordSet, RecordType}
class Route53IntegrationSpec class Route53IntegrationSpec
extends AnyWordSpec extends AnyWordSpec
with BeforeAndAfterAll with BeforeAndAfterAll
with BeforeAndAfterEach with BeforeAndAfterEach
with Matchers { with Matchers {
@ -52,6 +52,8 @@ class Route53IntegrationSpec
"test", "test",
Option("access"), Option("access"),
Option("secret"), Option("secret"),
None,
None,
sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"), sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"),
"us-east-1" "us-east-1"
) )

View File

@ -16,13 +16,9 @@
package vinyldns.route53.backend package vinyldns.route53.backend
import java.util.UUID
import cats.data.OptionT import cats.data.OptionT
import cats.effect.IO import cats.effect.IO
import com.amazonaws.auth.{
AWSStaticCredentialsProvider,
BasicAWSCredentials,
DefaultAWSCredentialsProviderChain
}
import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration
import com.amazonaws.handlers.AsyncHandler import com.amazonaws.handlers.AsyncHandler
import com.amazonaws.services.route53.{AmazonRoute53Async, AmazonRoute53AsyncClientBuilder} import com.amazonaws.services.route53.{AmazonRoute53Async, AmazonRoute53AsyncClientBuilder}
@ -106,10 +102,17 @@ class Route53Backend(
* @return A list of record sets matching the name, empty if not found * @return A list of record sets matching the name, empty if not found
*/ */
def resolve(name: String, zoneName: String, typ: RecordType): IO[List[RecordSet]] = { def resolve(name: String, zoneName: String, typ: RecordType): IO[List[RecordSet]] = {
val fqdn = Fqdn.merge(name, zoneName).fqdn
def filterResourceRecordSet(
rrs: java.util.List[ResourceRecordSet],
rrType: RRType
): java.util.List[ResourceRecordSet] =
rrs.asScala.filter { r =>
r.getName == fqdn && RRType.fromValue(r.getType) == rrType
}.asJava
for { for {
hostedZoneId <- lookupHostedZone(zoneName) hostedZoneId <- lookupHostedZone(zoneName)
awsRRType <- OptionT.fromOption[IO](toRoute53RecordType(typ)) awsRRType <- OptionT.fromOption[IO](toRoute53RecordType(typ))
fqdn = Fqdn.merge(name, zoneName).fqdn
result <- OptionT.liftF { result <- OptionT.liftF {
r53( r53(
new ListResourceRecordSetsRequest() new ListResourceRecordSetsRequest()
@ -119,7 +122,10 @@ class Route53Backend(
client.listResourceRecordSetsAsync client.listResourceRecordSetsAsync
) )
} }
} yield toVinylRecordSets(result.getResourceRecordSets, zoneName: String) } yield toVinylRecordSets(
filterResourceRecordSet(result.getResourceRecordSets, awsRRType),
zoneName: String
)
}.getOrElse(Nil) }.getOrElse(Nil)
/** /**
@ -278,21 +284,23 @@ object Route53Backend {
r53ClientBuilder.withEndpointConfiguration( r53ClientBuilder.withEndpointConfiguration(
new EndpointConfiguration(config.serviceEndpoint, config.signingRegion) new EndpointConfiguration(config.serviceEndpoint, config.signingRegion)
) )
// If either of accessKey or secretKey are empty in conf file; then use AWSCredentialsProviderChain to figure out
// credentials. val r53CredBuilder = Route53Credentials.builder
// https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html for {
val credProvider = config.accessKey accessKey <- config.accessKey
.zip(config.secretKey) secretKey <- config.secretKey
.map { } r53CredBuilder.basicCredentials(accessKey, secretKey)
case (key, secret) =>
new AWSStaticCredentialsProvider( for (role <- config.roleArn) {
new BasicAWSCredentials(key, secret) config.externalId match {
) case Some(externalId) =>
} r53CredBuilder.withRole(role, UUID.randomUUID().toString, externalId)
.headOption case None => r53CredBuilder.withRole(role, UUID.randomUUID().toString)
.getOrElse {
new DefaultAWSCredentialsProviderChain()
} }
}
val credProvider = r53CredBuilder.build().provider
r53ClientBuilder.withCredentials(credProvider).build() r53ClientBuilder.withCredentials(credProvider).build()
} }

View File

@ -0,0 +1,111 @@
/*
* 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.route53.backend
import com.amazonaws.auth._
import org.slf4j.LoggerFactory
import com.amazonaws.services.securitytoken.{
AWSSecurityTokenService,
AWSSecurityTokenServiceClientBuilder
}
private[backend] sealed trait Route53Credentials extends Serializable {
def provider: AWSCredentialsProvider
}
private[backend] final case object DefaultCredentials extends Route53Credentials {
def provider: AWSCredentialsProvider = new DefaultAWSCredentialsProviderChain
}
private[backend] final case class BasicCredentials(accessKeyId: String, secretKey: String)
extends Route53Credentials {
private final val logger = LoggerFactory.getLogger(classOf[Route53Backend])
def provider: AWSCredentialsProvider =
try {
new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyId, secretKey))
} catch {
case e: IllegalArgumentException =>
logger.error(
"Error when using accessKey/secret: {}. Using DefaultProviderChain.",
e.getMessage()
)
new DefaultAWSCredentialsProviderChain
}
}
private[backend] final case class STSCredentials(
roleArn: String,
sessionName: String,
externalId: Option[String] = None,
longLivedCreds: Route53Credentials = DefaultCredentials
) extends Route53Credentials {
def provider: AWSCredentialsProvider = {
lazy val stsClient: AWSSecurityTokenService =
AWSSecurityTokenServiceClientBuilder
.standard()
.withCredentials(longLivedCreds.provider)
.build()
val builder = new STSAssumeRoleSessionCredentialsProvider.Builder(roleArn, sessionName)
.withStsClient(stsClient)
externalId match {
case Some(externalId) =>
builder
.withExternalId(externalId)
.build()
case None =>
builder.build()
}
}
}
object Route53Credentials {
class Builder {
private var basicCreds: Option[BasicCredentials] = None
private var stsCreds: Option[STSCredentials] = None
def basicCredentials(accessKeyId: String, secretKey: String): Builder = {
basicCreds = Option(BasicCredentials(accessKeyId, secretKey))
this
}
def withRole(roleArn: String, sessionName: String): Builder = {
stsCreds = Option(STSCredentials(roleArn, sessionName))
this
}
def withRole(roleArn: String, sessionName: String, externalId: String): Builder = {
stsCreds = Option(
STSCredentials(
roleArn,
sessionName,
Option(externalId)
)
)
this
}
def build(): Route53Credentials =
stsCreds.map(_.copy(longLivedCreds = longLivedCreds)).getOrElse(longLivedCreds)
private def longLivedCreds: Route53Credentials = basicCreds.getOrElse(DefaultCredentials)
}
def builder: Builder = new Builder
}

View File

@ -27,6 +27,8 @@ final case class Route53BackendConfig(
id: String, id: String,
accessKey: Option[String], accessKey: Option[String],
secretKey: Option[String], secretKey: Option[String],
roleArn: Option[String],
externalId: Option[String],
serviceEndpoint: String, serviceEndpoint: String,
signingRegion: String signingRegion: String
) )

View File

@ -94,6 +94,7 @@ object Dependencies {
lazy val r53Dependencies = Seq( lazy val r53Dependencies = Seq(
"com.amazonaws" % "aws-java-sdk-core" % awsV withSources(), "com.amazonaws" % "aws-java-sdk-core" % awsV withSources(),
"com.amazonaws" % "aws-java-sdk-sts" % awsV withSources(),
"com.amazonaws" % "aws-java-sdk-route53" % awsV withSources() "com.amazonaws" % "aws-java-sdk-route53" % awsV withSources()
) )