From 665c8aa3bc6972c19d3814de00d8b77fd428a95e Mon Sep 17 00:00:00 2001
From: kbalk <7536198+kbalk@users.noreply.github.com>
Date: Fri, 22 Oct 2021 17:47:29 -0400
Subject: [PATCH] Route53 query-logging-config APIs (#4437)
---
IMPLEMENTATION_COVERAGE.md | 10 +-
moto/logs/models.py | 4 +-
moto/route53/exceptions.py | 73 +++++
moto/route53/models.py | 139 ++++++++-
moto/route53/responses.py | 160 ++++++++--
moto/route53/urls.py | 3 +
moto/route53/utils.py | 10 +
.../test_route53_query_logging_config.py | 277 ++++++++++++++++++
8 files changed, 637 insertions(+), 39 deletions(-)
create mode 100644 moto/route53/exceptions.py
create mode 100644 moto/route53/utils.py
create mode 100644 tests/test_route53/test_route53_query_logging_config.py
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 76b5cdabc..7f48252aa 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -3506,7 +3506,7 @@
- [X] create_health_check
- [X] create_hosted_zone
- [ ] create_key_signing_key
-- [ ] create_query_logging_config
+- [X] create_query_logging_config
- [ ] create_reusable_delegation_set
- [ ] create_traffic_policy
- [ ] create_traffic_policy_instance
@@ -3516,7 +3516,7 @@
- [X] delete_health_check
- [X] delete_hosted_zone
- [ ] delete_key_signing_key
-- [ ] delete_query_logging_config
+- [X] delete_query_logging_config
- [ ] delete_reusable_delegation_set
- [ ] delete_traffic_policy
- [ ] delete_traffic_policy_instance
@@ -3536,7 +3536,7 @@
- [X] get_hosted_zone
- [ ] get_hosted_zone_count
- [ ] get_hosted_zone_limit
-- [ ] get_query_logging_config
+- [X] get_query_logging_config
- [ ] get_reusable_delegation_set
- [ ] get_reusable_delegation_set_limit
- [ ] get_traffic_policy
@@ -3547,7 +3547,7 @@
- [X] list_hosted_zones
- [X] list_hosted_zones_by_name
- [ ] list_hosted_zones_by_vpc
-- [ ] list_query_logging_configs
+- [X] list_query_logging_configs
- [ ] list_resource_record_sets
- [ ] list_reusable_delegation_sets
- [X] list_tags_for_resource
@@ -4642,4 +4642,4 @@
- workmailmessageflow
- workspaces
- xray
-
\ No newline at end of file
+
diff --git a/moto/logs/models.py b/moto/logs/models.py
index ac8549fbf..ee426b149 100644
--- a/moto/logs/models.py
+++ b/moto/logs/models.py
@@ -652,7 +652,9 @@ class LogsBackend(BaseBackend):
del self.groups[log_group_name]
@paginate(pagination_model=PAGINATION_MODEL)
- def describe_log_groups(self, log_group_name_prefix, limit=None, next_token=None):
+ def describe_log_groups(
+ self, log_group_name_prefix=None, limit=None, next_token=None
+ ):
if log_group_name_prefix is None:
log_group_name_prefix = ""
diff --git a/moto/route53/exceptions.py b/moto/route53/exceptions.py
new file mode 100644
index 000000000..94981dad6
--- /dev/null
+++ b/moto/route53/exceptions.py
@@ -0,0 +1,73 @@
+"""Exceptions raised by the Route53 service."""
+from moto.core.exceptions import RESTError
+
+
+class Route53ClientError(RESTError):
+ """Base class for Route53 errors."""
+
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault("template", "single_error")
+ super().__init__(*args, **kwargs)
+
+
+class InvalidInput(Route53ClientError):
+ """Malformed ARN for the CloudWatch log group."""
+
+ code = 400
+
+ def __init__(self):
+ message = "The ARN for the CloudWatch Logs log group is invalid"
+ super().__init__("InvalidInput", message)
+
+
+class InvalidPaginationToken(Route53ClientError):
+ """Bad NextToken specified when listing query logging configs."""
+
+ code = 400
+
+ def __init__(self):
+ message = (
+ "Route 53 can't get the next page of query logging configurations "
+ "because the specified value for NextToken is invalid."
+ )
+ super().__init__("InvalidPaginationToken", message)
+
+
+class NoSuchCloudWatchLogsLogGroup(Route53ClientError):
+ """CloudWatch LogGroup has a permissions policy, but does not exist."""
+
+ code = 404
+
+ def __init__(self):
+ message = "The specified CloudWatch Logs log group doesn't exist."
+ super().__init__("NoSuchCloudWatchLogsLogGroup", message)
+
+
+class NoSuchHostedZone(Route53ClientError):
+ """HostedZone does not exist."""
+
+ code = 404
+
+ def __init__(self, host_zone_id):
+ message = f"No hosted zone found with ID: {host_zone_id}"
+ super().__init__("NoSuchHostedZone", message)
+
+
+class NoSuchQueryLoggingConfig(Route53ClientError):
+ """Query log config does not exist."""
+
+ code = 404
+
+ def __init__(self):
+ message = "The query logging configuration does not exist"
+ super().__init__("NoSuchQueryLoggingConfig", message)
+
+
+class QueryLoggingConfigAlreadyExists(Route53ClientError):
+ """Query log config exists for the hosted zone."""
+
+ code = 409
+
+ def __init__(self):
+ message = "A query logging configuration already exists for this hosted zone"
+ super().__init__("QueryLoggingConfigAlreadyExists", message)
diff --git a/moto/route53/models.py b/moto/route53/models.py
index 09a458e96..4c6bd85c9 100644
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -1,13 +1,23 @@
+"""Route53Backend class with methods for supported APIs."""
import itertools
from collections import defaultdict
+import re
import string
import random
import uuid
from jinja2 import Template
-from moto.core import BaseBackend, CloudFormationModel
-
+from moto.route53.exceptions import (
+ InvalidInput,
+ NoSuchCloudWatchLogsLogGroup,
+ NoSuchHostedZone,
+ NoSuchQueryLoggingConfig,
+ QueryLoggingConfigAlreadyExists,
+)
+from moto.core import BaseBackend, BaseModel, CloudFormationModel
+from moto.utilities.paginator import paginate
+from .utils import PAGINATION_MODEL
ROUTE53_ID_CHOICE = string.ascii_uppercase + string.digits
@@ -342,7 +352,7 @@ class RecordSetGroup(CloudFormationModel):
@property
def physical_resource_id(self):
- return "arn:aws:route53:::hostedzone/{0}".format(self.hosted_zone_id)
+ return f"arn:aws:route53:::hostedzone/{self.hosted_zone_id}"
@staticmethod
def cloudformation_name_type():
@@ -372,11 +382,37 @@ class RecordSetGroup(CloudFormationModel):
return record_set_group
+class QueryLoggingConfig(BaseModel):
+
+ """QueryLoggingConfig class; this object isn't part of Cloudformation."""
+
+ def __init__(
+ self, query_logging_config_id, hosted_zone_id, cloudwatch_logs_log_group_arn
+ ):
+ self.hosted_zone_id = hosted_zone_id
+ self.cloudwatch_logs_log_group_arn = cloudwatch_logs_log_group_arn
+ self.query_logging_config_id = query_logging_config_id
+ self.location = f"https://route53.amazonaws.com/2013-04-01/queryloggingconfig/{self.query_logging_config_id}"
+
+ def to_xml(self):
+ template = Template(
+ """
+ {{ query_logging_config.cloudwatch_logs_log_group_arn }}
+ {{ query_logging_config.hosted_zone_id }}
+ {{ query_logging_config.query_logging_config_id }}
+ """
+ )
+ # The "Location" value must be put into the header; that's done in
+ # responses.py.
+ return template.render(query_logging_config=self)
+
+
class Route53Backend(BaseBackend):
def __init__(self):
self.zones = {}
self.health_checks = {}
self.resource_tags = defaultdict(dict)
+ self.query_logging_configs = {}
def create_hosted_zone(self, name, private_zone, comment=None):
new_id = create_route53_zone_id()
@@ -414,13 +450,10 @@ class Route53Backend(BaseBackend):
cleaned_hosted_zone_name = the_zone.name.strip(".")
if not cleaned_record_name.endswith(cleaned_hosted_zone_name):
- error_msg = """
+ error_msg = f"""
An error occurred (InvalidChangeBatch) when calling the ChangeResourceRecordSets operation:
- RRSet with DNS name %s is not permitted in zone %s
- """ % (
- record_set["Name"],
- the_zone.name,
- )
+ RRSet with DNS name {record_set["Name"]} is not permitted in zone {the_zone.name}
+ """
return error_msg
if not record_set["Name"].endswith("."):
@@ -477,6 +510,7 @@ class Route53Backend(BaseBackend):
for zone in self.list_hosted_zones():
if zone.name == name:
return zone
+ return None
def delete_hosted_zone(self, id_):
return self.zones.pop(id_.replace("/hostedzone/", ""), None)
@@ -493,5 +527,92 @@ class Route53Backend(BaseBackend):
def delete_health_check(self, health_check_id):
return self.health_checks.pop(health_check_id, None)
+ @staticmethod
+ def _validate_arn(region, arn):
+ match = re.match(fr"arn:aws:logs:{region}:\d{{12}}:log-group:.+", arn)
+ if not arn or not match:
+ raise InvalidInput()
+
+ # The CloudWatch Logs log group must be in the "us-east-1" region.
+ match = re.match(r"^(?:[^:]+:){3}(?P[^:]+).*", arn)
+ if match.group("region") != "us-east-1":
+ raise InvalidInput()
+
+ def create_query_logging_config(self, region, hosted_zone_id, log_group_arn):
+ """Process the create_query_logging_config request."""
+ # Does the hosted_zone_id exist?
+ response = self.list_hosted_zones()
+ zones = list(response) if response else []
+ for zone in zones:
+ if zone.id == hosted_zone_id:
+ break
+ else:
+ raise NoSuchHostedZone(hosted_zone_id)
+
+ # Ensure CloudWatch Logs log ARN is valid, otherwise raise an error.
+ self._validate_arn(region, log_group_arn)
+
+ # Note: boto3 checks the resource policy permissions before checking
+ # whether the log group exists. moto doesn't have a way of checking
+ # the resource policy, so in some instances moto will complain
+ # about a log group that doesn't exist whereas boto3 will complain
+ # that "The resource policy that you're using for Route 53 query
+ # logging doesn't grant Route 53 sufficient permission to create
+ # a log stream in the specified log group."
+
+ from moto.logs import logs_backends # pylint: disable=import-outside-toplevel
+
+ response = logs_backends[region].describe_log_groups()
+ log_groups = response[0] if response else []
+ for entry in log_groups:
+ if log_group_arn == entry["arn"]:
+ break
+ else:
+ # There is no CloudWatch Logs log group with the specified ARN.
+ raise NoSuchCloudWatchLogsLogGroup()
+
+ # Verify there is no existing query log config using the same hosted
+ # zone.
+ for query_log in self.query_logging_configs.values():
+ if query_log.hosted_zone_id == hosted_zone_id:
+ raise QueryLoggingConfigAlreadyExists()
+
+ # Create an instance of the query logging config.
+ query_logging_config_id = str(uuid.uuid4())
+ query_logging_config = QueryLoggingConfig(
+ query_logging_config_id, hosted_zone_id, log_group_arn
+ )
+ self.query_logging_configs[query_logging_config_id] = query_logging_config
+ return query_logging_config
+
+ def delete_query_logging_config(self, query_logging_config_id):
+ """Delete query logging config, if it exists."""
+ if query_logging_config_id not in self.query_logging_configs:
+ raise NoSuchQueryLoggingConfig()
+ self.query_logging_configs.pop(query_logging_config_id)
+
+ def get_query_logging_config(self, query_logging_config_id):
+ """Return query logging config, if it exists."""
+ if query_logging_config_id not in self.query_logging_configs:
+ raise NoSuchQueryLoggingConfig()
+ return self.query_logging_configs[query_logging_config_id]
+
+ @paginate(pagination_model=PAGINATION_MODEL)
+ def list_query_logging_configs(
+ self, hosted_zone_id=None, next_token=None, max_results=None,
+ ): # pylint: disable=unused-argument
+ """Return a list of query logging configs."""
+ if hosted_zone_id:
+ # Does the hosted_zone_id exist?
+ response = self.list_hosted_zones()
+ zones = list(response) if response else []
+ for zone in zones:
+ if zone.id == hosted_zone_id:
+ break
+ else:
+ raise NoSuchHostedZone(hosted_zone_id)
+
+ return list(self.query_logging_configs.values())
+
route53_backend = Route53Backend()
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
index e0563e4a0..d61d729d5 100644
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -1,14 +1,20 @@
-from jinja2 import Template
+"""Handles Route53 API requests, invokes method and returns response."""
from urllib.parse import parse_qs, urlparse
-from moto.core.responses import BaseResponse
-from .models import route53_backend
+from jinja2 import Template
import xmltodict
+from moto.core.responses import BaseResponse
+from moto.core.exceptions import InvalidToken
+from moto.route53.exceptions import Route53ClientError, InvalidPaginationToken
+from moto.route53.models import route53_backend
+
XMLNS = "https://route53.amazonaws.com/doc/2013-04-01/"
class Route53(BaseResponse):
+ """Handler for Route53 requests and responses."""
+
def list_or_create_hostzone_response(self, request, full_url, headers):
self.setup_class(request, full_url, headers)
@@ -56,7 +62,7 @@ class Route53(BaseResponse):
dnsname, zones = route53_backend.list_hosted_zones_by_name(dnsname)
template = Template(LIST_HOSTED_ZONES_BY_NAME_RESPONSE)
- return 200, headers, template.render(zones=zones, dnsname=dnsname)
+ return 200, headers, template.render(zones=zones, dnsname=dnsname, xmlns=XMLNS)
def get_or_delete_hostzone_response(self, request, full_url, headers):
self.setup_class(request, full_url, headers)
@@ -148,15 +154,20 @@ class Route53(BaseResponse):
caller_reference, health_check_args
)
template = Template(CREATE_HEALTH_CHECK_RESPONSE)
- return 201, headers, template.render(health_check=health_check)
+ return 201, headers, template.render(health_check=health_check, xmlns=XMLNS)
elif method == "DELETE":
health_check_id = parsed_url.path.split("/")[-1]
route53_backend.delete_health_check(health_check_id)
- return 200, headers, DELETE_HEALTH_CHECK_RESPONSE
+ template = Template(DELETE_HEALTH_CHECK_RESPONSE)
+ return 200, headers, template.render(xmlns=XMLNS)
elif method == "GET":
template = Template(LIST_HEALTH_CHECKS_RESPONSE)
health_checks = route53_backend.list_health_checks()
- return 200, headers, template.render(health_checks=health_checks)
+ return (
+ 200,
+ headers,
+ template.render(health_checks=health_checks, xmlns=XMLNS),
+ )
def not_implemented_response(self, request, full_url, headers):
self.setup_class(request, full_url, headers)
@@ -167,7 +178,7 @@ class Route53(BaseResponse):
elif "trafficpolicyinstances" in full_url:
action = "policies"
raise NotImplementedError(
- "The action for {0} has not been implemented for route 53".format(action)
+ f"The action for {action} has not been implemented for route 53"
)
def list_or_change_tags_for_resource_request(self, request, full_url, headers):
@@ -196,7 +207,6 @@ class Route53(BaseResponse):
route53_backend.change_tags_for_resource(id_, tags)
template = Template(CHANGE_TAGS_FOR_RESOURCE_RESPONSE)
-
return 200, headers, template.render()
def get_change(self, request, full_url, headers):
@@ -206,20 +216,100 @@ class Route53(BaseResponse):
parsed_url = urlparse(full_url)
change_id = parsed_url.path.rstrip("/").rsplit("/", 1)[1]
template = Template(GET_CHANGE_RESPONSE)
- return 200, headers, template.render(change_id=change_id)
+ return 200, headers, template.render(change_id=change_id, xmlns=XMLNS)
+
+ def list_or_create_query_logging_config_response(self, request, full_url, headers):
+ self.setup_class(request, full_url, headers)
+
+ if request.method == "POST":
+ json_body = xmltodict.parse(self.body)["CreateQueryLoggingConfigRequest"]
+ hosted_zone_id = json_body["HostedZoneId"]
+ log_group_arn = json_body["CloudWatchLogsLogGroupArn"]
+ try:
+ query_logging_config = route53_backend.create_query_logging_config(
+ self.region, hosted_zone_id, log_group_arn
+ )
+ except Route53ClientError as r53error:
+ return r53error.code, {}, r53error.description
+
+ template = Template(CREATE_QUERY_LOGGING_CONFIG_RESPONSE)
+ headers["Location"] = query_logging_config.location
+ return (
+ 201,
+ headers,
+ template.render(query_logging_config=query_logging_config, xmlns=XMLNS),
+ )
+
+ elif request.method == "GET":
+ hosted_zone_id = self._get_param("hostedzoneid")
+ next_token = self._get_param("nexttoken")
+ max_results = self._get_int_param("maxresults")
+ try:
+ try:
+ # The paginator picks up named arguments, returns tuple.
+ # pylint: disable=unbalanced-tuple-unpacking
+ (
+ all_configs,
+ next_token,
+ ) = route53_backend.list_query_logging_configs(
+ hosted_zone_id=hosted_zone_id,
+ next_token=next_token,
+ max_results=max_results,
+ )
+ except InvalidToken as exc:
+ raise InvalidPaginationToken() from exc
+ except Route53ClientError as r53error:
+ return r53error.code, {}, r53error.description
+
+ template = Template(LIST_QUERY_LOGGING_CONFIGS_RESPONSE)
+ return (
+ 200,
+ headers,
+ template.render(
+ query_logging_configs=all_configs,
+ next_token=next_token,
+ xmlns=XMLNS,
+ ),
+ )
+
+ def get_or_delete_query_logging_config_response(self, request, full_url, headers):
+ self.setup_class(request, full_url, headers)
+ parsed_url = urlparse(full_url)
+ query_logging_config_id = parsed_url.path.rstrip("/").rsplit("/", 1)[1]
+
+ if request.method == "GET":
+ try:
+ query_logging_config = route53_backend.get_query_logging_config(
+ query_logging_config_id
+ )
+ except Route53ClientError as r53error:
+ return r53error.code, {}, r53error.description
+ template = Template(GET_QUERY_LOGGING_CONFIG_RESPONSE)
+ return (
+ 200,
+ headers,
+ template.render(query_logging_config=query_logging_config, xmlns=XMLNS),
+ )
+
+ elif request.method == "DELETE":
+ try:
+ route53_backend.delete_query_logging_config(query_logging_config_id)
+ except Route53ClientError as r53error:
+ return r53error.code, {}, r53error.description
+ return 200, headers, ""
-def no_such_hosted_zone_error(zoneid, headers={}):
+def no_such_hosted_zone_error(zoneid, headers=None):
+ if not headers:
+ headers = {}
headers["X-Amzn-ErrorType"] = "NoSuchHostedZone"
headers["Content-Type"] = "text/xml"
- message = "Zone %s Not Found" % zoneid
- error_response = (
- "NoSuchHostedZone
%s" % message
- )
- error_response = '%s' % (
- XMLNS,
- error_response,
- )
+ error_response = f"""
+
+ NoSuchHostedZone
+ Zone {zoneid} Not Found
+
+ """
return 404, headers, error_response
@@ -323,7 +413,7 @@ LIST_HOSTED_ZONES_RESPONSE = """
+LIST_HOSTED_ZONES_BY_NAME_RESPONSE = """
{% if dnsname %}
{{ dnsname }}
{% endif %}
@@ -346,12 +436,12 @@ LIST_HOSTED_ZONES_BY_NAME_RESPONSE = """
-
+
{{ health_check.to_xml() }}
"""
LIST_HEALTH_CHECKS_RESPONSE = """
-
+
{% for health_check in health_checks %}
{{ health_check.to_xml() }}
@@ -362,14 +452,36 @@ LIST_HEALTH_CHECKS_RESPONSE = """
"""
DELETE_HEALTH_CHECK_RESPONSE = """
-
+
"""
GET_CHANGE_RESPONSE = """
-
+
INSYNC
2010-09-10T01:36:41.958Z
{{ change_id }}
"""
+
+CREATE_QUERY_LOGGING_CONFIG_RESPONSE = """
+
+ {{ query_logging_config.to_xml() }}
+"""
+
+GET_QUERY_LOGGING_CONFIG_RESPONSE = """
+
+ {{ query_logging_config.to_xml() }}
+"""
+
+LIST_QUERY_LOGGING_CONFIGS_RESPONSE = """
+
+
+ {% for query_logging_config in query_logging_configs %}
+ {{ query_logging_config.to_xml() }}
+ {% endfor %}
+
+ {% if next_token %}
+ {{ next_token }}
+ {% endif %}
+"""
diff --git a/moto/route53/urls.py b/moto/route53/urls.py
index 6574d051f..1b498207c 100644
--- a/moto/route53/urls.py
+++ b/moto/route53/urls.py
@@ -1,3 +1,4 @@
+"""Route53 base URL and path."""
from .responses import Route53
url_bases = [r"https?://route53(.*)\.amazonaws.com"]
@@ -22,4 +23,6 @@ url_paths = {
r"{0}/(?P[\d_-]+)/tags/hostedzone/(?P[^/]+)$": tag_response2,
r"{0}/(?P[\d_-]+)/trafficpolicyinstances/*": Route53().not_implemented_response,
r"{0}/(?P[\d_-]+)/change/(?P[^/]+)$": Route53().get_change,
+ r"{0}/(?P[\d_-]+)/queryloggingconfig$": Route53().list_or_create_query_logging_config_response,
+ r"{0}/(?P[\d_-]+)/queryloggingconfig/(?P[^/]+)$": Route53().get_or_delete_query_logging_config_response,
}
diff --git a/moto/route53/utils.py b/moto/route53/utils.py
new file mode 100644
index 000000000..858f48878
--- /dev/null
+++ b/moto/route53/utils.py
@@ -0,0 +1,10 @@
+"""Pagination control model for Route53."""
+
+PAGINATION_MODEL = {
+ "list_query_logging_configs": {
+ "input_token": "next_token",
+ "limit_key": "max_results",
+ "limit_default": 100,
+ "page_ending_range_keys": ["hosted_zone_id"],
+ },
+}
diff --git a/tests/test_route53/test_route53_query_logging_config.py b/tests/test_route53/test_route53_query_logging_config.py
new file mode 100644
index 000000000..de374c13e
--- /dev/null
+++ b/tests/test_route53/test_route53_query_logging_config.py
@@ -0,0 +1,277 @@
+"""Route53 unit tests specific to query_logging_config APIs."""
+import pytest
+
+import boto3
+from botocore.exceptions import ClientError
+
+from moto import mock_logs
+from moto import mock_route53
+from moto.core import ACCOUNT_ID
+from moto.core.utils import get_random_hex
+
+# The log group must be in the us-east-1 region.
+TEST_REGION = "us-east-1"
+
+
+def create_hosted_zone_id(route53_client, hosted_zone_test_name):
+ """Return ID of a newly created Route53 public hosted zone"""
+ response = route53_client.create_hosted_zone(
+ Name=hosted_zone_test_name,
+ CallerReference=f"test_caller_ref_{get_random_hex(6)}",
+ )
+ assert response["ResponseMetadata"]["HTTPStatusCode"] == 201
+ assert "HostedZone" in response and response["HostedZone"]["Id"]
+ return response["HostedZone"]["Id"]
+
+
+def create_log_group_arn(logs_client, hosted_zone_test_name):
+ """Return ARN of a newly created CloudWatch log group."""
+ log_group_name = f"/aws/route53/{hosted_zone_test_name}"
+ response = logs_client.create_log_group(logGroupName=log_group_name)
+ assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+
+ log_group_arn = None
+ response = logs_client.describe_log_groups()
+ for entry in response["logGroups"]:
+ if entry["logGroupName"] == log_group_name:
+ log_group_arn = entry["arn"]
+ break
+ return log_group_arn
+
+
+@mock_logs
+@mock_route53
+def test_create_query_logging_config_bad_args():
+ """Test bad arguments to create_query_logging_config()."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+ # Check exception: NoSuchHostedZone
+ with pytest.raises(ClientError) as exc:
+ client.create_query_logging_config(
+ HostedZoneId="foo", CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "NoSuchHostedZone"
+ assert "No hosted zone found with ID: foo" in err["Message"]
+
+ # Check exception: InvalidInput (bad CloudWatch Logs log ARN)
+ with pytest.raises(ClientError) as exc:
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id,
+ CloudWatchLogsLogGroupArn=f"arn:aws:logs:{TEST_REGION}:{ACCOUNT_ID}:foo-bar:foo",
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidInput"
+ assert "The ARN for the CloudWatch Logs log group is invalid" in err["Message"]
+
+ # Check exception: InvalidInput (CloudWatch Logs log not in us-east-1)
+ with pytest.raises(ClientError) as exc:
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id,
+ CloudWatchLogsLogGroupArn=log_group_arn.replace(TEST_REGION, "us-west-1"),
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidInput"
+ assert "The ARN for the CloudWatch Logs log group is invalid" in err["Message"]
+
+ # Check exception: NoSuchCloudWatchLogsLogGroup
+ with pytest.raises(ClientError) as exc:
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id,
+ CloudWatchLogsLogGroupArn=log_group_arn.replace(
+ hosted_zone_test_name, "foo"
+ ),
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "NoSuchCloudWatchLogsLogGroup"
+ assert "The specified CloudWatch Logs log group doesn't exist" in err["Message"]
+
+ # Check exception: QueryLoggingConfigAlreadyExists
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+ with pytest.raises(ClientError) as exc:
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "QueryLoggingConfigAlreadyExists"
+ assert (
+ "A query logging configuration already exists for this hosted zone"
+ in err["Message"]
+ )
+
+
+@mock_logs
+@mock_route53
+def test_create_query_logging_config_good_args():
+ """Test a valid create_logging_config() request."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+ response = client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+ config = response["QueryLoggingConfig"]
+ assert config["HostedZoneId"] == hosted_zone_id.split("/")[-1]
+ assert config["CloudWatchLogsLogGroupArn"] == log_group_arn
+ assert config["Id"]
+
+ location = response["Location"]
+ assert (
+ location
+ == f"https://route53.amazonaws.com/2013-04-01/queryloggingconfig/{config['Id']}"
+ )
+
+
+@mock_logs
+@mock_route53
+def test_delete_query_logging_config():
+ """Test valid and invalid delete_query_logging_config requests."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ # Create a query logging config that can then be deleted.
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+ query_response = client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+
+ # Test the deletion.
+ query_id = query_response["QueryLoggingConfig"]["Id"]
+ response = client.delete_query_logging_config(Id=query_id)
+ # There is no response other than the usual ResponseMetadata.
+ assert list(response.keys()) == ["ResponseMetadata"]
+
+ # Test the deletion of a non-existent query logging config, i.e., the
+ # one that was just deleted.
+ with pytest.raises(ClientError) as exc:
+ client.delete_query_logging_config(Id=query_id)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "NoSuchQueryLoggingConfig"
+ assert "The query logging configuration does not exist" in err["Message"]
+
+
+@mock_logs
+@mock_route53
+def test_get_query_logging_config():
+ """Test valid and invalid get_query_logging_config requests."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ # Create a query logging config that can then be retrieved.
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+ query_response = client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+
+ # Test the retrieval.
+ query_id = query_response["QueryLoggingConfig"]["Id"]
+ response = client.get_query_logging_config(Id=query_id)
+ config = response["QueryLoggingConfig"]
+ assert config["HostedZoneId"] == hosted_zone_id.split("/")[-1]
+ assert config["CloudWatchLogsLogGroupArn"] == log_group_arn
+ assert config["Id"]
+
+ # Test the retrieval of a non-existent query logging config.
+ with pytest.raises(ClientError) as exc:
+ client.get_query_logging_config(Id="1234567890")
+ err = exc.value.response["Error"]
+ assert err["Code"] == "NoSuchQueryLoggingConfig"
+ assert "The query logging configuration does not exist" in err["Message"]
+
+
+@mock_logs
+@mock_route53
+def test_list_query_logging_configs_bad_args():
+ """Test bad arguments to list_query_logging_configs()."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ # Check exception: NoSuchHostedZone
+ with pytest.raises(ClientError) as exc:
+ client.list_query_logging_configs(HostedZoneId="foo", MaxResults="10")
+ err = exc.value.response["Error"]
+ assert err["Code"] == "NoSuchHostedZone"
+ assert "No hosted zone found with ID: foo" in err["Message"]
+
+ # Create a couple of query logging configs to work with.
+ for _ in range(3):
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+
+ # Retrieve a query logging config, then request more with an invalid token.
+ client.list_query_logging_configs(MaxResults="1")
+ with pytest.raises(ClientError) as exc:
+ client.list_query_logging_configs(NextToken="foo")
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidPaginationToken"
+ assert (
+ "Route 53 can't get the next page of query logging configurations "
+ "because the specified value for NextToken is invalid." in err["Message"]
+ )
+
+
+@mock_logs
+@mock_route53
+def test_list_query_logging_configs_good_args():
+ """Test valid arguments to list_query_logging_configs()."""
+ client = boto3.client("route53", region_name=TEST_REGION)
+ logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+ # Test when there are no query logging configs.
+ response = client.list_query_logging_configs()
+ query_logging_configs = response["QueryLoggingConfigs"]
+ assert len(query_logging_configs) == 0
+
+ # Create a couple of query logging configs to work with.
+ zone_ids = []
+ for _ in range(10):
+ hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+ hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+ zone_ids.append(hosted_zone_id)
+
+ log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+ client.create_query_logging_config(
+ HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+ )
+
+ # Verify all 10 of the query logging configs can be retrieved in one go.
+ response = client.list_query_logging_configs()
+ query_logging_configs = response["QueryLoggingConfigs"]
+ assert len(query_logging_configs) == 10
+ for idx, query_logging_config in enumerate(query_logging_configs):
+ assert query_logging_config["HostedZoneId"] == zone_ids[idx].split("/")[-1]
+
+ # Request only two of the query logging configs and verify there's a
+ # next_token.
+ response = client.list_query_logging_configs(MaxResults="2")
+ assert len(response["QueryLoggingConfigs"]) == 2
+ assert response["NextToken"]
+
+ # Request the remaining 8 query logging configs and verify there is
+ # no next token.
+ response = client.list_query_logging_configs(
+ MaxResults="8", NextToken=response["NextToken"]
+ )
+ assert len(response["QueryLoggingConfigs"]) == 8
+ assert "NextToken" not in response