diff --git a/modules/api/src/it/scala/vinyldns/api/route53/Route53ApiIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/route53/Route53ApiIntegrationSpec.scala index bbf5a37c0..d1dee829f 100644 --- a/modules/api/src/it/scala/vinyldns/api/route53/Route53ApiIntegrationSpec.scala +++ b/modules/api/src/it/scala/vinyldns/api/route53/Route53ApiIntegrationSpec.scala @@ -57,6 +57,8 @@ class Route53ApiIntegrationSpec "test", Some("access"), Some("secret"), + None, + None, sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"), "us-east-1" ) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala index b7cac3152..fb14784fe 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala @@ -158,8 +158,8 @@ final case class ListAdminsResponse(admins: Seq[UserInfo]) final case class ListGroupChangesResponse( changes: Seq[GroupChangeInfo], - startFrom: Option[String] = None, - nextId: Option[String] = None, + startFrom: Option[Int] = None, + nextId: Option[Int] = None, maxItems: Int ) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala index e5fc50c69..86e6f09da 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala @@ -266,7 +266,7 @@ class MembershipService( def getGroupActivity( groupId: String, - startFrom: Option[String], + startFrom: Option[Int], maxItems: Int, authPrincipal: AuthPrincipal ): Result[ListGroupChangesResponse] = @@ -285,7 +285,7 @@ class MembershipService( } yield ListGroupChangesResponse( groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))), startFrom, - result.lastEvaluatedTimeStamp, + result.nextId, maxItems ) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala index 43f6955a7..d2a9d031e 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala @@ -63,7 +63,7 @@ trait MembershipServiceAlgebra { def getGroupActivity( groupId: String, - startFrom: Option[String], + startFrom: Option[Int], maxItems: Int, authPrincipal: AuthPrincipal ): Result[ListGroupChangesResponse] diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ListZoneChangesResponse.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ListZoneChangesResponse.scala index e6fe8fd03..7c84acb0c 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ListZoneChangesResponse.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ListZoneChangesResponse.scala @@ -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( failedZoneChanges: List[ZoneChange] = Nil, nextId: Int, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala index 701207900..2d1ce0dd2 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala @@ -22,7 +22,7 @@ import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType import vinyldns.core.domain.record.RecordSetStatus.RecordSetStatus import vinyldns.core.domain.record.RecordType.RecordType 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.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( zoneId: String, name: String, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala index f2080410b..c4e99782c 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala @@ -23,7 +23,7 @@ import vinyldns.api.Interfaces import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.api.repository.ApiDataAccessor 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.queue.MessageQueue import vinyldns.core.domain.DomainHelpers.ensureTrailingDot @@ -226,6 +226,58 @@ class ZoneService( } }.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( zones: List[Zone], auth: AuthPrincipal, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala index 01a15ace5..daede2241 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala @@ -49,6 +49,14 @@ trait ZoneServiceAlgebra { includeReverse: Boolean ): Result[ListZonesResponse] + def listDeletedZones( + authPrincipal: AuthPrincipal, + nameFilter: Option[String], + startFrom: Option[String], + maxItems: Int, + ignoreAccess: Boolean + ): Result[ListDeletedZoneChangesResponse] + def listZoneChanges( zoneId: String, authPrincipal: AuthPrincipal, diff --git a/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala index 74efb970c..39fab7414 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala @@ -162,8 +162,8 @@ class MembershipRoute( } ~ path("groups" / Segment / "activity") { groupId => (get & monitor("Endpoint.groupActivity")) { - parameters("startFrom".?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { - (startFrom: Option[String], maxItems: Int) => + parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { + (startFrom: Option[Int], maxItems: Int) => handleRejections(invalidQueryHandler) { validate( 0 < maxItems && maxItems <= MAX_ITEMS_LIMIT, diff --git a/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala index 50605823b..dcbffe6ba 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala @@ -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 => (get & monitor("Endpoint.listFailedRecordSetChanges")) { parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { diff --git a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala index 5ba1b0aef..9cc289b5f 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala @@ -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") { (get & monitor("Endpoint.getBackendIds")) { authenticateAndExecute(_ => zoneService.getBackendIds()) { ids => diff --git a/modules/api/src/test/functional/tests/membership/get_group_changes_test.py b/modules/api/src/test/functional/tests/membership/get_group_changes_test.py index f1221a57a..77bdb7bf3 100644 --- a/modules/api/src/test/functional/tests/membership/get_group_changes_test.py +++ b/modules/api/src/test/functional/tests/membership/get_group_changes_test.py @@ -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 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 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])) -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 created_group = group_activity_context["created_group"] 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 - assert_that(result["changes"], has_length(5)) + assert_that(result["changes"], has_length(0)) 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): diff --git a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala index 3139d8cc7..32af41c88 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala @@ -1013,11 +1013,11 @@ class MembershipServiceSpec "return the group activity" in { val groupChangeRepoResponse = ListGroupChangesResults( listOfDummyGroupChanges.take(100), - Some(listOfDummyGroupChanges(100).id) + Some(listOfDummyGroupChanges.size) ) doReturn(IO.pure(groupChangeRepoResponse)) .when(mockGroupChangeRepo) - .getGroupChanges(anyString, any[Option[String]], anyInt) + .getGroupChanges(anyString, any[Option[Int]], anyInt) doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) .when(mockUserRepo) @@ -1031,18 +1031,18 @@ class MembershipServiceSpec underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value.unsafeRunSync().toOption.get result.changes should contain theSameElementsAs expected result.maxItems shouldBe 100 - result.nextId shouldBe Some(listOfDummyGroupChanges(100).id) + result.nextId shouldBe Some(listOfDummyGroupChanges.size) result.startFrom shouldBe None } "return group activity even if the user is not authorized" in { val groupChangeRepoResponse = ListGroupChangesResults( listOfDummyGroupChanges.take(100), - Some(listOfDummyGroupChanges(100).id) + Some(listOfDummyGroupChanges.size) ) doReturn(IO.pure(groupChangeRepoResponse)) .when(mockGroupChangeRepo) - .getGroupChanges(anyString, any[Option[String]], anyInt) + .getGroupChanges(anyString, any[Option[Int]], anyInt) doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1")))) .when(mockUserRepo) @@ -1056,7 +1056,7 @@ class MembershipServiceSpec underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value.unsafeRunSync().toOption.get result.changes should contain theSameElementsAs expected result.maxItems shouldBe 100 - result.nextId shouldBe Some(listOfDummyGroupChanges(100).id) + result.nextId shouldBe Some(listOfDummyGroupChanges.size) result.startFrom shouldBe None } } diff --git a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala index dc8789eb1..d45198ee5 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala @@ -57,6 +57,8 @@ class ZoneServiceSpec private val badConnection = ZoneConnection("bad", "bad", Encrypted("bad"), "bad") private val abcZoneSummary = ZoneSummaryInfo(abcZone, abcGroup.name, AccessLevel.Delete) 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 zoneIp6ZoneSummary = ZoneSummaryInfo(zoneIp6, abcGroup.name, AccessLevel.Delete) 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 { "retrieve the zone changes" in { doReturn(IO.pure(Some(okZone))) diff --git a/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala index 2289495f0..bce272e79 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala @@ -705,14 +705,14 @@ class MembershipRoutingSpec ) doReturn(result(expected)) .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 { status shouldBe StatusCodes.OK val maxItemsCaptor = ArgumentCaptor.forClass(classOf[Int]) verify(membershipService).getGroupActivity( anyString, - any[Option[String]], + any[Option[Int]], maxItemsCaptor.capture(), any[AuthPrincipal] ) diff --git a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala index 9b9f0673a..e3b15da57 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala @@ -117,6 +117,26 @@ class ZoneRoutingSpec Zone("zone6.in-addr.arpa.", "zone6@test.com", ZoneStatus.Active, adminGroupId = xyzGroup.id) private val zoneSummaryInfo6 = ZoneSummaryInfo(zone6, xyzGroup.name, AccessLevel.NoAccess) 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 = ("invalidField" -> "randomValue") ~~ @@ -395,6 +415,92 @@ class ZoneRoutingSpec 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( zoneId: String, 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 { "return the zone changes" in { Get(s"/zones/${ok.id}/changes") ~> zoneRoute ~> check { diff --git a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala index 408a9345c..c51a5b73b 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/membership/GroupChangeRepository.scala @@ -28,7 +28,7 @@ trait GroupChangeRepository extends Repository { def getGroupChanges( groupId: String, - startFrom: Option[String], + startFrom: Option[Int], maxItems: Int ): IO[ListGroupChangesResults] diff --git a/modules/core/src/main/scala/vinyldns/core/domain/membership/ListGroupChangesResults.scala b/modules/core/src/main/scala/vinyldns/core/domain/membership/ListGroupChangesResults.scala index 66b71c028..1ceeabf80 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/membership/ListGroupChangesResults.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/membership/ListGroupChangesResults.scala @@ -18,5 +18,5 @@ package vinyldns.core.domain.membership final case class ListGroupChangesResults( changes: Seq[GroupChange], - lastEvaluatedTimeStamp: Option[String] + nextId: Option[Int] ) diff --git a/modules/core/src/main/scala/vinyldns/core/domain/zone/ListZoneChangesResults.scala b/modules/core/src/main/scala/vinyldns/core/domain/zone/ListZoneChangesResults.scala index 5a44e4109..002f7280f 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/zone/ListZoneChangesResults.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/zone/ListZoneChangesResults.scala @@ -23,6 +23,15 @@ case class ListZoneChangesResults( 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( items: List[ZoneChange] = List[ZoneChange](), nextId: Int = 0, diff --git a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChangeRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChangeRepository.scala index 79a55ebf6..15ffcbebd 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChangeRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/zone/ZoneChangeRepository.scala @@ -17,6 +17,7 @@ package vinyldns.core.domain.zone import cats.effect._ +import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.repository.Repository trait ZoneChangeRepository extends Repository { @@ -29,6 +30,14 @@ trait ZoneChangeRepository extends Repository { maxItems: Int = 100 ): IO[ListZoneChangesResults] + def listDeletedZones( + authPrincipal: AuthPrincipal, + zoneNameFilter: Option[String] = None, + startFrom: Option[String] = None, + maxItems: Int = 100, + ignoreAccess: Boolean = false + ): IO[ListDeletedZonesChangeResults] + def listFailedZoneChanges( maxItems: Int = 100, startFrom: Int= 0 diff --git a/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala b/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala index d27a84fe5..c489938f5 100644 --- a/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala +++ b/modules/core/src/test/scala/vinyldns/core/TestZoneData.scala @@ -52,9 +52,21 @@ object TestZoneData { 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( "some.deleted.zone.", "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, connection = testConnection ) @@ -89,6 +101,22 @@ object TestZoneData { 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 = ZoneChange(zone, "userId", ZoneChangeType.Update, ZoneChangeStatus.Pending) diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupChangeRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupChangeRepositoryIntegrationSpec.scala index 895d12203..15593f6bf 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupChangeRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlGroupChangeRepositoryIntegrationSpec.scala @@ -102,7 +102,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec } "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 changes = generateGroupChanges(groupId, 50) changes.map(saveGroupChangeData(repo, _).unsafeRunSync()) @@ -113,7 +113,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec val listResponse = repo.getGroupChanges(groupId, None, 100).unsafeRunSync() listResponse.changes shouldBe expectedChanges - listResponse.lastEvaluatedTimeStamp shouldBe None + listResponse.nextId shouldBe None } "get group changes properly using a maxItems of 1" in { @@ -130,9 +130,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec val listResponse = repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync() listResponse.changes shouldBe expectedChanges - listResponse.lastEvaluatedTimeStamp shouldBe Some( - expectedChanges.head.created.toEpochMilli.toString - ) + listResponse.nextId shouldBe Some(1) } "page group changes using a startFrom and maxItems" in { @@ -145,33 +143,33 @@ class MySqlGroupChangeRepositoryIntegrationSpec .reverse val expectedPageOne = Seq(changesSorted(0)) - val expectedPageOneNext = Some(changesSorted(0).created.toEpochMilli.toString) + val expectedPageOneNext = Some(1) val expectedPageTwo = Seq(changesSorted(1)) - val expectedPageTwoNext = Some(changesSorted(1).created.toEpochMilli.toString) + val expectedPageTwoNext = Some(2) val expectedPageThree = Seq(changesSorted(2)) - val expectedPageThreeNext = Some(changesSorted(2).created.toEpochMilli.toString) + val expectedPageThreeNext = Some(3) // get first page val pageOne = repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync() pageOne.changes shouldBe expectedPageOne - pageOne.lastEvaluatedTimeStamp shouldBe expectedPageOneNext + pageOne.nextId shouldBe expectedPageOneNext // get second page val pageTwo = repo - .getGroupChanges(groupId, startFrom = pageOne.lastEvaluatedTimeStamp, maxItems = 1) + .getGroupChanges(groupId, startFrom = pageOne.nextId, maxItems = 1) .unsafeRunSync() pageTwo.changes shouldBe expectedPageTwo - pageTwo.lastEvaluatedTimeStamp shouldBe expectedPageTwoNext + pageTwo.nextId shouldBe expectedPageTwoNext // get final page val pageThree = repo - .getGroupChanges(groupId, startFrom = pageTwo.lastEvaluatedTimeStamp, maxItems = 1) + .getGroupChanges(groupId, startFrom = pageTwo.nextId, maxItems = 1) .unsafeRunSync() pageThree.changes shouldBe expectedPageThree - pageThree.lastEvaluatedTimeStamp shouldBe expectedPageThreeNext + pageThree.nextId shouldBe expectedPageThreeNext } } } diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneChangeRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneChangeRepositoryIntegrationSpec.scala index ab1758f76..b40c50775 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneChangeRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneChangeRepositoryIntegrationSpec.scala @@ -33,6 +33,9 @@ import vinyldns.core.TestZoneData.okZone import vinyldns.core.TestZoneData.testConnection import vinyldns.core.domain.Encrypted 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.util.Random @@ -92,6 +95,7 @@ class MySqlZoneChangeRepositoryIntegrationSpec status= ZoneChangeStatus.Failed, created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000)) ) + val successChanges : IndexedSeq[ZoneChange] = for { zone <- zones } yield ZoneChange( zone, @@ -100,6 +104,84 @@ class MySqlZoneChangeRepositoryIntegrationSpec status= ZoneChangeStatus.Synced, 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._ @@ -303,5 +385,145 @@ class MySqlZoneChangeRepositoryIntegrationSpec pageThree.nextId should equal(None) 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 + } } } diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala index 7b0c19b32..9c54eef8f 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlZoneRepositoryIntegrationSpec.scala @@ -952,12 +952,13 @@ class MySqlZoneRepositoryIntegrationSpec "check if an id has an ACL rule for at least one of the zones" in { val zoneId = UUID.randomUUID().toString + val adminId = UUID.randomUUID().toString val testZones = (1 until 3).map { num => okZone.copy( name = num.toString + ".", id = zoneId, - adminGroupId = testZoneAdminGroupId, + adminGroupId = adminId, acl = testZoneAcl ) } @@ -965,7 +966,7 @@ class MySqlZoneRepositoryIntegrationSpec val f = for { _ <- saveZones(testZones) - zones <- repo.getFirstOwnedZoneAclGroupId(testZoneAdminGroupId) + zones <- repo.getFirstOwnedZoneAclGroupId(adminId) } yield zones f.unsafeRunSync() shouldBe Some(zoneId) diff --git a/modules/mysql/src/main/resources/db/migration/V3.30__AddStatusZoneAccess.sql b/modules/mysql/src/main/resources/db/migration/V3.30__AddStatusZoneAccess.sql new file mode 100644 index 000000000..eca6b16f8 --- /dev/null +++ b/modules/mysql/src/main/resources/db/migration/V3.30__AddStatusZoneAccess.sql @@ -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 +; \ No newline at end of file diff --git a/modules/mysql/src/main/resources/test/ddl.sql b/modules/mysql/src/main/resources/test/ddl.sql index 4889127a9..d310c48d0 100644 --- a/modules/mysql/src/main/resources/test/ddl.sql +++ b/modules/mysql/src/main/resources/test/ddl.sql @@ -237,10 +237,7 @@ CREATE TABLE IF NOT EXISTS zone_access ( accessor_id char(36) not null, zone_id char(36) not null, - primary key (accessor_id, zone_id), - constraint fk_zone_access_zone_id - foreign key (zone_id) references zone (id) - on delete cascade + primary key (accessor_id, zone_id) ); CREATE INDEX IF NOT EXISTS zone_access_accessor_id_index diff --git a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupChangeRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupChangeRepository.scala index 2773a7ca0..d8c200408 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupChangeRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlGroupChangeRepository.scala @@ -47,9 +47,9 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored { sql""" |SELECT data | FROM group_change - | WHERE group_id = {groupId} AND created_timestamp < {startFrom} + | WHERE group_id = {groupId} | ORDER BY created_timestamp DESC - | LIMIT {maxItems} + | LIMIT {maxItems} OFFSET {startFrom} """.stripMargin private final val LIST_GROUP_CHANGE_NO_START = @@ -100,7 +100,7 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored { def getGroupChanges( groupId: String, - startFrom: Option[String], + startFrom: Option[Int], maxItems: Int ): IO[ListGroupChangesResults] = monitor("repo.GroupChange.getGroupChanges") { @@ -112,21 +112,25 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored { val query = startFrom match { case Some(start) => LIST_GROUP_CHANGES_WITH_START - .bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> maxItems) + .bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> (maxItems + 1)) case None => LIST_GROUP_CHANGE_NO_START - .bindByName('groupId -> groupId, 'maxItems -> maxItems) + .bindByName('groupId -> groupId, 'maxItems -> (maxItems + 1)) } val queryResult = query .map(toGroupChange(1)) .list() .apply() - val nextId = - if (queryResult.size < maxItems) None - else queryResult.lastOption.map(_.created.toEpochMilli.toString) + val maxQueries = queryResult.take(maxItems) + val startValue = startFrom.getOrElse(0) - ListGroupChangesResults(queryResult, nextId) + val nextId = queryResult match { + case _ if queryResult.size <= maxItems | queryResult.isEmpty => None + case _ => Some(startValue + maxItems) + } + + ListGroupChangesResults(maxQueries, nextId) } } } diff --git a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneChangeRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneChangeRepository.scala index cf0614431..b0b5a4192 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneChangeRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneChangeRepository.scala @@ -17,10 +17,13 @@ package vinyldns.mysql.repository import cats.effect.IO + import java.time.Instant import java.time.temporal.ChronoUnit import org.slf4j.LoggerFactory import scalikejdbc._ +import vinyldns.core.domain.auth.AuthPrincipal +import vinyldns.core.domain.membership.User import vinyldns.core.domain.zone._ import vinyldns.core.protobuf._ import vinyldns.core.route.Monitored @@ -32,12 +35,26 @@ class MySqlZoneChangeRepository with Monitored { private final val logger = LoggerFactory.getLogger(classOf[MySqlZoneChangeRepository]) + private final val MAX_ACCESSORS = 30 + private final val PUT_ZONE_CHANGE = sql""" |REPLACE INTO zone_change (change_id, zone_id, data, created_timestamp) | VALUES ({change_id}, {zone_id}, {data}, {created_timestamp}) """.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 = sql""" |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] = monitor("repo.ZoneChange.listFailedZoneChanges") { IO { @@ -130,4 +247,8 @@ class MySqlZoneChangeRepository private def extractZoneChange(colIndex: Int): WrappedResultSet => ZoneChange = res => { fromPB(VinylDNSProto.ZoneChange.parseFrom(res.bytes(colIndex))) } + + private def extractZone(columnIndex: Int): WrappedResultSet => Zone = res => { + fromPB(VinylDNSProto.Zone.parseFrom(res.bytes(columnIndex))) + } } diff --git a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala index c61a1ea14..6a15a9853 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlZoneRepository.scala @@ -71,10 +71,17 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M */ private final val PUT_ZONE_ACCESS = sql""" - |REPLACE INTO zone_access(accessor_id, zone_id) - | VALUES ({accessorId}, {zoneId}) + |REPLACE INTO zone_access(accessor_id, zone_id, zone_status) + | VALUES ({accessorId}, {zoneId}, {zoneStatus}) """.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 = sql""" |DELETE @@ -127,8 +134,8 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M private final val GET_ZONE_ACCESS_BY_ADMIN_GROUP_ID = sql""" |SELECT zone_id - | FROM zone_access z - | WHERE z.accessor_id = (?) + | FROM zone_access za + | WHERE za.accessor_id = (?) AND za.zone_status <> 'Deleted' | LIMIT 1 """.stripMargin @@ -503,10 +510,10 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M val sqlParameters: Seq[Seq[(Symbol, Any)]] = zone.acl.rules.toSeq .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 - 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 PUT_ZONE_ACCESS.batchByName(allAccessors.distinct: _*).apply() @@ -518,6 +525,12 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M 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 = { DELETE_ZONE_ACCESS.bind(zone.id).update().apply() zone @@ -532,6 +545,7 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M IO { DB.localTx { implicit s => deleteZone(zone) + updateZoneAccess(zone) } } } diff --git a/modules/portal/app/controllers/VinylDNS.scala b/modules/portal/app/controllers/VinylDNS.scala index 68a011ea9..c9affbf85 100644 --- a/modules/portal/app/controllers/VinylDNS.scala +++ b/modules/portal/app/controllers/VinylDNS.scala @@ -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 => val queryParameters = new HashMap[String, java.util.List[String]]() for { diff --git a/modules/portal/app/views/dnsChanges/dnsChanges.scala.html b/modules/portal/app/views/dnsChanges/dnsChanges.scala.html index c3a143d71..80d3b6acf 100644 --- a/modules/portal/app/views/dnsChanges/dnsChanges.scala.html +++ b/modules/portal/app/views/dnsChanges/dnsChanges.scala.html @@ -12,7 +12,7 @@ -

DNS Changes {{ getPageTitle() }}

+

DNS Changes

@@ -58,7 +58,8 @@
-
+
+ {{ getPageTitle() }}