2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-30 22:05:21 +00:00

Merge branch 'master' into fix_change_failure_metrics

This commit is contained in:
Arpit Shah
2023-07-21 14:10:40 -04:00
committed by GitHub
34 changed files with 1173 additions and 70 deletions

View File

@@ -256,7 +256,7 @@ class MembershipService(
_ <- isGroupChangePresent(result).toResult
_ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult
allUserIds = getGroupUserIds(Seq(result.get))
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap)
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user"))
groupChangeMessage <- determineGroupDifference(Seq(result.get), allUserMap)
groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = Seq(result.get).map(_.userId).toSet
@@ -276,7 +276,7 @@ class MembershipService(
.getGroupChanges(groupId, startFrom, maxItems)
.toResult[ListGroupChangesResults]
allUserIds = getGroupUserIds(result.changes)
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap)
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user"))
groupChangeMessage <- determineGroupDifference(result.changes, allUserMap)
groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = result.changes.map(_.userId).toSet
@@ -316,7 +316,8 @@ class MembershipService(
sb.append(s"Group email changed to '${change.newGroup.email}'. ")
}
if (change.oldGroup.get.description != change.newGroup.description) {
sb.append(s"Group description changed to '${change.newGroup.description.get}'. ")
val description = if(change.newGroup.description.isEmpty) "" else change.newGroup.description.get
sb.append(s"Group description changed to '$description'. ")
}
val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds)
if (adminAddDifference.nonEmpty) {

View File

@@ -42,6 +42,29 @@ object ListRecordSetChangesResponse {
)
}
case class ListRecordSetHistoryResponse(
zoneId: Option[String],
recordSetChanges: List[RecordSetChangeInfo] = Nil,
nextId: Option[Int],
startFrom: Option[Int],
maxItems: Int
)
object ListRecordSetHistoryResponse {
def apply(
zoneId: Option[String],
listResults: ListRecordSetChangesResults,
info: List[RecordSetChangeInfo]
): ListRecordSetHistoryResponse =
ListRecordSetHistoryResponse(
zoneId,
info,
listResults.nextId,
listResults.startFrom,
listResults.maxItems
)
}
case class ListFailedRecordSetChangesResponse(
failedRecordSetChanges: List[RecordSetChange] = Nil,
nextId: Int,

View File

@@ -576,20 +576,38 @@ class RecordSetService(
} yield change
def listRecordSetChanges(
zoneId: String,
zoneId: Option[String] = None,
startFrom: Option[Int] = None,
maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None,
authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] =
for {
zone <- getZone(zoneId.get)
_ <- canSeeZone(authPrincipal, zone).toResult
recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(Some(zone.id), startFrom, maxItems, fqdn, recordType)
.toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
} yield ListRecordSetChangesResponse(zoneId.get, recordSetChangesResults, recordSetChangesInfo)
def listRecordSetChangeHistory(
zoneId: Option[String] = None,
startFrom: Option[Int] = None,
maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None,
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse] =
for {
zone <- getZone(zoneId)
_ <- canSeeZone(authPrincipal, zone).toResult
recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(zone.id, startFrom, maxItems)
.listRecordSetChanges(zoneId, startFrom, maxItems, fqdn, recordType)
.toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
} yield ListRecordSetChangesResponse(zoneId, recordSetChangesResults, recordSetChangesInfo)
_ <- if(recordSetChangesResults.items.nonEmpty) canSeeZone(authPrincipal, recordSetChangesInfo.map(_.zone).head).toResult else ().toResult
zoneId = if(recordSetChangesResults.items.nonEmpty) Some(recordSetChangesResults.items.map(x => x.zone.id).head) else None
} yield ListRecordSetHistoryResponse(zoneId, recordSetChangesResults, recordSetChangesInfo)
def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal,

View File

@@ -101,12 +101,23 @@ trait RecordSetServiceAlgebra {
): Result[RecordSetChange]
def listRecordSetChanges(
zoneId: String,
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse]
def listRecordSetChangeHistory(
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse]
def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal,
startFrom: Int,

View File

@@ -78,6 +78,28 @@ object ZoneInfo {
)
}
case class ZoneDetails(
name: String,
email: String,
status: ZoneStatus,
adminGroupId: String,
adminGroupName: String,
)
object ZoneDetails {
def apply(
zone: Zone,
groupName: String,
): ZoneDetails =
ZoneDetails(
name = zone.name,
email = zone.email,
status = zone.status,
adminGroupId = zone.adminGroupId,
adminGroupName = groupName,
)
}
case class ZoneSummaryInfo(
name: String,
email: String,

View File

@@ -153,6 +153,12 @@ class ZoneService(
accessLevel = getZoneAccess(auth, zone)
} yield ZoneInfo(zone, aclInfo, groupName, accessLevel)
def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] =
for {
zone <- getZoneOrFail(zoneId)
groupName <- getGroupName(zone.adminGroupId)
} yield ZoneDetails(zone, groupName)
def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] =
for {
zone <- getZoneByNameOrFail(ensureTrailingDot(zoneName))

View File

@@ -35,6 +35,8 @@ trait ZoneServiceAlgebra {
def getZone(zoneId: String, auth: AuthPrincipal): Result[ZoneInfo]
def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails]
def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo]
def listZones(

View File

@@ -223,8 +223,8 @@ class RecordSetRoute(
} ~
path("zones" / Segment / "recordsetchanges") { zoneId =>
(get & monitor("Endpoint.listRecordSetChanges")) {
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[Int], maxItems: Int) =>
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) {
(startFrom: Option[Int], maxItems: Int, fqdn: Option[String], _: Option[String]) =>
handleRejections(invalidQueryHandler) {
validate(
check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS,
@@ -233,7 +233,34 @@ class RecordSetRoute(
) {
authenticateAndExecute(
recordSetService
.listRecordSetChanges(zoneId, startFrom, maxItems, _)
.listRecordSetChanges(Some(zoneId), startFrom, maxItems, fqdn, None, _)
) { changes =>
complete(StatusCodes.OK, changes)
}
}
}
}
}
} ~
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)
}

View File

