From 5a7c711a74f3f91c86ac2527af09f4c40f49b6aa Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Fri, 25 Nov 2016 21:07:24 -0800 Subject: [PATCH 01/32] bring dynamodb2 update expression handling closer to spec --- moto/dynamodb2/models.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index a70d6347d..e9980410b 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -3,6 +3,7 @@ from collections import defaultdict import datetime import decimal import json +import re from moto.compat import OrderedDict from moto.core import BaseBackend @@ -110,27 +111,24 @@ class Item(object): } def update(self, update_expression, expression_attribute_names, expression_attribute_values): - ACTION_VALUES = ['SET', 'set', 'REMOVE', 'remove'] - - action = None - for value in update_expression.split(): - if value in ACTION_VALUES: - # An action - action = value - continue - else: + parts = [p for p in re.split(r'\b(SET|REMOVE|ADD|DELETE)\b', update_expression) if p] + for action, valstr in zip(parts[:-1:1], parts[1::1]): + values = valstr.split(',') + for value in values: # A Real value value = value.lstrip(":").rstrip(",") - for k, v in expression_attribute_names.items(): - value = value.replace(k, v) - if action == "REMOVE" or action == 'remove': - self.attrs.pop(value, None) - elif action == 'SET' or action == 'set': - key, value = value.split("=") - if value in expression_attribute_values: - self.attrs[key] = DynamoType(expression_attribute_values[value]) - else: - self.attrs[key] = DynamoType({"S": value}) + for k, v in expression_attribute_names.items(): + value = value.replace(k, v) + if action == "REMOVE" or action == 'remove': + self.attrs.pop(value, None) + elif action == 'SET' or action == 'set': + key, value = value.split("=") + key = key.strip() + value = value.strip() + if value in expression_attribute_values: + self.attrs[key] = DynamoType(expression_attribute_values[value]) + else: + self.attrs[key] = DynamoType({"S": value}) def update_with_attribute_updates(self, attribute_updates): for attribute_name, update_action in attribute_updates.items(): From 2c505615631c2958e7b5f4a3c196b5386370a9e0 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Tue, 29 Nov 2016 14:04:23 -0800 Subject: [PATCH 02/32] fix decoding keys in query condition --- moto/dynamodb2/models.py | 7 ++++--- moto/dynamodb2/responses.py | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index e9980410b..2c6d8d60f 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -119,9 +119,9 @@ class Item(object): value = value.lstrip(":").rstrip(",") for k, v in expression_attribute_names.items(): value = value.replace(k, v) - if action == "REMOVE" or action == 'remove': + if action == "REMOVE": self.attrs.pop(value, None) - elif action == 'SET' or action == 'set': + elif action == 'SET': key, value = value.split("=") key = key.strip() value = value.strip() @@ -129,6 +129,8 @@ class Item(object): self.attrs[key] = DynamoType(expression_attribute_values[value]) else: self.attrs[key] = DynamoType({"S": value}) + else: + raise NotImplementedError('{} update action not yet supported'.format(action)) def update_with_attribute_updates(self, attribute_updates): for attribute_name, update_action in attribute_updates.items(): @@ -323,7 +325,6 @@ class Table(object): def query(self, hash_key, range_comparison, range_objs, limit, exclusive_start_key, scan_index_forward, index_name=None, **filter_kwargs): results = [] - if index_name: all_indexes = (self.global_indexes or []) + (self.indexes or []) indexes_by_name = dict((i['IndexName'], i) for i in all_indexes) diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index 081afc2c4..eea2bace5 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -279,19 +279,22 @@ class DynamoHandler(BaseResponse): else: index = table.schema - key_map = [column for _, column in sorted((k, v) for k, v in self.body['ExpressionAttributeNames'].items())] + reverse_attribute_lookup = {v: k for k, v in self.body['ExpressionAttributeNames'].iteritems()} if " AND " in key_condition_expression: expressions = key_condition_expression.split(" AND ", 1) index_hash_key = [key for key in index if key['KeyType'] == 'HASH'][0] - hash_key_index_in_key_map = key_map.index(index_hash_key['AttributeName']) + hash_key_var = reverse_attribute_lookup.get(index_hash_key['AttributeName'], index_hash_key['AttributeName']) + i, hash_key_expression = ((i, e) for i, e in enumerate(expressions) if re.search(r'[\s(]#n1\b'.format(hash_key_var), e)).next() + hash_key_expression = hash_key_expression.strip('()') + expressions.pop(i) - hash_key_expression = expressions.pop(hash_key_index_in_key_map).strip('()') # TODO implement more than one range expression and OR operators range_key_expression = expressions[0].strip('()') range_key_expression_components = range_key_expression.split() range_comparison = range_key_expression_components[1] + if 'AND' in range_key_expression: range_comparison = 'BETWEEN' range_values = [ From 98a39cf4b53ffeff99cfc31c990b6fafefe54089 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Tue, 6 Dec 2016 12:14:57 -0800 Subject: [PATCH 03/32] account for keys potentially being substrings of other keys (e.g. #c1 and #c10) --- moto/dynamodb2/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 2c6d8d60f..5e7888842 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -118,7 +118,7 @@ class Item(object): # A Real value value = value.lstrip(":").rstrip(",") for k, v in expression_attribute_names.items(): - value = value.replace(k, v) + value = re.sub(r'{}\b'.format(k), v, value) if action == "REMOVE": self.attrs.pop(value, None) elif action == 'SET': From 390bef77521a683c3599198fc57a3375d6d79137 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Tue, 6 Dec 2016 16:57:36 -0800 Subject: [PATCH 04/32] fake change to force push because github was broken --- moto/dynamodb2/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 5e7888842..0e88d594b 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -119,6 +119,7 @@ class Item(object): value = value.lstrip(":").rstrip(",") for k, v in expression_attribute_names.items(): value = re.sub(r'{}\b'.format(k), v, value) + if action == "REMOVE": self.attrs.pop(value, None) elif action == 'SET': From 3c128fdb51d7cdb81f8128fe73a5adca6daf4c6a Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Wed, 7 Dec 2016 11:47:48 -0800 Subject: [PATCH 05/32] correct looping through update actions, value stripping, hash key regex --- moto/dynamodb2/models.py | 4 ++-- moto/dynamodb2/responses.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 0e88d594b..15a3e3ba3 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -112,11 +112,11 @@ class Item(object): def update(self, update_expression, expression_attribute_names, expression_attribute_values): parts = [p for p in re.split(r'\b(SET|REMOVE|ADD|DELETE)\b', update_expression) if p] - for action, valstr in zip(parts[:-1:1], parts[1::1]): + for action, valstr in zip(parts[:-1:2], parts[1::2]): values = valstr.split(',') for value in values: # A Real value - value = value.lstrip(":").rstrip(",") + value = value.lstrip(":").rstrip(",").strip() for k, v in expression_attribute_names.items(): value = re.sub(r'{}\b'.format(k), v, value) diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index eea2bace5..39cdaae4e 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -286,7 +286,8 @@ class DynamoHandler(BaseResponse): index_hash_key = [key for key in index if key['KeyType'] == 'HASH'][0] hash_key_var = reverse_attribute_lookup.get(index_hash_key['AttributeName'], index_hash_key['AttributeName']) - i, hash_key_expression = ((i, e) for i, e in enumerate(expressions) if re.search(r'[\s(]#n1\b'.format(hash_key_var), e)).next() + hash_key_regex = r'(^|[\s(]){}\b'.format(hash_key_var) + i, hash_key_expression = ((i, e) for i, e in enumerate(expressions) if re.search(hash_key_regex, e)).next() hash_key_expression = hash_key_expression.strip('()') expressions.pop(i) From 0c875fd268d06d6b7fe8084537ae2bd3d653f8e6 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Wed, 7 Dec 2016 13:31:15 -0800 Subject: [PATCH 06/32] fixes for python 2.6 and 3 --- moto/dynamodb2/responses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index 39cdaae4e..815bd9f57 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -279,7 +279,7 @@ class DynamoHandler(BaseResponse): else: index = table.schema - reverse_attribute_lookup = {v: k for k, v in self.body['ExpressionAttributeNames'].iteritems()} + reverse_attribute_lookup = dict((v, k) for k, v in six.iteritems(self.body['ExpressionAttributeNames'])) if " AND " in key_condition_expression: expressions = key_condition_expression.split(" AND ", 1) From 114de9ba0b8e745a0054408713dafdfb535d0ec3 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Wed, 7 Dec 2016 13:55:26 -0800 Subject: [PATCH 07/32] more fixes for 2.6 and 3 --- moto/dynamodb2/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index 815bd9f57..636a0f9d3 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -286,8 +286,8 @@ class DynamoHandler(BaseResponse): index_hash_key = [key for key in index if key['KeyType'] == 'HASH'][0] hash_key_var = reverse_attribute_lookup.get(index_hash_key['AttributeName'], index_hash_key['AttributeName']) - hash_key_regex = r'(^|[\s(]){}\b'.format(hash_key_var) - i, hash_key_expression = ((i, e) for i, e in enumerate(expressions) if re.search(hash_key_regex, e)).next() + hash_key_regex = r'(^|[\s(]){0}\b'.format(hash_key_var) + i, hash_key_expression = next((i, e) for i, e in enumerate(expressions) if re.search(hash_key_regex, e)) hash_key_expression = hash_key_expression.strip('()') expressions.pop(i) From d4a31e5e50c1eca8fb6f885c60b56c15a50dce09 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Thu, 8 Dec 2016 14:34:21 -0800 Subject: [PATCH 08/32] unit tests did not catch this, but this will not work under python 2.6 --- moto/dynamodb2/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 15a3e3ba3..4bca83582 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -118,7 +118,7 @@ class Item(object): # A Real value value = value.lstrip(":").rstrip(",").strip() for k, v in expression_attribute_names.items(): - value = re.sub(r'{}\b'.format(k), v, value) + value = re.sub(r'{0}\b'.format(k), v, value) if action == "REMOVE": self.attrs.pop(value, None) From 5eb866146a71455ac94bfe7750a1d88503620790 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Fri, 2 Jun 2017 13:19:45 -0700 Subject: [PATCH 09/32] add assert to catch odd numbers in operator/value parsing --- moto/dynamodb2/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 4bca83582..1a609bebb 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -112,6 +112,7 @@ class Item(object): def update(self, update_expression, expression_attribute_names, expression_attribute_values): parts = [p for p in re.split(r'\b(SET|REMOVE|ADD|DELETE)\b', update_expression) if p] + assert len(parts) % 2 == 0, "Mismatched operators and values in update expression: '{}'".format(update_expression) for action, valstr in zip(parts[:-1:2], parts[1::2]): values = valstr.split(',') for value in values: From b713eef491b84f440a85de25b10d6304874f817f Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Fri, 2 Jun 2017 13:41:33 -0700 Subject: [PATCH 10/32] cleanup after merge --- moto/dynamodb2/responses.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index 39b67240c..d3fa68b7b 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -316,16 +316,18 @@ class DynamoHandler(BaseResponse): else: index = table.schema - reverse_attribute_lookup = dict((v, k) for k, v in - six.iteritems(self.body['ExpressionAttributeNames'])) + reverse_attribute_lookup = dict((v, k) for k, v in + six.iteritems(self.body['ExpressionAttributeNames'])) if " AND " in key_condition_expression: expressions = key_condition_expression.split(" AND ", 1) index_hash_key = [key for key in index if key['KeyType'] == 'HASH'][0] - hash_key_var = reverse_attribute_lookup.get(index_hash_key['AttributeName'], index_hash_key['AttributeName']) + hash_key_var = reverse_attribute_lookup.get(index_hash_key['AttributeName'], + index_hash_key['AttributeName']) hash_key_regex = r'(^|[\s(]){0}\b'.format(hash_key_var) - i, hash_key_expression = next((i, e) for i, e in enumerate(expressions) if re.search(hash_key_regex, e)) + i, hash_key_expression = next((i, e) for i, e in enumerate(expressions) + if re.search(hash_key_regex, e)) hash_key_expression = hash_key_expression.strip('()') expressions.pop(i) From a0471b04072d9538aa885c97964f22617e9ac879 Mon Sep 17 00:00:00 2001 From: Peter Gorniak Date: Thu, 15 Jun 2017 15:34:58 -0700 Subject: [PATCH 11/32] add comment about splitting update expression by operator keywords --- moto/dynamodb2/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py index 32dbfadbd..e6f050781 100644 --- a/moto/dynamodb2/models.py +++ b/moto/dynamodb2/models.py @@ -116,7 +116,10 @@ class Item(BaseModel): } def update(self, update_expression, expression_attribute_names, expression_attribute_values): + # Update subexpressions are identifiable by the operator keyword, so split on that and + # get rid of the empty leading string. parts = [p for p in re.split(r'\b(SET|REMOVE|ADD|DELETE)\b', update_expression) if p] + # make sure that we correctly found only operator/value pairs assert len(parts) % 2 == 0, "Mismatched operators and values in update expression: '{}'".format(update_expression) for action, valstr in zip(parts[:-1:2], parts[1::2]): values = valstr.split(',') From c118d12e6fa5e8791133c8135c73906e1164a8b4 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Mon, 19 Jun 2017 18:22:33 -0700 Subject: [PATCH 12/32] Add describe_parameters support --- moto/ssm/models.py | 6 ++++++ moto/ssm/responses.py | 14 ++++++++++++++ setup.py | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index 3344623dd..cb4d5946e 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -46,6 +46,12 @@ class SimpleSystemManagerBackend(BaseBackend): except KeyError: pass + def get_all_parameters(self): + result = [] + for k, _ in self._parameters.iteritems(): + result.append(self._parameters[k]) + return result + def get_parameters(self, names, with_decryption): result = [] for name in names: diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py index ee21d7380..6c53bf039 100644 --- a/moto/ssm/responses.py +++ b/moto/ssm/responses.py @@ -43,6 +43,20 @@ class SimpleSystemManagerResponse(BaseResponse): return json.dumps(response) + def describe_parameters(self): + # filters = self._get_param('Filters') + result = self.ssm_backend.get_all_parameters() + + response = { + 'Parameters': [], + } + + for parameter in result: + param_data = parameter.response_object(False) + response['Parameters'].append(param_data) + + return json.dumps(response) + def put_parameter(self): name = self._get_param('Name') description = self._get_param('Description') diff --git a/setup.py b/setup.py index 289c1684c..2da16557c 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ extras_require = { setup( name='moto', - version='1.0.1', + version='1.0.1.1', description='A library that allows your python tests to easily' ' mock out the boto library', author='Steve Pulec', From db20dfcd82034ca49d98ed3d112bc454bbacf1d4 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Tue, 20 Jun 2017 11:47:53 -0700 Subject: [PATCH 13/32] Added filtering --- moto/ssm/models.py | 5 +- moto/ssm/responses.py | 51 +++++++++++- setup.py | 2 +- tests/test_ssm/test_ssm_boto3.py | 137 +++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index cb4d5946e..4efa22817 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -28,11 +28,14 @@ class Parameter(BaseModel): return value[len(prefix):] def response_object(self, decrypt=False): - return { + r = { 'Name': self.name, 'Type': self.type, 'Value': self.decrypt(self.value) if decrypt else self.value } + if self.keyid: + r['KeyId'] = self.keyid + return r class SimpleSystemManagerBackend(BaseBackend): diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py index 6c53bf039..f4ed9561d 100644 --- a/moto/ssm/responses.py +++ b/moto/ssm/responses.py @@ -44,16 +44,61 @@ class SimpleSystemManagerResponse(BaseResponse): return json.dumps(response) def describe_parameters(self): - # filters = self._get_param('Filters') + page_size = 10 + filters = self._get_param('Filters') + token = self._get_param('NextToken') + if hasattr(token, 'strip'): + token = token.strip() + if not token: + token = '0' + + token = int(token) + + result = self.ssm_backend.get_all_parameters() response = { 'Parameters': [], } - for parameter in result: + end = token + page_size + for parameter in result[token:]: param_data = parameter.response_object(False) - response['Parameters'].append(param_data) + add = False + + if filters: + for filter in filters: + if filter['Key'] == 'Name': + k = param_data['Name'] + for v in filter['Values']: + if k.startswith(v): + add = True + break + elif filter['Key'] == 'Type': + k = param_data['Type'] + for v in filter['Values']: + if k == v: + add = True + break + elif filter['Key'] == 'KeyId': + k = param_data.get('KeyId') + if k: + for v in filter['Values']: + if k == v: + add = True + break + else: + add = True + + if add: + response['Parameters'].append(param_data) + + token = token + 1 + if len(response['Parameters']) == page_size: + response['NextToken'] = str(end) + break + + return json.dumps(response) diff --git a/setup.py b/setup.py index 2da16557c..b00567895 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ extras_require = { setup( name='moto', - version='1.0.1.1', + version='1.0.1.2', description='A library that allows your python tests to easily' ' mock out the boto library', author='Steve Pulec', diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py index 6b8a1a369..8b5d1f200 100644 --- a/tests/test_ssm/test_ssm_boto3.py +++ b/tests/test_ssm/test_ssm_boto3.py @@ -47,6 +47,143 @@ def test_put_parameter(): response['Parameters'][0]['Type'].should.equal('String') +@mock_ssm +def test_describe_parameters(): + client = boto3.client('ssm', region_name='us-east-1') + + client.put_parameter( + Name='test', + Description='A test parameter', + Value='value', + Type='String') + + response = client.describe_parameters() + + len(response['Parameters']).should.equal(1) + response['Parameters'][0]['Name'].should.equal('test') + response['Parameters'][0]['Type'].should.equal('String') + + +@mock_ssm +def test_describe_parameters_paging(): + client = boto3.client('ssm', region_name='us-east-1') + + for i in range(50): + client.put_parameter( + Name="param-%d" % i, + Value="value-%d" % i, + Type="String" + ) + + response = client.describe_parameters() + len(response['Parameters']).should.equal(10) + response['NextToken'].should.equal('10') + + response = client.describe_parameters(NextToken=response['NextToken']) + len(response['Parameters']).should.equal(10) + response['NextToken'].should.equal('20') + + response = client.describe_parameters(NextToken=response['NextToken']) + len(response['Parameters']).should.equal(10) + response['NextToken'].should.equal('30') + + response = client.describe_parameters(NextToken=response['NextToken']) + len(response['Parameters']).should.equal(10) + response['NextToken'].should.equal('40') + + response = client.describe_parameters(NextToken=response['NextToken']) + len(response['Parameters']).should.equal(10) + response['NextToken'].should.equal('50') + + response = client.describe_parameters(NextToken=response['NextToken']) + len(response['Parameters']).should.equal(0) + ''.should.equal(response.get('NextToken', '')) + +@mock_ssm +def test_describe_parameters_filter_names(): + client = boto3.client('ssm', region_name='us-east-1') + + for i in range(50): + p = { + 'Name': "param-%d" % i, + 'Value': "value-%d" % i, + 'Type': "String" + } + if i % 5 == 0: + p['Type'] = 'SecureString' + p['KeyId'] = 'a key' + client.put_parameter(**p) + + + response = client.describe_parameters(Filters=[ + { + 'Key': 'Name', + 'Values': ['param-45', 'param-22'] + }, + ]) + len(response['Parameters']).should.equal(2) + response['Parameters'][0]['Name'].should.equal('param-22') + response['Parameters'][0]['Type'].should.equal('String') + response['Parameters'][1]['Name'].should.equal('param-45') + response['Parameters'][1]['Type'].should.equal('SecureString') + ''.should.equal(response.get('NextToken', '')) + +@mock_ssm +def test_describe_parameters_filter_type(): + client = boto3.client('ssm', region_name='us-east-1') + + for i in range(50): + p = { + 'Name': "param-%d" % i, + 'Value': "value-%d" % i, + 'Type': "String" + } + if i % 5 == 0: + p['Type'] = 'SecureString' + p['KeyId'] = 'a key' + client.put_parameter(**p) + + + response = client.describe_parameters(Filters=[ + { + 'Key': 'Type', + 'Values': ['SecureString'] + }, + ]) + len(response['Parameters']).should.equal(10) + response['Parameters'][0]['Name'].should.equal('param-35') + response['Parameters'][0]['Type'].should.equal('SecureString') + '10'.should.equal(response.get('NextToken', '')) + +@mock_ssm +def test_describe_parameters_filter_keyid(): + client = boto3.client('ssm', region_name='us-east-1') + + for i in range(50): + p = { + 'Name': "param-%d" % i, + 'Value': "value-%d" % i, + 'Type': "String" + } + if i % 5 == 0: + p['Type'] = 'SecureString' + p['KeyId'] = "key:%d" % i + client.put_parameter(**p) + + + response = client.describe_parameters(Filters=[ + { + 'Key': 'KeyId', + 'Values': ['key:5','key:10'] + }, + ]) + len(response['Parameters']).should.equal(2) + response['Parameters'][0]['Name'].should.equal('param-10') + response['Parameters'][0]['Type'].should.equal('SecureString') + response['Parameters'][1]['Name'].should.equal('param-5') + response['Parameters'][1]['Type'].should.equal('SecureString') + ''.should.equal(response.get('NextToken', '')) + @mock_ssm def test_put_parameter_secure_default_kms(): client = boto3.client('ssm', region_name='us-east-1') From f2d64e86395864a355d660ba023a1a7f27916d00 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Tue, 20 Jun 2017 12:03:50 -0700 Subject: [PATCH 14/32] Revert version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b00567895..289c1684c 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ extras_require = { setup( name='moto', - version='1.0.1.2', + version='1.0.1', description='A library that allows your python tests to easily' ' mock out the boto library', author='Steve Pulec', From 5a4b2139501b1ec695afac6970615efd61686010 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Tue, 20 Jun 2017 12:38:52 -0700 Subject: [PATCH 15/32] Remove blank lines --- moto/ssm/responses.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py index f4ed9561d..09fe6d0c2 100644 --- a/moto/ssm/responses.py +++ b/moto/ssm/responses.py @@ -51,12 +51,9 @@ class SimpleSystemManagerResponse(BaseResponse): token = token.strip() if not token: token = '0' - token = int(token) - result = self.ssm_backend.get_all_parameters() - response = { 'Parameters': [], } @@ -98,8 +95,6 @@ class SimpleSystemManagerResponse(BaseResponse): response['NextToken'] = str(end) break - - return json.dumps(response) def put_parameter(self): From f0fae81af1f522e7344708e5117b70d2ae7957c1 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Tue, 20 Jun 2017 12:55:01 -0700 Subject: [PATCH 16/32] Fix iteritems --- moto/ssm/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index 4efa22817..f1aac336b 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -51,7 +51,7 @@ class SimpleSystemManagerBackend(BaseBackend): def get_all_parameters(self): result = [] - for k, _ in self._parameters.iteritems(): + for k, _ in self._parameters.items(): result.append(self._parameters[k]) return result From 63f01039c3f321c6f726c620eba6ec66e98f55ec Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Tue, 20 Jun 2017 13:51:25 -0700 Subject: [PATCH 17/32] Implementing RDS Snapshots --- moto/rds2/exceptions.py | 8 ++++ moto/rds2/models.py | 81 +++++++++++++++++++++++++++++++++++- moto/rds2/responses.py | 59 +++++++++++++++++++++++++- tests/test_rds2/test_rds2.py | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 2 deletions(-) diff --git a/moto/rds2/exceptions.py b/moto/rds2/exceptions.py index 29e92941d..057a13ba2 100644 --- a/moto/rds2/exceptions.py +++ b/moto/rds2/exceptions.py @@ -28,6 +28,14 @@ class DBInstanceNotFoundError(RDSClientError): "Database {0} not found.".format(database_identifier)) +class DBSnapshotNotFoundError(RDSClientError): + + def __init__(self): + super(DBSnapshotNotFoundError, self).__init__( + 'DBSnapshotNotFound', + "DBSnapshotIdentifier does not refer to an existing DB snapshot.") + + class DBSecurityGroupNotFoundError(RDSClientError): def __init__(self, security_group_name): diff --git a/moto/rds2/models.py b/moto/rds2/models.py index 4036cdcd1..ae97ba1f2 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import copy +import datetime from collections import defaultdict import boto.rds2 @@ -10,9 +11,11 @@ from moto.cloudformation.exceptions import UnformattedGetAttTemplateException from moto.compat import OrderedDict from moto.core import BaseBackend, BaseModel from moto.core.utils import get_random_hex +from moto.core.utils import iso_8601_datetime_with_milliseconds from moto.ec2.models import ec2_backends from .exceptions import (RDSClientError, DBInstanceNotFoundError, + DBSnapshotNotFoundError, DBSecurityGroupNotFoundError, DBSubnetGroupNotFoundError, DBParameterGroupNotFoundError) @@ -205,7 +208,7 @@ class Database(BaseModel): {% endif %} {% if database.iops %} {{ database.iops }} - io1 + standard {% else %} {{ database.storage_type }} {% endif %} @@ -399,6 +402,53 @@ class Database(BaseModel): backend.delete_database(self.db_instance_identifier) +class Snapshot(BaseModel): + def __init__(self, database, snapshot_id, tags): + self.database = database + self.snapshot_id = snapshot_id + self.tags = tags + self.created_at = iso_8601_datetime_with_milliseconds(datetime.datetime.now()) + + @property + def snapshot_arn(self): + return "arn:aws:rds:{0}:1234567890:snapshot:{1}".format(self.database.region, self.snapshot_id) + + def to_xml(self): + template = Template(""" + {{ snapshot.snapshot_id }} + {{ database.db_instance_identifier }} + {{ snapshot.created_at }} + {{ database.engine }} + {{ database.allocated_storage }} + available + {{ database.port }} + {{ database.availability_zone }} + {{ database.db_subnet_group.vpc_id }} + {{ snapshot.created_at }} + {{ database.master_username }} + {{ database.engine_version }} + general-public-license + manual + {% if database.iops %} + {{ database.iops }} + io1 + {% else %} + {{ database.storage_type }} + {% endif %} + {{ database.option_group_name }} + {{ 100 }} + {{ database.region }} + + + {{ database.storage_encrypted }} + {{ database.kms_key_id }} + {{ snapshot.snapshot_arn }} + + false + """) + return template.render(snapshot=self, database=self.database) + + class SecurityGroup(BaseModel): def __init__(self, group_name, description, tags): @@ -607,6 +657,7 @@ class RDS2Backend(BaseBackend): self.arn_regex = re_compile( r'^arn:aws:rds:.*:[0-9]*:(db|es|og|pg|ri|secgrp|snapshot|subgrp):.*$') self.databases = OrderedDict() + self.snapshots = OrderedDict() self.db_parameter_groups = {} self.option_groups = {} self.security_groups = {} @@ -624,6 +675,20 @@ class RDS2Backend(BaseBackend): self.databases[database_id] = database return database + def create_snapshot(self, db_instance_identifier, db_snapshot_identifier, tags): + database = self.databases.get(db_instance_identifier) + if not database: + raise DBInstanceNotFoundError(db_instance_identifier) + snapshot = Snapshot(database, db_snapshot_identifier, tags) + self.snapshots[db_snapshot_identifier] = snapshot + return snapshot + + def delete_snapshot(self, db_snapshot_identifier): + if db_snapshot_identifier not in self.snapshots: + raise DBSnapshotNotFoundError() + + return self.snapshots.pop(db_snapshot_identifier) + def create_database_replica(self, db_kwargs): database_id = db_kwargs['db_instance_identifier'] source_database_id = db_kwargs['source_db_identifier'] @@ -646,6 +711,20 @@ class RDS2Backend(BaseBackend): raise DBInstanceNotFoundError(db_instance_identifier) return self.databases.values() + def describe_snapshots(self, db_instance_identifier, db_snapshot_identifier): + if db_instance_identifier: + for snapshot in self.snapshots.values(): + if snapshot.database.db_instance_identifier == db_instance_identifier: + return [snapshot] + raise DBSnapshotNotFoundError() + + if db_snapshot_identifier: + if db_snapshot_identifier in self.snapshots: + return [self.snapshots[db_snapshot_identifier]] + raise DBSnapshotNotFoundError() + + return self.snapshots.values() + def modify_database(self, db_instance_identifier, db_kwargs): database = self.describe_databases(db_instance_identifier)[0] database.update(db_kwargs) diff --git a/moto/rds2/responses.py b/moto/rds2/responses.py index f8f33f2b9..cdadd3424 100644 --- a/moto/rds2/responses.py +++ b/moto/rds2/responses.py @@ -39,7 +39,7 @@ class RDS2Response(BaseResponse): "region": self.region, "security_groups": self._get_multi_param('DBSecurityGroups.DBSecurityGroupName'), "storage_encrypted": self._get_param("StorageEncrypted"), - "storage_type": self._get_param("StorageType"), + "storage_type": self._get_param("StorageType", 'standard'), # VpcSecurityGroupIds.member.N "tags": list(), } @@ -150,6 +150,27 @@ class RDS2Response(BaseResponse): template = self.response_template(REBOOT_DATABASE_TEMPLATE) return template.render(database=database) + def create_db_snapshot(self): + db_instance_identifier = self._get_param('DBInstanceIdentifier') + db_snapshot_identifier = self._get_param('DBSnapshotIdentifier') + tags = self._get_param('Tags', []) + snapshot = self.backend.create_snapshot(db_instance_identifier, db_snapshot_identifier, tags) + template = self.response_template(CREATE_SNAPSHOT_TEMPLATE) + return template.render(snapshot=snapshot) + + def describe_db_snapshots(self): + db_instance_identifier = self._get_param('DBInstanceIdentifier') + db_snapshot_identifier = self._get_param('DBSnapshotIdentifier') + snapshots = self.backend.describe_snapshots(db_instance_identifier, db_snapshot_identifier) + template = self.response_template(DESCRIBE_SNAPSHOTS_TEMPLATE) + return template.render(snapshots=snapshots) + + def delete_db_snapshot(self): + db_snapshot_identifier = self._get_param('DBSnapshotIdentifier') + snapshot = self.backend.delete_snapshot(db_snapshot_identifier) + template = self.response_template(DELETE_SNAPSHOT_TEMPLATE) + return template.render(snapshot=snapshot) + def list_tags_for_resource(self): arn = self._get_param('ResourceName') template = self.response_template(LIST_TAGS_FOR_RESOURCE_TEMPLATE) @@ -397,6 +418,42 @@ DELETE_DATABASE_TEMPLATE = """ + + {{ snapshot.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + + +""" + +DESCRIBE_SNAPSHOTS_TEMPLATE = """ + + + {%- for snapshot in snapshots -%} + {{ snapshot.to_xml() }} + {%- endfor -%} + + {% if marker %} + {{ marker }} + {% endif %} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + +""" + +DELETE_SNAPSHOT_TEMPLATE = """ + + {{ snapshot.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + + +""" + CREATE_SECURITY_GROUP_TEMPLATE = """ {{ security_group.to_xml() }} diff --git a/tests/test_rds2/test_rds2.py b/tests/test_rds2/test_rds2.py index 148b00aa1..7a801257c 100644 --- a/tests/test_rds2/test_rds2.py +++ b/tests/test_rds2/test_rds2.py @@ -168,6 +168,81 @@ def test_delete_non_existant_database(): DBInstanceIdentifier="not-a-db").should.throw(ClientError) +@mock_rds2 +def test_create_db_snapshots(): + conn = boto3.client('rds', region_name='us-west-2') + conn.create_db_snapshot.when.called_with( + DBInstanceIdentifier='db-primary-1', + DBSnapshotIdentifier='snapshot-1').should.throw(ClientError) + + conn.create_db_instance(DBInstanceIdentifier='db-primary-1', + AllocatedStorage=10, + Engine='postgres', + DBName='staging-postgres', + DBInstanceClass='db.m1.small', + MasterUsername='root', + MasterUserPassword='hunter2', + Port=1234, + DBSecurityGroups=["my_sg"]) + + snapshot = conn.create_db_snapshot(DBInstanceIdentifier='db-primary-1', + DBSnapshotIdentifier='g-1').get('DBSnapshot') + + snapshot.get('Engine').should.equal('postgres') + snapshot.get('DBInstanceIdentifier').should.equal('db-primary-1') + snapshot.get('DBSnapshotIdentifier').should.equal('g-1') + + +@mock_rds2 +def test_describe_db_snapshots(): + conn = boto3.client('rds', region_name='us-west-2') + conn.create_db_instance(DBInstanceIdentifier='db-primary-1', + AllocatedStorage=10, + Engine='postgres', + DBName='staging-postgres', + DBInstanceClass='db.m1.small', + MasterUsername='root', + MasterUserPassword='hunter2', + Port=1234, + DBSecurityGroups=["my_sg"]) + conn.describe_db_snapshots.when.called_with( + DBInstanceIdentifier="db-primary-1").should.throw(ClientError) + + created = conn.create_db_snapshot(DBInstanceIdentifier='db-primary-1', + DBSnapshotIdentifier='snapshot-1').get('DBSnapshot') + + created.get('Engine').should.equal('postgres') + + by_database_id = conn.describe_db_snapshots(DBInstanceIdentifier='db-primary-1').get('DBSnapshots') + by_snapshot_id = conn.describe_db_snapshots(DBSnapshotIdentifier='snapshot-1').get('DBSnapshots') + by_snapshot_id.should.equal(by_database_id) + + snapshot = by_snapshot_id[0] + snapshot.should.equal(created) + snapshot.get('Engine').should.equal('postgres') + + +@mock_rds2 +def test_delete_db_snapshot(): + conn = boto3.client('rds', region_name='us-west-2') + conn.create_db_instance(DBInstanceIdentifier='db-primary-1', + AllocatedStorage=10, + Engine='postgres', + DBName='staging-postgres', + DBInstanceClass='db.m1.small', + MasterUsername='root', + MasterUserPassword='hunter2', + Port=1234, + DBSecurityGroups=["my_sg"]) + conn.create_db_snapshot(DBInstanceIdentifier='db-primary-1', + DBSnapshotIdentifier='snapshot-1') + + conn.describe_db_snapshots(DBSnapshotIdentifier='snapshot-1').get('DBSnapshots')[0] + conn.delete_db_snapshot(DBSnapshotIdentifier='snapshot-1') + conn.describe_db_snapshots.when.called_with( + DBSnapshotIdentifier='snapshot-1').should.throw(ClientError) + + @mock_rds2 def test_create_option_group(): conn = boto3.client('rds', region_name='us-west-2') From ccb4ffde7c4f5bede92a4601f2832316a3335d56 Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Tue, 20 Jun 2017 13:53:22 -0700 Subject: [PATCH 18/32] Supporting io1 type --- moto/rds2/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/rds2/models.py b/moto/rds2/models.py index ae97ba1f2..2d9a66401 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -208,7 +208,7 @@ class Database(BaseModel): {% endif %} {% if database.iops %} {{ database.iops }} - standard + io1 {% else %} {{ database.storage_type }} {% endif %} From fb2efb1c6dca45b6781fa4e2d977735aeedc2d9a Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Tue, 20 Jun 2017 14:00:56 -0700 Subject: [PATCH 19/32] Implementing snapshots on rds delete --- moto/rds/responses.py | 3 ++- moto/rds2/models.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/moto/rds/responses.py b/moto/rds/responses.py index 0895a8bf2..cdcbe3603 100644 --- a/moto/rds/responses.py +++ b/moto/rds/responses.py @@ -114,7 +114,8 @@ class RDSResponse(BaseResponse): def delete_db_instance(self): db_instance_identifier = self._get_param('DBInstanceIdentifier') - database = self.backend.delete_database(db_instance_identifier) + db_snapshot_name = self._get_param('FinalDBSnapshotIdentifier') + database = self.backend.delete_database(db_instance_identifier, db_snapshot_name) template = self.response_template(DELETE_DATABASE_TEMPLATE) return template.render(database=database) diff --git a/moto/rds2/models.py b/moto/rds2/models.py index 2d9a66401..549f6e247 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -403,10 +403,10 @@ class Database(BaseModel): class Snapshot(BaseModel): - def __init__(self, database, snapshot_id, tags): + def __init__(self, database, snapshot_id, tags=None): self.database = database self.snapshot_id = snapshot_id - self.tags = tags + self.tags = tags or [] self.created_at = iso_8601_datetime_with_milliseconds(datetime.datetime.now()) @property @@ -746,13 +746,15 @@ class RDS2Backend(BaseBackend): return backend.describe_databases(db_name)[0] - def delete_database(self, db_instance_identifier): + def delete_database(self, db_instance_identifier, db_snapshot_name): if db_instance_identifier in self.databases: database = self.databases.pop(db_instance_identifier) if database.is_replica: primary = self.find_db_from_id(database.source_db_identifier) primary.remove_replica(database) database.status = 'deleting' + if db_snapshot_name: + self.snapshots[db_snapshot_name] = Snapshot(database, db_snapshot_name) return database else: raise DBInstanceNotFoundError(db_instance_identifier) From 8df7169915cf4e894133c7640ea8758e2eddda6a Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Tue, 20 Jun 2017 14:01:28 -0700 Subject: [PATCH 20/32] Snapshots are optional --- moto/rds2/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/rds2/models.py b/moto/rds2/models.py index 549f6e247..86f7bae9a 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -746,7 +746,7 @@ class RDS2Backend(BaseBackend): return backend.describe_databases(db_name)[0] - def delete_database(self, db_instance_identifier, db_snapshot_name): + def delete_database(self, db_instance_identifier, db_snapshot_name=None): if db_instance_identifier in self.databases: database = self.databases.pop(db_instance_identifier) if database.is_replica: From e57798cb96966b83e42fb9ab3a9cc74f32879084 Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Tue, 20 Jun 2017 14:46:13 -0700 Subject: [PATCH 21/32] Implementing snapshots on rds instance deletion --- moto/rds/responses.py | 3 +-- moto/rds2/responses.py | 3 ++- tests/test_rds2/test_rds2.py | 14 ++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/moto/rds/responses.py b/moto/rds/responses.py index cdcbe3603..0895a8bf2 100644 --- a/moto/rds/responses.py +++ b/moto/rds/responses.py @@ -114,8 +114,7 @@ class RDSResponse(BaseResponse): def delete_db_instance(self): db_instance_identifier = self._get_param('DBInstanceIdentifier') - db_snapshot_name = self._get_param('FinalDBSnapshotIdentifier') - database = self.backend.delete_database(db_instance_identifier, db_snapshot_name) + database = self.backend.delete_database(db_instance_identifier) template = self.response_template(DELETE_DATABASE_TEMPLATE) return template.render(database=database) diff --git a/moto/rds2/responses.py b/moto/rds2/responses.py index cdadd3424..b26f2e347 100644 --- a/moto/rds2/responses.py +++ b/moto/rds2/responses.py @@ -140,7 +140,8 @@ class RDS2Response(BaseResponse): def delete_db_instance(self): db_instance_identifier = self._get_param('DBInstanceIdentifier') - database = self.backend.delete_database(db_instance_identifier) + db_snapshot_name = self._get_param('FinalDBSnapshotIdentifier') + database = self.backend.delete_database(db_instance_identifier, db_snapshot_name) template = self.response_template(DELETE_DATABASE_TEMPLATE) return template.render(database=database) diff --git a/tests/test_rds2/test_rds2.py b/tests/test_rds2/test_rds2.py index 7a801257c..f869dc1ce 100644 --- a/tests/test_rds2/test_rds2.py +++ b/tests/test_rds2/test_rds2.py @@ -145,10 +145,10 @@ def test_delete_database(): conn = boto3.client('rds', region_name='us-west-2') instances = conn.describe_db_instances() list(instances['DBInstances']).should.have.length_of(0) - conn.create_db_instance(DBInstanceIdentifier='db-master-1', + conn.create_db_instance(DBInstanceIdentifier='db-primary-1', AllocatedStorage=10, - DBInstanceClass='postgres', - Engine='db.m1.small', + Engine='postgres', + DBInstanceClass='db.m1.small', MasterUsername='root', MasterUserPassword='hunter2', Port=1234, @@ -156,10 +156,16 @@ def test_delete_database(): instances = conn.describe_db_instances() list(instances['DBInstances']).should.have.length_of(1) - conn.delete_db_instance(DBInstanceIdentifier="db-master-1") + conn.delete_db_instance(DBInstanceIdentifier="db-primary-1", + FinalDBSnapshotIdentifier='primary-1-snapshot') + instances = conn.describe_db_instances() list(instances['DBInstances']).should.have.length_of(0) + # Saved the snapshot + snapshots = conn.describe_db_snapshots(DBInstanceIdentifier="db-primary-1").get('DBSnapshots') + snapshots[0].get('Engine').should.equal('postgres') + @mock_rds2 def test_delete_non_existant_database(): From 8ca27e184aa98b62fd6841862499f66084b71bf1 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Mon, 26 Jun 2017 11:17:36 -0700 Subject: [PATCH 22/32] Simplify tests --- tests/test_ssm/test_ssm_boto3.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py index 8b5d1f200..a62536a23 100644 --- a/tests/test_ssm/test_ssm_boto3.py +++ b/tests/test_ssm/test_ssm_boto3.py @@ -118,14 +118,12 @@ def test_describe_parameters_filter_names(): response = client.describe_parameters(Filters=[ { 'Key': 'Name', - 'Values': ['param-45', 'param-22'] + 'Values': ['param-22'] }, ]) - len(response['Parameters']).should.equal(2) + len(response['Parameters']).should.equal(1) response['Parameters'][0]['Name'].should.equal('param-22') response['Parameters'][0]['Type'].should.equal('String') - response['Parameters'][1]['Name'].should.equal('param-45') - response['Parameters'][1]['Type'].should.equal('SecureString') ''.should.equal(response.get('NextToken', '')) @mock_ssm @@ -174,14 +172,12 @@ def test_describe_parameters_filter_keyid(): response = client.describe_parameters(Filters=[ { 'Key': 'KeyId', - 'Values': ['key:5','key:10'] + 'Values': ['key:10'] }, ]) - len(response['Parameters']).should.equal(2) + len(response['Parameters']).should.equal(1) response['Parameters'][0]['Name'].should.equal('param-10') response['Parameters'][0]['Type'].should.equal('SecureString') - response['Parameters'][1]['Name'].should.equal('param-5') - response['Parameters'][1]['Type'].should.equal('SecureString') ''.should.equal(response.get('NextToken', '')) @mock_ssm From 27f1248788b71943e3c04c9b7e173ac70e22dd81 Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Mon, 26 Jun 2017 11:20:56 -0700 Subject: [PATCH 23/32] Fix spacing --- tests/test_ssm/test_ssm_boto3.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py index a62536a23..de0793e82 100644 --- a/tests/test_ssm/test_ssm_boto3.py +++ b/tests/test_ssm/test_ssm_boto3.py @@ -99,6 +99,7 @@ def test_describe_parameters_paging(): len(response['Parameters']).should.equal(0) ''.should.equal(response.get('NextToken', '')) + @mock_ssm def test_describe_parameters_filter_names(): client = boto3.client('ssm', region_name='us-east-1') @@ -114,7 +115,6 @@ def test_describe_parameters_filter_names(): p['KeyId'] = 'a key' client.put_parameter(**p) - response = client.describe_parameters(Filters=[ { 'Key': 'Name', @@ -126,6 +126,7 @@ def test_describe_parameters_filter_names(): response['Parameters'][0]['Type'].should.equal('String') ''.should.equal(response.get('NextToken', '')) + @mock_ssm def test_describe_parameters_filter_type(): client = boto3.client('ssm', region_name='us-east-1') @@ -153,6 +154,7 @@ def test_describe_parameters_filter_type(): response['Parameters'][0]['Type'].should.equal('SecureString') '10'.should.equal(response.get('NextToken', '')) + @mock_ssm def test_describe_parameters_filter_keyid(): client = boto3.client('ssm', region_name='us-east-1') @@ -180,6 +182,7 @@ def test_describe_parameters_filter_keyid(): response['Parameters'][0]['Type'].should.equal('SecureString') ''.should.equal(response.get('NextToken', '')) + @mock_ssm def test_put_parameter_secure_default_kms(): client = boto3.client('ssm', region_name='us-east-1') From 7bf5211bef53c3b821bb60435ddab9db9f96da9e Mon Sep 17 00:00:00 2001 From: Declan Shanaghy Date: Mon, 26 Jun 2017 12:07:44 -0700 Subject: [PATCH 24/32] Simplify test 2 --- tests/test_ssm/test_ssm_boto3.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py index de0793e82..60a027933 100644 --- a/tests/test_ssm/test_ssm_boto3.py +++ b/tests/test_ssm/test_ssm_boto3.py @@ -150,7 +150,6 @@ def test_describe_parameters_filter_type(): }, ]) len(response['Parameters']).should.equal(10) - response['Parameters'][0]['Name'].should.equal('param-35') response['Parameters'][0]['Type'].should.equal('SecureString') '10'.should.equal(response.get('NextToken', '')) From 8921920ae6671cd251ffdb18ed452d6932bdb203 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Tue, 27 Jun 2017 17:18:21 +1000 Subject: [PATCH 25/32] add flag to enable SSL for moto_server --- moto/server.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/moto/server.py b/moto/server.py index e5426bc7a..be41f1ed0 100644 --- a/moto/server.py +++ b/moto/server.py @@ -171,6 +171,12 @@ def main(argv=sys.argv[1:]): help='Reload server on a file change', default=False ) + parser.add_argument( + '-s', '--ssl', + action='store_true', + help='Enable SSL encrypted connection (use https://... URL)', + default=False + ) args = parser.parse_args(argv) @@ -180,7 +186,8 @@ def main(argv=sys.argv[1:]): main_app.debug = True run_simple(args.host, args.port, main_app, - threaded=True, use_reloader=args.reload) + threaded=True, use_reloader=args.reload, + ssl_context='adhoc' if args.ssl else None) if __name__ == '__main__': From c4b9088bfcee05f39ccec8989eafa15b0fc69ddf Mon Sep 17 00:00:00 2001 From: Steven Cipriano Date: Tue, 27 Jun 2017 11:31:43 -0700 Subject: [PATCH 26/32] Add support for recursive emr settings - Updates _RecursiveDictRef to not implement __getitem__, avoiding errors when using recursive settings for an emr job flow --- moto/core/responses.py | 3 +++ tests/test_emr/test_emr_boto3.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/moto/core/responses.py b/moto/core/responses.py index adad5d1de..82e9d4cad 100644 --- a/moto/core/responses.py +++ b/moto/core/responses.py @@ -414,6 +414,9 @@ class _RecursiveDictRef(object): def __getattr__(self, key): return self.dic.__getattr__(key) + def __getitem__(self, key): + return self.dic.__getitem__(key) + def set_reference(self, key, dic): """Set the RecursiveDictRef object to keep reference to dict object (dic) at the key. diff --git a/tests/test_emr/test_emr_boto3.py b/tests/test_emr/test_emr_boto3.py index 830abdb85..237ff8bba 100644 --- a/tests/test_emr/test_emr_boto3.py +++ b/tests/test_emr/test_emr_boto3.py @@ -64,7 +64,18 @@ def test_describe_cluster(): args['Configurations'] = [ {'Classification': 'yarn-site', 'Properties': {'someproperty': 'somevalue', - 'someotherproperty': 'someothervalue'}}] + 'someotherproperty': 'someothervalue'}}, + {'Classification': 'nested-configs', + 'Properties': {}, + 'Configurations': [ + { + 'Classification': 'nested-config', + 'Properties': { + 'nested-property': 'nested-value' + } + } + ]} + ] args['Instances']['AdditionalMasterSecurityGroups'] = ['additional-master'] args['Instances']['AdditionalSlaveSecurityGroups'] = ['additional-slave'] args['Instances']['Ec2KeyName'] = 'mykey' @@ -87,6 +98,10 @@ def test_describe_cluster(): config['Classification'].should.equal('yarn-site') config['Properties'].should.equal(args['Configurations'][0]['Properties']) + nested_config = cl['Configurations'][1] + nested_config['Classification'].should.equal('nested-configs') + nested_config['Properties'].should.equal(args['Configurations'][1]['Properties']) + attrs = cl['Ec2InstanceAttributes'] attrs['AdditionalMasterSecurityGroups'].should.equal( args['Instances']['AdditionalMasterSecurityGroups']) From 898031b40c9bd4a4396f5972e0916081c90f7575 Mon Sep 17 00:00:00 2001 From: Luis Jimenez Date: Thu, 29 Jun 2017 09:24:09 -0400 Subject: [PATCH 27/32] SQSResponse: include MD5OfMessageAttributes parameter only when there are message attributes --- moto/sqs/responses.py | 6 +++ tests/test_sqs/test_sqs.py | 76 +++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/moto/sqs/responses.py b/moto/sqs/responses.py index 53bbac6ef..ba4a56b8f 100644 --- a/moto/sqs/responses.py +++ b/moto/sqs/responses.py @@ -339,7 +339,9 @@ SEND_MESSAGE_RESPONSE = """ {{- message.body_md5 -}} + {% if message.message_attributes.items()|count > 0 %} {{- message.attribute_md5 -}} + {% endif %} {{- message.id -}} @@ -373,7 +375,9 @@ RECEIVE_MESSAGE_RESPONSE = """ ApproximateFirstReceiveTimestamp {{ message.approximate_first_receive_timestamp }} + {% if message.message_attributes.items()|count > 0 %} {{- message.attribute_md5 -}} + {% endif %} {% for name, value in message.message_attributes.items() %} {{ name }} @@ -402,7 +406,9 @@ SEND_MESSAGE_BATCH_RESPONSE = """ {{ message.user_id }} {{ message.id }} {{ message.body_md5 }} + {% if message.message_attributes.items()|count > 0 %} {{- message.attribute_md5 -}} + {% endif %} {% endfor %} diff --git a/tests/test_sqs/test_sqs.py b/tests/test_sqs/test_sqs.py index b01a55406..db351f5ab 100644 --- a/tests/test_sqs/test_sqs.py +++ b/tests/test_sqs/test_sqs.py @@ -39,9 +39,25 @@ def test_get_inexistent_queue(): sqs.get_queue_by_name.when.called_with( QueueName='nonexisting-queue').should.throw(botocore.exceptions.ClientError) +@mock_sqs +def test_message_send_without_attributes(): + sqs = boto3.resource('sqs', region_name='us-east-1') + queue = sqs.create_queue(QueueName="blah") + msg = queue.send_message( + MessageBody="derp" + ) + msg.get('MD5OfMessageBody').should.equal( + '58fd9edd83341c29f1aebba81c31e257') + msg.shouldnt.have.key('MD5OfMessageAttributes') + msg.get('ResponseMetadata', {}).get('RequestId').should.equal( + '27daac76-34dd-47df-bd01-1f6e873584a0') + msg.get('MessageId').should_not.contain(' \n') + + messages = queue.receive_messages() + messages.should.have.length_of(1) @mock_sqs -def test_message_send(): +def test_message_send_with_attributes(): sqs = boto3.resource('sqs', region_name='us-east-1') queue = sqs.create_queue(QueueName="blah") msg = queue.send_message( @@ -189,7 +205,7 @@ def test_set_queue_attribute(): @mock_sqs -def test_send_message(): +def test_send_receive_message_without_attributes(): sqs = boto3.resource('sqs', region_name='us-east-1') conn = boto3.client("sqs", region_name='us-east-1') conn.create_queue(QueueName="test-queue") @@ -198,14 +214,62 @@ def test_send_message(): body_one = 'this is a test message' body_two = 'this is another test message' - response = queue.send_message(MessageBody=body_one) - response = queue.send_message(MessageBody=body_two) + queue.send_message(MessageBody=body_one) + queue.send_message(MessageBody=body_two) messages = conn.receive_message( QueueUrl=queue.url, MaxNumberOfMessages=2)['Messages'] - messages[0]['Body'].should.equal(body_one) - messages[1]['Body'].should.equal(body_two) + message1 = messages[0] + message2 = messages[1] + + message1['Body'].should.equal(body_one) + message2['Body'].should.equal(body_two) + + message1.shouldnt.have.key('MD5OfMessageAttributes') + message2.shouldnt.have.key('MD5OfMessageAttributes') + +@mock_sqs +def test_send_receive_message_with_attributes(): + sqs = boto3.resource('sqs', region_name='us-east-1') + conn = boto3.client("sqs", region_name='us-east-1') + conn.create_queue(QueueName="test-queue") + queue = sqs.Queue("test-queue") + + body_one = 'this is a test message' + body_two = 'this is another test message' + + queue.send_message( + MessageBody=body_one, + MessageAttributes={ + 'timestamp': { + 'StringValue': '1493147359900', + 'DataType': 'Number', + } + } + ) + + queue.send_message( + MessageBody=body_two, + MessageAttributes={ + 'timestamp': { + 'StringValue': '1493147359901', + 'DataType': 'Number', + } + } + ) + + messages = conn.receive_message( + QueueUrl=queue.url, MaxNumberOfMessages=2)['Messages'] + + message1 = messages[0] + message2 = messages[1] + + message1.get('Body').should.equal(body_one) + message2.get('Body').should.equal(body_two) + + message1.get('MD5OfMessageAttributes').should.equal('235c5c510d26fb653d073faed50ae77c') + message2.get('MD5OfMessageAttributes').should.equal('994258b45346a2cc3f9cbb611aa7af30') @mock_sqs From e4f42d58807c9ca73a9d8d8ebaa71bf364827dd3 Mon Sep 17 00:00:00 2001 From: Ferran Puig Date: Mon, 3 Jul 2017 16:17:01 +0200 Subject: [PATCH 28/32] Don't use exponential notation for SQS message timestamps --- moto/sqs/models.py | 4 ++-- tests/test_sqs/test_sqs.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/moto/sqs/models.py b/moto/sqs/models.py index 43a633c42..f6657269c 100644 --- a/moto/sqs/models.py +++ b/moto/sqs/models.py @@ -96,7 +96,7 @@ class Message(BaseModel): return escape(self._body) def mark_sent(self, delay_seconds=None): - self.sent_timestamp = unix_time_millis() + self.sent_timestamp = int(unix_time_millis()) if delay_seconds: self.delay(delay_seconds=delay_seconds) @@ -111,7 +111,7 @@ class Message(BaseModel): visibility_timeout = 0 if not self.approximate_first_receive_timestamp: - self.approximate_first_receive_timestamp = unix_time_millis() + self.approximate_first_receive_timestamp = int(unix_time_millis()) self.approximate_receive_count += 1 diff --git a/tests/test_sqs/test_sqs.py b/tests/test_sqs/test_sqs.py index db351f5ab..3eb8e2213 100644 --- a/tests/test_sqs/test_sqs.py +++ b/tests/test_sqs/test_sqs.py @@ -272,6 +272,25 @@ def test_send_receive_message_with_attributes(): message2.get('MD5OfMessageAttributes').should.equal('994258b45346a2cc3f9cbb611aa7af30') +@mock_sqs +def test_send_receive_message_timestamps(): + sqs = boto3.resource('sqs', region_name='us-east-1') + conn = boto3.client("sqs", region_name='us-east-1') + conn.create_queue(QueueName="test-queue") + queue = sqs.Queue("test-queue") + + queue.send_message(MessageBody="derp") + messages = conn.receive_message( + QueueUrl=queue.url, MaxNumberOfMessages=1)['Messages'] + + message = messages[0] + sent_timestamp = message.get('Attributes').get('SentTimestamp') + approximate_first_receive_timestamp = message.get('Attributes').get('ApproximateFirstReceiveTimestamp') + + int.when.called_with(sent_timestamp).shouldnt.throw(ValueError) + int.when.called_with(approximate_first_receive_timestamp).shouldnt.throw(ValueError) + + @mock_sqs def test_receive_messages_with_wait_seconds_timeout_of_zero(): """ From c3d9f4e056013b8844f64789c9268b89a332848f Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Wed, 5 Jul 2017 16:02:45 -0700 Subject: [PATCH 29/32] Persisting selected LicenseModel in RDS instances --- moto/rds/models.py | 2 +- moto/rds2/models.py | 7 +++---- moto/rds2/responses.py | 1 + tests/test_rds2/test_rds2.py | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/moto/rds/models.py b/moto/rds/models.py index a499b134d..77deff09d 100644 --- a/moto/rds/models.py +++ b/moto/rds/models.py @@ -182,7 +182,7 @@ class Database(BaseModel): {{ database.source_db_identifier }} {% endif %} {{ database.engine }} - general-public-license + {{ database.license_model }} {{ database.engine_version }} diff --git a/moto/rds2/models.py b/moto/rds2/models.py index 86f7bae9a..5abd2ed1b 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -89,8 +89,7 @@ class Database(BaseModel): self.preferred_backup_window = kwargs.get( 'preferred_backup_window', '13:14-13:44') - self.license_model = kwargs.get( - 'license_model', 'general-public-license') + self.license_model = kwargs.get('license_model', 'general-public-license') self.option_group_name = kwargs.get('option_group_name', None) self.default_option_groups = {"MySQL": "default.mysql5.6", "mysql": "default.mysql5.6", @@ -159,7 +158,7 @@ class Database(BaseModel): {{ database.source_db_identifier }} {% endif %} {{ database.engine }} - general-public-license + {{ database.license_model }} {{ database.engine_version }} @@ -427,7 +426,7 @@ class Snapshot(BaseModel): {{ snapshot.created_at }} {{ database.master_username }} {{ database.engine_version }} - general-public-license + {{ database.license_model }} manual {% if database.iops %} {{ database.iops }} diff --git a/moto/rds2/responses.py b/moto/rds2/responses.py index b26f2e347..ef02bfbf1 100644 --- a/moto/rds2/responses.py +++ b/moto/rds2/responses.py @@ -26,6 +26,7 @@ class RDS2Response(BaseResponse): "db_subnet_group_name": self._get_param("DBSubnetGroupName"), "engine": self._get_param("Engine"), "engine_version": self._get_param("EngineVersion"), + "license_model": self._get_param("LicenseModel"), "iops": self._get_int_param("Iops"), "kms_key_id": self._get_param("KmsKeyId"), "master_user_password": self._get_param('MasterUserPassword'), diff --git a/tests/test_rds2/test_rds2.py b/tests/test_rds2/test_rds2.py index f869dc1ce..a50f99868 100644 --- a/tests/test_rds2/test_rds2.py +++ b/tests/test_rds2/test_rds2.py @@ -14,6 +14,7 @@ def test_create_database(): Engine='postgres', DBName='staging-postgres', DBInstanceClass='db.m1.small', + LicenseModel='license-included', MasterUsername='root', MasterUserPassword='hunter2', Port=1234, @@ -23,6 +24,7 @@ def test_create_database(): database['DBInstance']['DBInstanceIdentifier'].should.equal("db-master-1") database['DBInstance']['AllocatedStorage'].should.equal(10) database['DBInstance']['DBInstanceClass'].should.equal("db.m1.small") + database['DBInstance']['LicenseModel'].should.equal("license-included") database['DBInstance']['MasterUsername'].should.equal("root") database['DBInstance']['DBSecurityGroups'][0][ 'DBSecurityGroupName'].should.equal('my_sg') From dbbbc01f886fd1c9d264687c25f36e73feab3966 Mon Sep 17 00:00:00 2001 From: Jack Danger Canty Date: Thu, 6 Jul 2017 21:29:18 -0700 Subject: [PATCH 30/32] Test boto3 elb listener deletion --- tests/test_elb/test_elb.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_elb/test_elb.py b/tests/test_elb/test_elb.py index 36f96c0e2..78c6e0ad0 100644 --- a/tests/test_elb/test_elb.py +++ b/tests/test_elb/test_elb.py @@ -214,6 +214,13 @@ def test_create_and_delete_listener_boto3_support(): balancer['ListenerDescriptions'][1]['Listener'][ 'InstancePort'].should.equal(8443) + client.delete_load_balancer_listeners( + LoadBalancerName='my-lb', + LoadBalancerPorts=[443]) + + balancer = client.describe_load_balancers()['LoadBalancerDescriptions'][0] + list(balancer['ListenerDescriptions']).should.have.length_of(1) + @mock_elb_deprecated def test_set_sslcertificate(): From 98342bfcc3c66d63c4e691c492e19840149cfdbb Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Thu, 6 Jul 2017 21:52:01 -0700 Subject: [PATCH 31/32] Raise error on duplicate elbv1 listener AWS returns an error condition when a listener is defined that interferes with an existing listener on the same load balancer port. --- moto/elb/exceptions.py | 9 +++++++++ moto/elb/models.py | 7 +++++++ tests/test_elb/test_elb.py | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/moto/elb/exceptions.py b/moto/elb/exceptions.py index 071181a6c..6c316ef47 100644 --- a/moto/elb/exceptions.py +++ b/moto/elb/exceptions.py @@ -40,6 +40,15 @@ class BadHealthCheckDefinition(ELBClientError): "HealthCheck Target must begin with one of HTTP, TCP, HTTPS, SSL") +class DuplicateListenerError(ELBClientError): + + def __init__(self, name, port): + super(DuplicateListenerError, self).__init__( + "DuplicateListener", + "A listener already exists for {0} with LoadBalancerPort {1}, but with a different InstancePort, Protocol, or SSLCertificateId" + .format(name, port)) + + class DuplicateLoadBalancerName(ELBClientError): def __init__(self, name): diff --git a/moto/elb/models.py b/moto/elb/models.py index 5b6a58bb9..d09548340 100644 --- a/moto/elb/models.py +++ b/moto/elb/models.py @@ -18,6 +18,7 @@ from moto.ec2.models import ec2_backends from .exceptions import ( BadHealthCheckDefinition, DuplicateLoadBalancerName, + DuplicateListenerError, EmptyListenersError, LoadBalancerNotFoundError, TooManyTagsError, @@ -257,6 +258,12 @@ class ELBBackend(BaseBackend): ssl_certificate_id = port.get('sslcertificate_id') for listener in balancer.listeners: if lb_port == listener.load_balancer_port: + if protocol != listener.protocol: + raise DuplicateListenerError(name, lb_port) + if instance_port != listener.instance_port: + raise DuplicateListenerError(name, lb_port) + if ssl_certificate_id != listener.ssl_certificate_id: + raise DuplicateListenerError(name, lb_port) break else: balancer.listeners.append(FakeListener( diff --git a/tests/test_elb/test_elb.py b/tests/test_elb/test_elb.py index 78c6e0ad0..f9019eed2 100644 --- a/tests/test_elb/test_elb.py +++ b/tests/test_elb/test_elb.py @@ -214,6 +214,14 @@ def test_create_and_delete_listener_boto3_support(): balancer['ListenerDescriptions'][1]['Listener'][ 'InstancePort'].should.equal(8443) + # Creating this listener with an conflicting definition throws error + with assert_raises(ClientError): + client.create_load_balancer_listeners( + LoadBalancerName='my-lb', + Listeners=[ + {'Protocol': 'tcp', 'LoadBalancerPort': 443, 'InstancePort': 1234}] + ) + client.delete_load_balancer_listeners( LoadBalancerName='my-lb', LoadBalancerPorts=[443]) From 2a65f40a194854aef6337357cc29ba5d55b4c2ef Mon Sep 17 00:00:00 2001 From: fdfk Date: Tue, 11 Jul 2017 08:02:31 +0000 Subject: [PATCH 32/32] Adding list_verified_email_addresses and testing --- moto/ses/models.py | 7 +++++++ moto/ses/responses.py | 31 +++++++++++++++++++++++++++++++ tests/test_ses/test_ses_boto3.py | 7 +++++++ 3 files changed, 45 insertions(+) diff --git a/moto/ses/models.py b/moto/ses/models.py index 2f51d1473..179f4d8e0 100644 --- a/moto/ses/models.py +++ b/moto/ses/models.py @@ -36,6 +36,7 @@ class SESBackend(BaseBackend): def __init__(self): self.addresses = [] + self.email_addresses = [] self.domains = [] self.sent_messages = [] self.sent_message_count = 0 @@ -49,12 +50,18 @@ class SESBackend(BaseBackend): def verify_email_identity(self, address): self.addresses.append(address) + def verify_email_address(self, address): + self.email_addresses.append(address) + def verify_domain(self, domain): self.domains.append(domain) def list_identities(self): return self.domains + self.addresses + def list_verified_email_addresses(self): + return self.email_addresses + def delete_identity(self, identity): if '@' in identity: self.addresses.remove(identity) diff --git a/moto/ses/responses.py b/moto/ses/responses.py index d7bfe0787..6cd018aa6 100644 --- a/moto/ses/responses.py +++ b/moto/ses/responses.py @@ -15,11 +15,22 @@ class EmailResponse(BaseResponse): template = self.response_template(VERIFY_EMAIL_IDENTITY) return template.render() + def verify_email_address(self): + address = self.querystring.get('EmailAddress')[0] + ses_backend.verify_email_address(address) + template = self.response_template(VERIFY_EMAIL_ADDRESS) + return template.render() + def list_identities(self): identities = ses_backend.list_identities() template = self.response_template(LIST_IDENTITIES_RESPONSE) return template.render(identities=identities) + def list_verified_email_addresses(self): + email_addresses = ses_backend.list_verified_email_addresses() + template = self.response_template(LIST_VERIFIED_EMAIL_RESPONSE) + return template.render(email_addresses=email_addresses) + def verify_domain_dkim(self): domain = self.querystring.get('Domain')[0] ses_backend.verify_domain(domain) @@ -95,6 +106,13 @@ VERIFY_EMAIL_IDENTITY = """ + + + 47e0ef1a-9bf2-11e1-9279-0100e8cf109a + +""" + LIST_IDENTITIES_RESPONSE = """ @@ -108,6 +126,19 @@ LIST_IDENTITIES_RESPONSE = """ + + + {% for email in email_addresses %} + {{ email }} + {% endfor %} + + + + cacecf23-9bf1-11e1-9279-0100e8cf109a + +""" + VERIFY_DOMAIN_DKIM_RESPONSE = """ diff --git a/tests/test_ses/test_ses_boto3.py b/tests/test_ses/test_ses_boto3.py index 224ebb626..5d39f61d4 100644 --- a/tests/test_ses/test_ses_boto3.py +++ b/tests/test_ses/test_ses_boto3.py @@ -19,6 +19,13 @@ def test_verify_email_identity(): address = identities['Identities'][0] address.should.equal('test@example.com') +@mock_ses +def test_verify_email_address(): + conn = boto3.client('ses', region_name='us-east-1') + conn.verify_email_address(EmailAddress="test@example.com") + email_addresses = conn.list_verified_email_addresses() + email = email_addresses['VerifiedEmailAddresses'][0] + email.should.equal('test@example.com') @mock_ses def test_domain_verify():