@@ -29,6 +29,7 @@ import vinyldns.core.domain.zone._
import scala.concurrent.duration._
case class GetZoneResponse(zone: ZoneInfo)
case class GetZoneDetailsResponse(zone: ZoneDetails)
case class ZoneRejected(zone: Zone, errors: List[String])
class ZoneRoute(
@@ -142,6 +143,13 @@ class ZoneRoute(
}
}
} ~
path("zones" / Segment / "details") { id =>
(get & monitor("Endpoint.getCommonZoneDetails")) {
authenticateAndExecute(zoneService.getCommonZoneDetails(id, _)) { zone =>
complete(StatusCodes.OK, GetZoneDetailsResponse(zone))
}
}
} ~
path("zones" / Segment / "sync") { id =>
(post & monitor("Endpoint.syncZone")) {
authenticateAndExecute(zoneService.syncZone(id, _)) { chg =>

View File

@@ -30,6 +30,38 @@ def check_changes_response(response, recordChanges=False, nextId=False, startFro
assert_that(change["userName"], is_("history-user"))
def check_change_history_response(response, fqdn, type, recordChanges=False, nextId=False, startFrom=False, maxItems=100):
"""
:param type: type of the record
:param fqdn: fqdn of the record
:param response: return value of list_recordset_changes()
:param recordChanges: true if not empty or False if empty, cannot check exact values because don't have access to all attributes
:param nextId: true if exists, false if doesn't, wouldn't be able to check exact value
:param startFrom: the string for startFrom or false if doesnt exist
:param maxItems: maxItems is defined as an Int by default so will always return an Int
"""
assert_that(response, has_key("zoneId")) # always defined as random string
if recordChanges:
assert_that(response["recordSetChanges"], is_not(has_length(0)))
else:
assert_that(response["recordSetChanges"], has_length(0))
if nextId:
assert_that(response, has_key("nextId"))
else:
assert_that(response, is_not(has_key("nextId")))
if startFrom:
assert_that(response["startFrom"], is_(startFrom))
else:
assert_that(response, is_not(has_key("startFrom")))
assert_that(response["maxItems"], is_(maxItems))
for change in response["recordSetChanges"]:
assert_that(change["userName"], is_("history-user"))
for recordset in change["recordSet"]:
assert_that(change["recordSet"]["type"], is_(type))
assert_that(change["recordSet"]["name"] + "." + response["recordSetChanges"][0]["zone"]["name"], is_(fqdn))
def test_list_recordset_changes_no_authorization(shared_zone_test_context):
"""
Test that recordset changes without authorization fails
@@ -174,3 +206,101 @@ def test_list_recordset_changes_max_items_boundaries(shared_zone_test_context):
assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive"))
assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive"))
def test_list_recordset_history_no_authorization(shared_zone_test_context):
"""
Test that recordset history without authorization fails
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
client.list_recordset_change_history(fqdn, type, sign_request=False, status=401)
def test_list_recordset_history_member_auth_success(shared_zone_test_context):
"""
Test recordset history succeeds with membership auth for member of admin group
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(fqdn, type, status=200)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False)
def test_list_recordset_history_member_auth_no_access(shared_zone_test_context):
"""
Test recordset history fails for user not in admin group with no acl rules
"""
client = shared_zone_test_context.ok_vinyldns_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
client.list_recordset_change_history(fqdn, type, status=403)
def test_list_recordset_history_success(shared_zone_test_context):
"""
Test recordset history succeeds with membership auth for member of admin group
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(fqdn, type, status=200)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False)
def test_list_recordset_history_paging(shared_zone_test_context):
"""
Test paging for recordset history can use previous nextId as start key of next page
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response_1 = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=1)
response_2 = client.list_recordset_change_history(fqdn, type, start_from=response_1["nextId"], max_items=1)
check_change_history_response(response_1, fqdn, type, recordChanges=True, nextId=True, startFrom=False, maxItems=1)
check_change_history_response(response_2, fqdn, type, recordChanges=True, nextId=True, startFrom=response_1["nextId"], maxItems=1)
def test_list_recordset_history_returning_no_changes(shared_zone_test_context):
"""
Pass in startFrom of "2000" should return empty list because start key exceeded number of recordset change history
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(fqdn, type, start_from=2000, max_items=None)
assert_that(response["recordSetChanges"], has_length(0))
assert_that(response["startFrom"], is_(2000))
assert_that(response["maxItems"], is_(100))
def test_list_recordset_history_default_max_items(shared_zone_test_context):
"""
Test default max items is 100
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=None)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False, maxItems=100)
def test_list_recordset_history_max_items_boundaries(shared_zone_test_context):
"""
Test 0 < max_items <= 100
"""
client = shared_zone_test_context.history_client
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
too_large = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=101, status=400)
too_small = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=0, status=400)
assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive"))
assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive"))

View File

@@ -46,6 +46,7 @@ def test_get_zone_shared_by_id_non_owner(shared_zone_test_context):
assert_that(retrieved["shared"], is_(True))
assert_that(retrieved["accessLevel"], is_("Read"))
def test_get_zone_private_by_id_fails_without_access(shared_zone_test_context):
"""
Test get an existing zone by id without access
@@ -72,6 +73,29 @@ def test_get_zone_by_id_no_authorization(shared_zone_test_context):
client.get_zone("123456", sign_request=False, status=401)
def test_get_common_zone_details_by_id(shared_zone_test_context):
"""
Test get an existing zone's common details by id
"""
client = shared_zone_test_context.ok_vinyldns_client
result = client.get_common_zone_details(shared_zone_test_context.system_test_zone["id"], status=200)
retrieved = result["zone"]
assert_that(retrieved["name"], is_(shared_zone_test_context.system_test_zone["name"]))
assert_that(retrieved["email"], is_(shared_zone_test_context.system_test_zone["email"]))
assert_that(retrieved["adminGroupName"], is_(shared_zone_test_context.ok_group["name"]))
assert_that(retrieved["adminGroupId"], is_(shared_zone_test_context.ok_group["id"]))
def test_get_common_zone_details_by_id_returns_404_when_not_found(shared_zone_test_context):
"""
Test get an existing zone returns a 404 when the zone is not found
"""
client = shared_zone_test_context.ok_vinyldns_client
client.get_common_zone_details(str(uuid.uuid4()), status=404)
@pytest.mark.serial
def test_get_zone_by_id_includes_acl_display_name(shared_zone_test_context):
"""

View File

@@ -51,7 +51,8 @@ class VinylDNSClient(object):
self.session.close()
self.session_not_found_ok.close()
def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504), session=None):
def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504),
session=None):
session = session or requests.Session()
retry = Retry(
total=retries,
@@ -79,7 +80,8 @@ class VinylDNSClient(object):
session.mount("https://", adapter)
return session
def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False, **kwargs):
def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False,
**kwargs):
# pull out status or None
status_code = kwargs.pop("status", None)
@@ -100,13 +102,15 @@ class VinylDNSClient(object):
for k, v in query.items())
if sign_request:
signed_headers, signed_body = self.sign_request(method, path, body_string, query, with_headers=headers or {}, **kwargs)
signed_headers, signed_body = self.sign_request(method, path, body_string, query,
with_headers=headers or {}, **kwargs)
else:
signed_headers = headers or {}
signed_body = body_string
if not_found_ok:
response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers, **kwargs)
response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers,
**kwargs)
else:
response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs)
@@ -388,6 +392,17 @@ class VinylDNSClient(object):
return data
def get_common_zone_details(self, zone_id, **kwargs):
"""
Gets common zone details which can be seen by all users for the given zone id
:param zone_id: the id of the zone to retrieve
:return: the zone, or will 404 if not found
"""
url = urljoin(self.index_url, "/zones/{0}/details".format(zone_id))
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def get_zone_by_name(self, zone_name, **kwargs):
"""
Gets a zone for the given zone name
@@ -445,7 +460,29 @@ class VinylDNSClient(object):
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False, ignore_access=False, **kwargs):
def list_recordset_change_history(self, fqdn, record_type, start_from=None, max_items=None, **kwargs):
"""
Gets the record's change history for the given record fqdn and record type
:param fqdn: the record's fqdn
:param record_type: the record's type
:param start_from: the start key of the page
:param max_items: the page limit
:return: the zone, or will 404 if not found
"""
args = []
if start_from:
args.append("startFrom={0}".format(start_from))
if max_items is not None:
args.append("maxItems={0}".format(max_items))
args.append("fqdn={0}".format(fqdn))
args.append("recordType={0}".format(record_type))
url = urljoin(self.index_url, "recordsetchange/history") + "?" + "&".join(args)
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False,
ignore_access=False, **kwargs):
"""
Gets a list of zones that currently exist
:return: a list of zones
@@ -535,7 +572,8 @@ class VinylDNSClient(object):
response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs)
return data
def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None, record_type_filter=None, name_sort=None, **kwargs):
def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None,
record_type_filter=None, name_sort=None, **kwargs):
"""
Retrieves all recordsets in a zone
:param zone_id: the zone to retrieve
@@ -618,7 +656,8 @@ class VinylDNSClient(object):
_, data = self.make_request(url, "POST", self.headers, **kwargs)
return data
def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None, **kwargs):
def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None,
**kwargs):
"""
Gets list of user's batch change summaries
:return: the content of the response
@@ -660,7 +699,8 @@ class VinylDNSClient(object):
:return: the content of the response
"""
url = urljoin(self.index_url, "/zones/{0}/acl/rules".format(zone_id))
response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request, **kwargs)
response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request,
**kwargs)
return data
@@ -804,7 +844,8 @@ class VinylDNSClient(object):
while change["status"] != expected_status and retries > 0:
time.sleep(RETRY_WAIT)
retries -= 1
latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"], change["id"], status=(200, 404))
latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"],
change["id"], status=(200, 404))
if type(latest_change) != str:
change = latest_change

View File

@@ -184,6 +184,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(Seq(), None)))
.when(mockUserRepo)
.getUsers(Set.empty, None, None)
doReturn(IO.pure(Some(okGroup)))
.when(mockGroupRepo)
.getGroup(okGroup.id)
val result: RecordSetChange =
underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get
@@ -468,7 +471,7 @@ class RecordSetServiceSpec
result.recordSet.ownerGroupId shouldBe Some(okGroup.id)
}
"fail if user is in not owner group" in {
"fail if user is not in owner group" in {
val record = aaaa.copy(ownerGroupId = Some(dummyGroup.id))
doReturn(IO.pure(List()))
@@ -583,6 +586,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None)))
.when(mockUserRepo)
.getUsers(dummyGroup.memberIds, None, None)
doReturn(IO.pure(Some(xyzGroup)))
.when(mockGroupRepo)
.getGroup(xyzGroup.id)
// passes as all three properties within dotted hosts config (allowed zones, users and record types) are satisfied
val result: RecordSetChange =
@@ -787,6 +793,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None)))
.when(mockUserRepo)
.getUsers(dummyGroup.memberIds, None, None)
doReturn(IO.pure(Some(abcGroup)))
.when(mockGroupRepo)
.getGroup(abcGroup.id)
// fails as only two properties within dotted hosts config (zones and record types) are satisfied while user is not allowed
val result = underTest.addRecordSet(record, abcAuth).value.unsafeRunSync().swap.toOption.get
@@ -1932,13 +1941,13 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListRecordSetChangesResults(completeRecordSetChanges)))
.when(mockRecordChangeRepo)
.listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100)
.listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None)
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListRecordSetChangesResponse =
underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get
underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get
val changesWithName =
completeRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok")))
val expectedResults = ListRecordSetChangesResponse(
@@ -1951,13 +1960,42 @@ class RecordSetServiceSpec
result shouldBe expectedResults
}
"retrieve the recordset changes based on fqdn and record type" in {
val filteredRecordSetChanges: List[RecordSetChange] =
List(pendingCreateAAAA, completeCreateAAAA)
val zoneId = filteredRecordSetChanges.head.zoneId
doReturn(IO.pure(ListRecordSetChangesResults(filteredRecordSetChanges)))
.when(mockRecordChangeRepo)
.listRecordSetChanges(zoneId = None, startFrom = None, maxItems = 100, fqdn = Some("aaaa.ok.zone.recordsets."), recordType = Some(RecordType.AAAA))
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListRecordSetHistoryResponse =
underTest.listRecordSetChangeHistory(None, fqdn = Some("aaaa.ok.zone.recordsets."), recordType = Some(RecordType.AAAA), authPrincipal = okAuth).value.unsafeRunSync().toOption.get
val changesWithName =
filteredRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok")))
val expectedResults = ListRecordSetHistoryResponse(
zoneId = Some(zoneId),
recordSetChanges = changesWithName,
nextId = None,
startFrom = None,
maxItems = 100
)
result shouldBe expectedResults
}
"return a zone with no changes if no changes exist" in {
doReturn(IO.pure(ListRecordSetChangesResults(items = Nil)))
.when(mockRecordChangeRepo)
.listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100)
.listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None)
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListRecordSetChangesResponse =
underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get
underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get
val expectedResults = ListRecordSetChangesResponse(
zoneId = okZone.id,
recordSetChanges = List(),
@@ -2023,7 +2061,7 @@ class RecordSetServiceSpec
"return a NotAuthorizedError" in {
val error =
underTest.listRecordSetChanges(zoneNotAuthorized.id, authPrincipal = okAuth).value.unsafeRunSync().swap.toOption.get
underTest.listRecordSetChanges(Some(zoneNotAuthorized.id), authPrincipal = okAuth).value.unsafeRunSync().swap.toOption.get
error shouldBe a[NotAuthorizedError]
}
@@ -2034,10 +2072,13 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListRecordSetChangesResults(List(rsChange2, rsChange1))))
.when(mockRecordChangeRepo)
.listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100)
.listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None)
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListRecordSetChangesResponse =
underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get
underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get
val changesWithName =
List(RecordSetChangeInfo(rsChange2, Some("ok")), RecordSetChangeInfo(rsChange1, Some("ok")))
val expectedResults = ListRecordSetChangesResponse(

View File

@@ -799,6 +799,46 @@ class ZoneServiceSpec
}
}
"Getting a zone details" should {
"fail with no zone returned" in {
doReturn(IO.pure(None)).when(mockZoneRepo).getZone("notAZoneId")
val error = underTest.getCommonZoneDetails("notAZoneId", okAuth).value.unsafeRunSync().swap.toOption.get
error shouldBe a[ZoneNotFoundError]
}
"return zone details even if the user is not authorized for the zone" in {
doReturn(IO.pure(Some(okZone))).when(mockZoneRepo).getZone(anyString)
val noAuth = AuthPrincipal(TestDataLoader.okUser, Seq())
val result = underTest.getCommonZoneDetails(okZone.id, noAuth).value.unsafeRunSync()
val expectedZoneDetails =
ZoneDetails(okZone, okGroup.name)
result.right.value shouldBe expectedZoneDetails
}
"return the appropriate zone as a ZoneDetails" in {
doReturn(IO.pure(Some(abcZone))).when(mockZoneRepo).getZone(abcZone.id)
doReturn(IO.pure(Some(abcGroup))).when(mockGroupRepo).getGroup(anyString)
val expectedZoneDetails =
ZoneDetails(abcZone, abcGroup.name)
val result = underTest.getCommonZoneDetails(abcZone.id, abcAuth).value.unsafeRunSync()
result.right.value shouldBe expectedZoneDetails
}
"return Unknown group name if zone admin group cannot be found" in {
doReturn(IO.pure(Some(abcZone))).when(mockZoneRepo).getZone(abcZone.id)
doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(anyString)
val expectedZoneDetails =
ZoneDetails(abcZone, "Unknown group name")
val result: ZoneDetails = underTest.getCommonZoneDetails(abcZone.id, abcAuth).value.unsafeRunSync().toOption.get
result shouldBe expectedZoneDetails
}
}
"ListZones" should {
"not fail with no zones returned" in {
doReturn(IO.pure(ListZonesResults(List())))

View File

@@ -556,7 +556,8 @@ class ZoneSyncHandlerSpec
verify(recordChangeRepo).save(any[DB], captor.capture())
val req = captor.getValue
anonymize(req) shouldBe anonymize(testChangeSet)
val expectedRecordSetChanges = testChangeSet.changes
anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges))
}
"save the record changes to the recordSetCacheRepo" in {
@@ -566,7 +567,8 @@ class ZoneSyncHandlerSpec
verify(recordSetCacheRepo).save(any[DB], captor.capture())
val req = captor.getValue
anonymize(req) shouldBe anonymize(testChangeSet)
val expectedRecordSetChanges = testChangeSet.changes
anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges))
}
@@ -577,7 +579,8 @@ class ZoneSyncHandlerSpec
verify(recordSetRepo).apply(any[DB], captor.capture())
val req = captor.getValue
anonymize(req) shouldBe anonymize(testChangeSet)
val expectedRecordSetChanges = testChangeSet.changes
anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges))
}
"returns the zone as active and sets the latest sync" in {

View File

@@ -29,7 +29,7 @@ import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import vinyldns.api.Interfaces._
import vinyldns.api.config.LimitsConfig
import vinyldns.api.domain.record.{ListFailedRecordSetChangesResponse, ListRecordSetChangesResponse, RecordSetServiceAlgebra}
import vinyldns.api.domain.record.{ListFailedRecordSetChangesResponse, ListRecordSetChangesResponse, ListRecordSetHistoryResponse, RecordSetServiceAlgebra}
import vinyldns.api.domain.zone._
import vinyldns.core.TestMembershipData.okAuth
import vinyldns.core.domain.Fqdn
@@ -409,6 +409,16 @@ class RecordSetRoutingSpec
maxItems = 100
)
private val changeWithUserName =
List(RecordSetChangeInfo(rsChange1, Some("ok")))
private val listRecordSetChangeHistoryResponse = ListRecordSetHistoryResponse(
Some(okZone.id),
changeWithUserName,
nextId = None,
startFrom = None,
maxItems = 100
)
private val failedChangesWithUserName =
List(rsChange1.copy(status = RecordSetChangeStatus.Failed) , rsChange2.copy(status = RecordSetChangeStatus.Failed))
private val listFailedRecordSetChangeResponse = ListFailedRecordSetChangesResponse(
@@ -736,18 +746,35 @@ class RecordSetRoutingSpec
}.toResult
def listRecordSetChanges(
zoneId: String,
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] = {
zoneId match {
case zoneNotFound.id => Left(ZoneNotFoundError(s"$zoneId"))
case notAuthorizedZone.id => Left(NotAuthorizedError("no way"))
case Some(zoneNotFound.id) => Left(ZoneNotFoundError(s"$zoneId"))
case Some(notAuthorizedZone.id) => Left(NotAuthorizedError("no way"))
case _ => Right(listRecordSetChangesResponse)
}
}.toResult
def listRecordSetChangeHistory(
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse] = {
zoneId match {
case Some(zoneNotFound.id) => Left(ZoneNotFoundError(s"$zoneId"))
case Some(notAuthorizedZone.id) => Left(NotAuthorizedError("no way"))
case _ => Right(listRecordSetChangeHistoryResponse)
}
}.toResult
}
val recordSetService: RecordSetServiceAlgebra = new TestService
@@ -883,6 +910,33 @@ class RecordSetRoutingSpec
}
}
"GET recordset change history" should {
"return the recordset change" in {
Get(s"/recordsetchange/history?fqdn=rs1.ok.&recordType=A") ~> recordSetRoute ~> check {
val response = responseAs[ListRecordSetHistoryResponse]
response.zoneId shouldBe Some(okZone.id)
(response.recordSetChanges.map(_.id) should contain)
.only(rsChange1.id)
}
}
"return an error when the record fqdn and type is not defined" in {
Get(s"/recordsetchange/history") ~> recordSetRoute ~> check {
status shouldBe StatusCodes.BadRequest
}
}
"return a Bad Request when maxItems is out of Bounds" in {
Get(s"/recordsetchange/history?maxItems=101") ~> recordSetRoute ~> check {
status shouldBe StatusCodes.BadRequest
}
Get(s"/recordsetchange/history?maxItems=0") ~> recordSetRoute ~> check {
status shouldBe StatusCodes.BadRequest
}
}
}
"GET failed record set changes" should {
"return the failed record set changes" in {
val rsChangeFailed1 = rsChange1.copy(status = RecordSetChangeStatus.Failed)

View File

@@ -82,6 +82,7 @@ class ZoneRoutingSpec
private val ok = Zone("ok.", "ok@test.com", acl = zoneAcl, adminGroupId = "test")
private val aclAsInfo = ZoneACLInfo(zoneAcl.rules.map(ACLRuleInfo(_, Some("name"))))
private val okAsZoneInfo = ZoneInfo(ok, aclAsInfo, okGroup.name, AccessLevel.Read)
private val okAsZoneDetails = ZoneDetails(ok, okGroup.name)
private val badRegex = Zone("ok.", "bad-regex@test.com", adminGroupId = "test")
private val trailingDot = Zone("trailing.dot", "trailing-dot@test.com")
private val connectionOk = Zone(
@@ -249,6 +250,15 @@ class ZoneRoutingSpec
outcome.toResult
}
def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] = {
val outcome = zoneId match {
case notFound.id => Left(ZoneNotFoundError(s"$zoneId"))
case ok.id => Right(okAsZoneDetails)
case error.id => Left(new RuntimeException("fail"))
}
outcome.toResult
}
def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] = {
val outcome = zoneName match {
case notFound.name => Left(ZoneNotFoundError(s"$zoneName"))
@@ -890,6 +900,27 @@ class ZoneRoutingSpec
}
}
"GET zone details" should {
"return the zone is retrieved" in {
Get(s"/zones/${ok.id}/details") ~> zoneRoute ~> check {
status shouldBe OK
val resultZone = responseAs[GetZoneDetailsResponse].zone
resultZone.email shouldBe ok.email
resultZone.name shouldBe ok.name
Option(resultZone.status) shouldBe defined
resultZone.adminGroupId shouldBe "test"
resultZone.adminGroupName shouldBe "ok"
}
}
"return 404 if the zone does not exist" in {
Get(s"/zones/${notFound.id}/details") ~> zoneRoute ~> check {
status shouldBe NotFound
}
}
}
"GET zone by name " should {
"return the zone is retrieved" in {
Get(s"/zones/name/${ok.name}") ~> zoneRoute ~> check {

View File

@@ -50,6 +50,11 @@ case class ChangeSet(
status: ChangeSetStatus
) {
def withRecordSetChange(recordSetChanges: Seq[RecordSetChange]): ChangeSet =
copy(
changes = recordSetChanges
)
def complete(change: RecordSetChange): ChangeSet = {
val updatedChanges = this.changes.filterNot(_.id == change.id) :+ change
if (isFinished)

View File

@@ -18,6 +18,7 @@ package vinyldns.core.domain.record
import cats.effect._
import scalikejdbc.DB
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.repository.Repository
trait RecordChangeRepository extends Repository {
@@ -25,9 +26,11 @@ trait RecordChangeRepository extends Repository {
def save(db: DB, changeSet: ChangeSet): IO[ChangeSet]
def listRecordSetChanges(
zoneId: String,
zoneId: Option[String],
startFrom: Option[Int] = None,
maxItems: Int = 100
maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None
): IO[ListRecordSetChangesResults]
def getRecordSetChange(zoneId: String, changeId: String): IO[Option[RecordSetChange]]

View File

@@ -15,12 +15,13 @@ section: "operator_menu"
- [Queue Configuration](#queue-configuration)
- [Database Configuration](#database-configuration)
- [Cryptography](#cryptography-settings)
- [Zone Connections](#zone-connections)
- [Additional Configuration Settings](#additional-configuration-settings)
- [Full Example Config](#full-example-config)
There are a lot of configuration settings in VinylDNS. So much so that it may seem overwhelming to configure vinyldns to
your environment. This document describes the configuration settings, highlighting the settings you are _most likely to
change_. All of the configuration settings are captured at the end.
change_. All the configuration settings are captured at the end.
It is important to note that the `api` and `portal` have _different_ configuration. We will review the configuration for
each separately.
@@ -271,7 +272,7 @@ vinyldns {
}
```
## Default Zone Connections
## Zone Connections
VinylDNS has three ways of indicating zone connections:
@@ -291,6 +292,7 @@ VinylDNS also ties in testing network connectivity to the default zone connectio
checks. A value for the health check connection timeout in milliseconds can be specified using `health-check-timeout`; a
default value of 10000 will be used if not provided.
### Global Zone Connections Configuration:
```yaml
vinyldns {
@@ -347,6 +349,93 @@ vinyldns {
]
```
### Alternate Zone Connections Configuration:
Below is an alternate way of setting zone connections configuration instead of using the [Global Zone Connections
Configuration](#global-zone-connections-configuration)
```yaml
# configured backend providers
backend {
# Use "default" when dns backend legacy = true
# otherwise, use the id of one of the connections in any of your backends
default-backend-id = "default"
# this is where we can save additional backends
backend-providers = [
{
class-name = "vinyldns.api.backend.dns.DnsBackendProviderLoader"
settings = {
legacy = false
backends = [
{
id = "default"
zone-connection = {
name = "vinyldns."
key-name = "vinyldns."
key = "nzisn+4G2ldMn0q1CV3vsg=="
primary-server = "127.0.0.1:19001"
}
transfer-connection = {
name = "vinyldns."
key-name = "vinyldns."
key = "nzisn+4G2ldMn0q1CV3vsg=="
primary-server = "127.0.0.1:19001"
},
tsig-usage = "always"
},
{
id = "func-test-backend"
zone-connection = {
name = "vinyldns."
key-name = "vinyldns."
key = "nzisn+4G2ldMn0q1CV3vsg=="
primary-server = "127.0.0.1:19001"
}
transfer-connection = {
name = "vinyldns."
key-name = "vinyldns."
key = "nzisn+4G2ldMn0q1CV3vsg=="
primary-server = "127.0.0.1:19001"
},
tsig-usage = "always"
}
]
}
}
]
}
```
Below is an example configuration of backend provider for AWS Route 53, in case we want to use AWS Route 53 as backend.
```yaml
backend {
default-backend-id = "r53"
backend-providers = [
{
class-name = "vinyldns.route53.backend.Route53BackendProviderLoader"
settings = {
backends = [
{
# AWS access key and secret key.
access-key = "your-access-key"
secret-key = "your-secret-key"
# Regional endpoint to make your requests (eg. 'us-west-2', 'us-east-1', etc.). This is the region where your queue is housed.
signing-region = "us-east-1"
# Endpoint to access r53
service-endpoint = "https://route53.amazonaws.com/"
id = "r53"
}
]
}
}
]
}
```
Make sure to add AWS name servers in [Approved Name Servers Config](#approved-name-servers).
## Additional Configuration Settings
### Approved Name Servers

View File

@@ -22,10 +22,12 @@ import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import scalikejdbc._
import vinyldns.core.domain.record.{ChangeSet, RecordChangeRepository, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType}
import vinyldns.core.domain.record.{ChangeSet, RecordChangeRepository, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType, RecordType}
import vinyldns.core.domain.zone.Zone
import vinyldns.mysql.TestMySqlInstance
import vinyldns.mysql.TransactionProvider
import java.time.Instant
class MySqlRecordChangeRepositoryIntegrationSpec
extends AnyWordSpec
with Matchers
@@ -60,6 +62,15 @@ class MySqlRecordChangeRepositoryIntegrationSpec
newRecordSets.map(makeTestAddChange(_, zone)).toList
}
def generateSameInserts(zone: Zone, count: Int): List[RecordSetChange] = {
val newRecordSets =
for {
i <- 1 to count
} yield aaaa.copy(zoneId = zone.id, name = s"apply-test", id = UUID.randomUUID().toString, created = Instant.now.plusSeconds(i))
newRecordSets.map(makeTestAddChange(_, zone)).toList
}
def generateFailedInserts(zone: Zone, count: Int): List[RecordSetChange] = {
val newRecordSets =
for {
@@ -102,7 +113,7 @@ class MySqlRecordChangeRepositoryIntegrationSpec
repo.save(db, ChangeSet(inserts))
}
saveRecChange.attempt.unsafeRunSync() shouldBe right
val result = repo.listRecordSetChanges(okZone.id, None, 5).unsafeRunSync()
val result = repo.listRecordSetChanges(Some(okZone.id), None, 5).unsafeRunSync()
result.nextId shouldBe defined
result.maxItems shouldBe 5
(result.items should have).length(5)
@@ -121,21 +132,48 @@ class MySqlRecordChangeRepositoryIntegrationSpec
repo.save(db, ChangeSet(timeSpaced))
}
saveRecChange.attempt.unsafeRunSync() shouldBe right
val page1 = repo.listRecordSetChanges(okZone.id, None, 2).unsafeRunSync()
val page1 = repo.listRecordSetChanges(Some(okZone.id), None, 2).unsafeRunSync()
page1.nextId shouldBe Some(2)
page1.maxItems shouldBe 2
(page1.items should contain).theSameElementsInOrderAs(expectedOrder.take(2))
val page2 = repo.listRecordSetChanges(okZone.id, page1.nextId, 2).unsafeRunSync()
val page2 = repo.listRecordSetChanges(Some(okZone.id), page1.nextId, 2).unsafeRunSync()
page2.nextId shouldBe Some(4)
page2.maxItems shouldBe 2
(page2.items should contain).theSameElementsInOrderAs(expectedOrder.slice(2, 4))
val page3 = repo.listRecordSetChanges(okZone.id, page2.nextId, 2).unsafeRunSync()
val page3 = repo.listRecordSetChanges(Some(okZone.id), page2.nextId, 2).unsafeRunSync()
page3.nextId shouldBe None
page3.maxItems shouldBe 2
page3.items should contain theSameElementsAs expectedOrder.slice(4, 5)
}
"list a particular recordset's changes by fqdn and record type" in {
val inserts = generateInserts(okZone, 10)
val saveRecChange = executeWithinTransaction { db: DB =>
repo.save(db, ChangeSet(inserts))
}
saveRecChange.attempt.unsafeRunSync() shouldBe right
val result = repo.listRecordSetChanges(None, None, 5, Some("1-apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync()
result.nextId shouldBe None
result.maxItems shouldBe 5
(result.items should have).length(1)
}
"page through a particular recordset's changes by fqdn and record type" in {
val inserts = generateSameInserts(okZone, 8)
val saveRecChange = executeWithinTransaction { db: DB =>
repo.save(db, ChangeSet(inserts))
}
saveRecChange.attempt.unsafeRunSync() shouldBe right
val page1 = repo.listRecordSetChanges(None, None, 5, Some("apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync()
page1.nextId shouldBe defined
page1.maxItems shouldBe 5
(page1.items should have).length(5)
val page2 = repo.listRecordSetChanges(None, page1.nextId, 5, Some("apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync()
page2.nextId shouldBe None
page2.maxItems shouldBe 5
(page2.items should have).length(3)
}
}
"list failed record changes" should {

View File

@@ -0,0 +1,8 @@
CREATE SCHEMA IF NOT EXISTS ${dbName};
USE ${dbName};
ALTER TABLE record_change ADD COLUMN fqdn VARCHAR(255) NOT NULL;
ALTER TABLE record_change ADD COLUMN record_type VARCHAR(255) NOT NULL;
CREATE INDEX fqdn_index ON record_change (fqdn);
CREATE INDEX record_type_index ON record_change (record_type);

View File

@@ -19,9 +19,11 @@ package vinyldns.mysql.repository
import cats.effect._
import scalikejdbc._
import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record._
import vinyldns.core.protobuf.ProtobufConversions
import vinyldns.core.route.Monitored
import vinyldns.mysql.repository.MySqlRecordSetRepository.fromRecordType
import vinyldns.proto.VinylDNSProto
class MySqlRecordChangeRepository
@@ -39,6 +41,24 @@ class MySqlRecordChangeRepository
| LIMIT {limit} OFFSET {startFrom}
""".stripMargin
private val LIST_CHANGES_WITH_START_FQDN_TYPE =
sql"""
|SELECT data
| FROM record_change
| WHERE fqdn = {fqdn} AND record_type = {type}
| ORDER BY created DESC
| LIMIT {limit} OFFSET {startFrom}
""".stripMargin
private val LIST_CHANGES_WITHOUT_START_FQDN_TYPE =
sql"""
|SELECT data
| FROM record_change
| WHERE fqdn = {fqdn} AND record_type = {type}
| ORDER BY created DESC
| LIMIT {limit}
""".stripMargin
private val LIST_RECORD_CHANGES =
sql"""
|SELECT data
@@ -63,7 +83,7 @@ class MySqlRecordChangeRepository
""".stripMargin
private val INSERT_CHANGES =
sql"INSERT IGNORE INTO record_change (id, zone_id, created, type, data) VALUES (?, ?, ?, ?, ?)"
sql"INSERT IGNORE INTO record_change (id, zone_id, created, type, fqdn, record_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)"
/**
* We have the same issue with changes as record sets, namely we may have to save millions of them
@@ -82,7 +102,9 @@ class MySqlRecordChangeRepository
change.zoneId,
change.created.toEpochMilli,
fromChangeType(change.changeType),
toPB(change).toByteArray
if(change.recordSet.name == change.zone.name) change.zone.name else change.recordSet.name + "." + change.zone.name,
fromRecordType(change.recordSet.typ),
toPB(change).toByteArray,
)
}
}
@@ -94,33 +116,52 @@ class MySqlRecordChangeRepository
}
def listRecordSetChanges(
zoneId: String,
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType]
): IO[ListRecordSetChangesResults] =
monitor("repo.RecordChange.listRecordSetChanges") {
IO {
DB.readOnly { implicit s =>
val changes = startFrom match {
case Some(start) =>
LIST_CHANGES_WITH_START
.bindByName('zoneId -> zoneId, 'startFrom -> start, 'limit -> maxItems)
.map(toRecordSetChange)
.list()
.apply()
case None =>
LIST_CHANGES_NO_START
.bindByName('zoneId -> zoneId, 'limit -> maxItems)
.map(toRecordSetChange)
.list()
.apply()
val changes = if(startFrom.isDefined && fqdn.isDefined && recordType.isDefined){
LIST_CHANGES_WITH_START_FQDN_TYPE
.bindByName('fqdn -> fqdn.get, 'type -> fromRecordType(recordType.get), 'startFrom -> startFrom.get, 'limit -> (maxItems + 1))
.map(toRecordSetChange)
.list()
.apply()
} else if(fqdn.isDefined && recordType.isDefined){
LIST_CHANGES_WITHOUT_START_FQDN_TYPE
.bindByName('fqdn -> fqdn.get, 'type -> fromRecordType(recordType.get), 'limit -> (maxItems + 1))
.map(toRecordSetChange)
.list()
.apply()
} else if(startFrom.isDefined){
LIST_CHANGES_WITH_START
.bindByName('zoneId -> zoneId.get, 'startFrom -> startFrom.get, 'limit -> (maxItems + 1))
.map(toRecordSetChange)
.list()
.apply()
} else {
LIST_CHANGES_NO_START
.bindByName('zoneId -> zoneId.get, 'limit -> (maxItems + 1))
.map(toRecordSetChange)
.list()
.apply()
}
val maxQueries = changes.take(maxItems)
val startValue = startFrom.getOrElse(0)
val nextId = if (changes.size < maxItems) None else Some(startValue + maxItems)
// earlier maxItems was incremented, if the (maxItems + 1) size is not reached then pages are exhausted
val nextId = changes match {
case _ if changes.size <= maxItems | changes.isEmpty => None
case _ => Some(startValue + maxItems)
}
ListRecordSetChangesResults(
changes,
maxQueries,
nextId,
startFrom,
maxItems

View File

@@ -76,7 +76,8 @@ class FrontendController @Inject() (
}
def viewRecordSets(): Action[AnyContent] = userAction.async { implicit request =>
Future(Ok(views.html.recordsets.recordSets(request.user.userName)))
val canReview = request.user.isSuper || request.user.isSupport
Future(Ok(views.html.recordsets.recordSets(request.user.userName, canReview)))
}
def viewAllBatchChanges(): Action[AnyContent] = userAction.async { implicit request =>

View File

@@ -458,6 +458,16 @@ class VinylDNS @Inject() (
// $COVERAGE-ON$
}
def getCommonZoneDetails(id: String): Action[AnyContent] = userAction.async { implicit request =>
// $COVERAGE-OFF$
val vinyldnsRequest = new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/$id/details")
executeRequest(vinyldnsRequest, request.user).map(response => {
Status(response.status)(response.body)
.withHeaders(cacheHeaders: _*)
})
// $COVERAGE-ON$
}
def getZoneByName(name: String): Action[AnyContent] = userAction.async { implicit request =>
val vinyldnsRequest =
new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/name/$name")
@@ -590,6 +600,25 @@ class VinylDNS @Inject() (
// $COVERAGE-ON$
}
def listRecordSetChangeHistory: Action[AnyContent] = userAction.async { implicit request =>
// $COVERAGE-OFF$
val queryParameters = new HashMap[String, java.util.List[String]]()
for {
(name, values) <- request.queryString
} queryParameters.put(name, values.asJava)
val vinyldnsRequest = new VinylDNSRequest(
"GET",
s"$vinyldnsServiceBackend",
s"recordsetchange/history",
parameters = queryParameters
)
executeRequest(vinyldnsRequest, request.user).map(response => {
Status(response.status)(response.body)
.withHeaders(cacheHeaders: _*)
})
// $COVERAGE-ON$
}
def addZone(): Action[AnyContent] = userAction.async { implicit request =>
// $COVERAGE-OFF$
val json = request.body.asJson

View File

@@ -1,4 +1,4 @@
@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks, meta: models.Meta)
@(rootAccountName: String, rootAccountCanReview: Boolean)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks, meta: models.Meta)
@content = {
<!-- PAGE CONTENT -->
@@ -81,7 +81,6 @@
<div class="btn-group">
<button id="refresh-records-button" class="btn btn-default" ng-click="refreshRecords()"><span class="fa fa-refresh"></span> Refresh</button>
<button id="create-record-button" class="btn btn-default" ng-if="canCreateRecords" ng-click="createRecord(defaultTtl)"><span class="fa fa-plus"></span> Create Record Set</button>
<button id="zone-sync-button" class="btn btn-default mb-control" ng-if="zoneInfo.accessLevel=='Delete'" data-toggle="modal" data-target="#mb-sync"><span class="fa fa-exchange"></span> Sync Zone</button>
</div>
<div>
@@ -167,6 +166,7 @@
@if(meta.sharedDisplayEnabled) {
<th>Owner Group Name</th>
}
<th ng-if="rootAccountCanReview || userCanAccessGroup">Record History</th>
</tr>
</thead>
<tbody>
@@ -374,6 +374,9 @@
title="Group with ID {{record.ownerGroupId}} no longer exists."><span class="fa fa-warning"></span> Group deleted</span>
</td>
}
<td ng-if="rootAccountCanReview || (record.ownerGroupName && canAccessGroup(record.ownerGroupId))">
<span><button class="btn btn-info btn-sm" ng-click="viewRecordHistory(record.fqdn, record.type)">View History</button></span>
</td>
</tr>
</tbody>
</table>
@@ -402,6 +405,102 @@
</div>
<!-- END PAGE CONTENT -->
<div class="modal fade in" id="record_history_modal">
<div class="modal-dialog set-width">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
</button>
<div class="modal-title">Record History</div>
</div>
<div class="modal-body">
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default" id="record_change_history">
<div class="panel-heading">
<h3 class="panel-title">Record Change History</h3>
</div>
<div class="panel-body">
<div class="btn-group">
<button class="btn btn-default" ng-click="refreshRecordChangeHistory(recordFqdn, recordType)"><span class="fa fa-refresh"></span> Refresh</button>
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeHistoryNextPageEnabled()" ng-click="changeHistoryNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table id="changeDataTable" class="table table-hover table-striped">
<thead>
<tr>
<th>Time</th>
<th class="col-md-5">Recordset Name</th>
<th>Recordset Type</th>
<th>Change Type</th>
<th>User</th>
<th>Status</th>
<th>Additional Info</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="change in recordsetChanges track by $index">
<td>{{change.created}}</td>
<td class="wrap-long-text">{{change.recordSet.name}}</td>
<td>{{change.recordSet.type}}</td>
<td>{{change.changeType}}</td>
<td>{{change.userName}}</td>
<td>
<span class="label label-{{ getRecordChangeStatusLabel(change.status) }}">{{ change.status }}</span>
</td>
<td class="col-md-3 wrap-long-text">
{{change.systemMessage}}
<div ng-if="change.status !='Failed'">
<a ng-if="change.changeType =='Create'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View created recordset</a>
<a ng-if="change.changeType =='Delete'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View deleted recordset</a>
<div><a ng-if="change.changeType =='Update'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View new recordset</a></div>
<div><a ng-if="change.changeType =='Update'" ng-click="viewRecordInfo(change.updates)" class="force-cursor">View old recordset</a></div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeHistoryNextPageEnabled()" ng-click="changeHistoryNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
<div class="panel-footer"></div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<recordmodal></recordmodal>
}
@plugins = {

View File

@@ -29,6 +29,7 @@ GET /api/recordsets @controllers.VinylDNS.listRecor
GET /api/zones @controllers.VinylDNS.getZones
GET /api/zones/backendids @controllers.VinylDNS.getBackendIds
GET /api/zones/:id @controllers.VinylDNS.getZone(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/:id/changes @controllers.VinylDNS.getZoneChange(id: String)
POST /api/zones @controllers.VinylDNS.addZone
@@ -42,6 +43,7 @@ DELETE /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.deleteRec
PUT /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.updateRecordSet(zid: String, rid:String)
GET /api/zones/:id/recordsetchanges @controllers.VinylDNS.listRecordSetChanges(id: String)
GET /api/recordsetchange/history @controllers.VinylDNS.listRecordSetChangeHistory
GET /api/groups @controllers.VinylDNS.getGroups
GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String)

View File

@@ -563,6 +563,10 @@ input[type="file"] {
}
/* Ending of css override for cron library and it's associated elements used in zone sync scheduling */
.set-width {
width: auto;
}
#set-dropdown-width {
width: 4em;
}

View File

@@ -237,6 +237,9 @@ angular.module('controller.manageZones', ['angular-cron-jobs'])
$scope.submitUpdateZone = function () {
var zone = angular.copy($scope.updateZoneInfo);
if(zone['recurrenceSchedule'] == ""){
delete zone['recurrenceSchedule']
}
zone = zonesService.normalizeZoneDates(zone);
zone = zonesService.setConnectionKeys(zone);
zone = zonesService.checkBackendId(zone);

View File

@@ -29,6 +29,51 @@
$scope.readRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', "SOA", 'SRV', 'NAPTR', 'SSHFP', 'TXT'];
$scope.selectedRecordTypes = [];
$scope.groups = [];
$scope.recordFqdn = undefined;
$scope.recordType = undefined;
$scope.recordsetChanges = {};
$scope.currentRecord = {};
$scope.zoneInfo = {};
$scope.profile = {};
$scope.recordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', 'SRV', 'NAPTR', 'SSHFP', 'TXT', 'SOA'];
$scope.sshfpAlgorithms = [{name: '(1) RSA', number: 1}, {name: '(2) DSA', number: 2}, {name: '(3) ECDSA', number: 3},
{name: '(4) Ed25519', number: 4}];
$scope.sshfpTypes = [{name: '(1) SHA-1', number: 1}, {name: '(2) SHA-256', number: 2}];
$scope.dsAlgorithms = [{name: '(3) DSA', number: 3}, {name: '(5) RSASHA1', number: 5},
{name: '(6) DSA_NSEC3_SHA1', number: 6}, {name: '(7) RSASHA1_NSEC3_SHA1' , number: 7},
{name: '(8) RSASHA256', number: 8}, {name: '(10) RSASHA512' , number: 10},
{name: '(12) ECC_GOST', number: 12}, {name: '(13) ECDSAP256SHA256' , number: 13},
{name: '(14) ECDSAP384SHA384', number: 14}, {name: '(15) ED25519', number: 15},
{name: '(16) ED448', number: 16},{name: '(253) PRIVATEDNS', number: 253},
{name: '(254) PRIVATEOID', number: 254}]
$scope.dsDigestTypes = [{name: '(1) SHA1', number: 1}, {name: '(2) SHA256', number: 2}, {name: '(3) GOSTR341194', number: 3}, {name: '(4) SHA384', number: 4}]
$scope.isZoneAdmin = false;
$scope.canReadZone = false;
$scope.canCreateRecords = false;
$scope.zoneId = undefined;
$scope.recordModalState = {
CREATE: 0,
UPDATE: 1,
DELETE: 2,
CONFIRM_UPDATE: 3,
CONFIRM_DELETE: 4,
VIEW_DETAILS: 5
};
// read-only data for setting various classes/attributes in record modal
$scope.recordModalParams = {
readOnly: {
class: "",
readOnly: true
},
editable: {
class: "record-edit",
readOnly: false
}
};
$scope.userCanAccessGroup = false;
// paging status for record changes
var changePaging = pagingService.getNewPagingParams(100);
// paging status for recordsets
var recordsPaging = pagingService.getNewPagingParams(100);
@@ -68,6 +113,13 @@
.append("<div>" + recordSet + "</div>")
.appendTo(ul); };
$scope.viewRecordHistory = function(recordFqdn, recordType) {
$scope.recordFqdn = recordFqdn;
$scope.recordType = recordType;
$scope.refreshRecordChangeHistory($scope.recordFqdn, $scope.recordType);
$("#record_history_modal").modal("show");
};
$scope.refreshRecords = function() {
if($scope.query.includes("|")) {
const queryRecord = $scope.query.split('|');
@@ -80,6 +132,7 @@
function success(response) {
recordsPaging.next = response.data.nextId;
updateRecordDisplay(response.data['recordSets']);
getMembership();
}
return recordsService
.listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter)
@@ -131,6 +184,11 @@
newRecords.push(recordsService.toDisplayRecord(record, ''));
});
$scope.records = newRecords;
for(var i = 0; i < $scope.records.length; i++) {
if (!$scope.records[i].zoneShared){
getZone($scope.records[i].zoneId, i);
}
}
if ($scope.records.length > 0) {
$("#ShowNoRec").modal("hide");
$("td.dataTables_empty").hide();
@@ -186,5 +244,142 @@
handleError(error, 'recordsService::nextPage-failure');
});
};
$scope.refreshRecordChangeHistory = function(recordFqdn, recordType) {
changePaging = pagingService.resetPaging(changePaging);
function success(response) {
$scope.zoneId = response.data.zoneId;
$scope.refreshZone();
changePaging.next = response.data.nextId;
updateChangeDisplay(response.data.recordSetChanges)
}
return recordsService
.listRecordSetChangeHistory(changePaging.maxItems, undefined, recordFqdn, recordType)
.then(success)
.catch(function (error){
handleError(error, 'recordsService::getRecordSetChangeHistory-failure');
});
};
/**
* Record change history paging
*/
$scope.changeHistoryPrevPageEnabled = function() {
return pagingService.prevPageEnabled(changePaging);
};
$scope.changeHistoryNextPageEnabled = function() {
return pagingService.nextPageEnabled(changePaging);
};
$scope.changeHistoryPrevPage = function() {
var startFrom = pagingService.getPrevStartFrom(changePaging);
return recordsService
.listRecordSetChangeHistory(changePaging.maxItems, startFrom, $scope.recordFqdn, $scope.recordType)
.then(function(response) {
changePaging = pagingService.prevPageUpdate(response.data.nextId, changePaging);
updateChangeDisplay(response.data.recordSetChanges);
})
.catch(function (error) {
handleError(error, 'recordsService::changePrevPage-failure');
});
};
$scope.changeHistoryNextPage = function() {
return recordsService
.listRecordSetChangeHistory(changePaging.maxItems, changePaging.next, $scope.recordFqdn, $scope.recordType)
.then(function(response) {
var changes = response.data.recordSetChanges;
changePaging = pagingService.nextPageUpdate(changes, response.data.nextId, changePaging);
if(changes.length > 0){
updateChangeDisplay(changes);
}
})
.catch(function (error) {
handleError(error, 'recordsService::changeNextPage-failure');
});
};
function updateChangeDisplay(changes) {
var newChanges = [];
angular.forEach(changes, function(change) {
newChanges.push(change);
});
$scope.recordsetChanges = newChanges;
}
$scope.getRecordChangeStatusLabel = function(status) {
switch(status) {
case 'Complete':
return 'success';
case 'Failed':
return 'danger';
default:
return 'info';
}
};
$scope.viewRecordInfo = function(record) {
$scope.currentRecord = recordsService.toDisplayRecord(record);
$scope.recordModal = {
action: $scope.recordModalState.VIEW_DETAILS,
title: "Record Info",
basics: $scope.recordModalParams.readOnly,
details: $scope.recordModalParams.readOnly,
sharedZone: $scope.zoneInfo.shared,
sharedDisplayEnabled: $scope.sharedDisplayEnabled
};
$("#record_modal").modal("show");
};
$scope.refreshZone = function() {
function success(response) {
$log.debug('recordsService::getZone-success');
$scope.zoneInfo = response.data.zone;
// Get current user's groups and determine if they're an admin of this zone
getMembership()
}
return recordsService
.getZone($scope.zoneId)
.then(success)
.catch(function (error){
handleError(error, 'recordsService::getZone-catch');
});
};
function getZone(zoneId, index){
recordsService
.getCommonZoneDetails(zoneId)
.then(
function (results) {
$scope.zoneDetails = results;
$scope.records[index].ownerGroupId = results.data.zone.adminGroupId;
$scope.records[index].ownerGroupName = results.data.zone.adminGroupName;
if($scope.records[index].ownerGroupName && $scope.canAccessGroup($scope.records[index].ownerGroupId)){
$scope.userCanAccessGroup = true;
}
})
.catch(function (error){
handleError(error, 'recordsService::getCommonZoneDetails-catch');
});
}
function getMembership(){
groupsService
.getGroupsStored()
.then(
function (results) {
$scope.myGroups = results.groups;
$scope.myGroupIds = results.groups.map(function(grp) {return grp['id']});
})
.catch(function (error){
handleError(error, 'groupsService::getGroupsStored-failure');
});
}
$scope.canAccessGroup = function(groupId) {
return $scope.myGroupIds !== undefined && $scope.myGroupIds.indexOf(groupId) > -1;
};
});
})();

View File

@@ -100,6 +100,10 @@ angular.module('service.records', [])
return $http.get("/api/zones/"+zid);
};
this.getCommonZoneDetails = function (zid) {
return $http.get("/api/zones/"+zid+"/details");
};
this.syncZone = function (zid) {
return $http.post("/api/zones/"+zid+"/sync", {}, {headers: utilityService.getCsrfHeader()});
};
@@ -108,7 +112,21 @@ angular.module('service.records', [])
var url = '/api/zones/' + zid + '/recordsetchanges';
var params = {
"maxItems": maxItems,
"startFrom": startFrom
"startFrom": startFrom,
"fqdn": undefined,
"recordType": undefined
};
url = utilityService.urlBuilder(url, params);
return $http.get(url);
};
this.listRecordSetChangeHistory = function (maxItems, startFrom, fqdn, recordType) {
var url = '/api/recordsetchange/history';
var params = {
"maxItems": maxItems,
"startFrom": startFrom,
"fqdn": fqdn,
"recordType": recordType
};
url = utilityService.urlBuilder(url, params);
return $http.get(url);

View File

@@ -238,6 +238,32 @@ class FrontendControllerSpec extends Specification with Mockito with TestApplica
}
}
"Get for '/recordsets'" should {
"redirect to the login page when a user is not logged in" in new WithApplication(app) {
val result = underTest.viewRecordSets()(FakeRequest(GET, "/recordsets"))
status(result) must equalTo(SEE_OTHER)
headers(result) must contain("Location" -> "/login?target=/recordsets")
}
"render the recordset view page when the user is logged in" in new WithApplication(app) {
val result =
underTest.viewRecordSets()(
FakeRequest(GET, "/recordsets").withSession("username" -> "frodo").withCSRFToken
)
status(result) must beEqualTo(OK)
contentType(result) must beSome.which(_ == "text/html")
contentAsString(result) must contain("RecordSets | VinylDNS")
}
"redirect to the no access page when a user is locked out" in new WithApplication(app) {
val result =
lockedUserUnderTest.viewRecordSets()(
FakeRequest(GET, "/recordsets")
.withSession("username" -> "lockedFbaggins")
.withCSRFToken
)
headers(result) must contain("Location" -> "/noaccess")
}
}
"Get for login" should {
"with ldap enabled" should {
"render the login page when the user is not logged in" in new WithApplication(app) {

View File

@@ -1650,6 +1650,35 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
}
}
".getCommonZoneDetails" should {
"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.getCommonZoneDetails(hobbitZoneId)(FakeRequest(GET, s"/api/zones/$hobbitZoneId/details"))
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.getCommonZoneDetails(hobbitZoneId)(
FakeRequest(GET, s"/api/zones/$hobbitZoneId/details").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."
)
}
}
".getZoneChange" should {
"return ok (200) if the zoneChanges is found" in new WithApplication(app) {
@@ -1947,6 +1976,37 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
}
}
".listRecordSetChangeHistory" should {
"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.listRecordSetChangeHistory()(
FakeRequest(GET, s"/api/recordsetchange/history")
)
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.listRecordSetChangeHistory()(
FakeRequest(GET, s"/api/recordsetchange/history").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."
)
}
}
".addZone" should {
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient]

View File

@@ -83,7 +83,7 @@ class Route53Backend(
val found = result.getHostedZones.asScala.toList.headOption.map { hz =>
val hzid = parseHostedZoneId(hz.getId)
// adds the hozted zone name and id to our cache if not present
// adds the hosted zone name and id to our cache if not present
zoneMap.putIfAbsent(hz.getName, hzid)
hzid
}