From febec7536485656e4989dd1ad1f2fb99498c1d35 Mon Sep 17 00:00:00 2001 From: gruebel Date: Sun, 17 Nov 2019 14:52:57 +0100 Subject: [PATCH 1/6] Add organizations.tag_resource --- IMPLEMENTATION_COVERAGE.md | 2 +- moto/organizations/models.py | 13 ++++++++ moto/organizations/responses.py | 5 ++++ .../test_organizations_boto3.py | 30 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index d8e02f596..d6a4d8174 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4766,7 +4766,7 @@ - [X] list_targets_for_policy - [X] move_account - [ ] remove_account_from_organization -- [ ] tag_resource +- [x] tag_resource - [ ] untag_resource - [ ] update_organizational_unit - [ ] update_policy diff --git a/moto/organizations/models.py b/moto/organizations/models.py index d558616d2..2fb0a6656 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -57,6 +57,7 @@ class FakeAccount(BaseModel): self.joined_method = "CREATED" self.parent_id = organization.root_id self.attached_policies = [] + self.tags = {} @property def arn(self): @@ -442,5 +443,17 @@ class OrganizationsBackend(BaseBackend): ] return dict(Targets=objects) + def tag_resource(self, **kwargs): + account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) + + if account is None: + raise RESTError( + "InvalidInputException", + "You provided a value that does not match the required pattern.", + ) + + new_tags = {tag["Key"]: tag["Value"] for tag in kwargs["Tags"]} + account.tags.update(new_tags) + organizations_backend = OrganizationsBackend() diff --git a/moto/organizations/responses.py b/moto/organizations/responses.py index f9e0b2e04..11093bae1 100644 --- a/moto/organizations/responses.py +++ b/moto/organizations/responses.py @@ -119,3 +119,8 @@ class OrganizationsResponse(BaseResponse): return json.dumps( self.organizations_backend.list_targets_for_policy(**self.request_params) ) + + def tag_resource(self): + return json.dumps( + self.organizations_backend.tag_resource(**self.request_params) + ) diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py index 3b4a51557..95b115548 100644 --- a/tests/test_organizations/test_organizations_boto3.py +++ b/tests/test_organizations/test_organizations_boto3.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import boto3 import json import six +import sure # noqa from botocore.exceptions import ClientError from nose.tools import assert_raises @@ -605,3 +606,32 @@ def test_list_targets_for_policy_exception(): ex.operation_name.should.equal("ListTargetsForPolicy") ex.response["Error"]["Code"].should.equal("400") ex.response["Error"]["Message"].should.contain("InvalidInputException") + + +@mock_organizations +def test_tag_resource(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + account_id = client.create_account(AccountName=mockname, Email=mockemail)[ + "CreateAccountStatus" + ]["AccountId"] + + client.tag_resource(ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]) + + +@mock_organizations +def test_tag_resource_errors(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + + with assert_raises(ClientError) as e: + client.tag_resource( + ResourceId="000000000000", Tags=[{"Key": "key", "Value": "value"},] + ) + ex = e.exception + ex.operation_name.should.equal("TagResource") + ex.response["Error"]["Code"].should.equal("400") + ex.response["Error"]["Message"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.contain( + "You provided a value that does not match the required pattern." + ) From c10afa3ab506d82cd56e2723b78c829ff416888f Mon Sep 17 00:00:00 2001 From: gruebel Date: Sun, 17 Nov 2019 15:10:38 +0100 Subject: [PATCH 2/6] Add organizations.list_tags_for_resource --- IMPLEMENTATION_COVERAGE.md | 2 +- moto/organizations/models.py | 12 ++++++ moto/organizations/responses.py | 5 +++ .../test_organizations_boto3.py | 41 +++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index d6a4d8174..213775d83 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4762,7 +4762,7 @@ - [X] list_policies - [X] list_policies_for_target - [X] list_roots -- [ ] list_tags_for_resource +- [x] list_tags_for_resource - [X] list_targets_for_policy - [X] move_account - [ ] remove_account_from_organization diff --git a/moto/organizations/models.py b/moto/organizations/models.py index 2fb0a6656..b5059fe8d 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -455,5 +455,17 @@ class OrganizationsBackend(BaseBackend): new_tags = {tag["Key"]: tag["Value"] for tag in kwargs["Tags"]} account.tags.update(new_tags) + def list_tags_for_resource(self, **kwargs): + account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) + + if account is None: + raise RESTError( + "InvalidInputException", + "You provided a value that does not match the required pattern.", + ) + + tags = [{"Key": key, "Value": value} for key, value in account.tags.items()] + return dict(Tags=tags) + organizations_backend = OrganizationsBackend() diff --git a/moto/organizations/responses.py b/moto/organizations/responses.py index 11093bae1..ab01ffb8a 100644 --- a/moto/organizations/responses.py +++ b/moto/organizations/responses.py @@ -124,3 +124,8 @@ class OrganizationsResponse(BaseResponse): return json.dumps( self.organizations_backend.tag_resource(**self.request_params) ) + + def list_tags_for_resource(self): + return json.dumps( + self.organizations_backend.list_tags_for_resource(**self.request_params) + ) diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py index 95b115548..27989c276 100644 --- a/tests/test_organizations/test_organizations_boto3.py +++ b/tests/test_organizations/test_organizations_boto3.py @@ -618,6 +618,17 @@ def test_tag_resource(): client.tag_resource(ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]) + response = client.list_tags_for_resource(ResourceId=account_id) + response["Tags"].should.equal([{"Key": "key", "Value": "value"}]) + + # adding a tag with an existing key, will update the value + client.tag_resource( + ResourceId=account_id, Tags=[{"Key": "key", "Value": "new-value"}] + ) + + response = client.list_tags_for_resource(ResourceId=account_id) + response["Tags"].should.equal([{"Key": "key", "Value": "new-value"}]) + @mock_organizations def test_tag_resource_errors(): @@ -635,3 +646,33 @@ def test_tag_resource_errors(): ex.response["Error"]["Message"].should.contain( "You provided a value that does not match the required pattern." ) + + +@mock_organizations +def test_list_tags_for_resource(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + account_id = client.create_account(AccountName=mockname, Email=mockemail)[ + "CreateAccountStatus" + ]["AccountId"] + client.tag_resource(ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]) + + response = client.list_tags_for_resource(ResourceId=account_id) + + response["Tags"].should.equal([{"Key": "key", "Value": "value"}]) + + +@mock_organizations +def test_list_tags_for_resource_errors(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + + with assert_raises(ClientError) as e: + client.list_tags_for_resource(ResourceId="000000000000") + ex = e.exception + ex.operation_name.should.equal("ListTagsForResource") + ex.response["Error"]["Code"].should.equal("400") + ex.response["Error"]["Message"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.contain( + "You provided a value that does not match the required pattern." + ) From d0ef72725c854d5f8251c4ab0b4a7de744e6ee9b Mon Sep 17 00:00:00 2001 From: gruebel Date: Sun, 17 Nov 2019 15:28:38 +0100 Subject: [PATCH 3/6] Add organizations.untag_resource --- IMPLEMENTATION_COVERAGE.md | 2 +- moto/organizations/models.py | 12 ++++++ moto/organizations/responses.py | 5 +++ .../test_organizations_boto3.py | 37 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 213775d83..f8ccf66d4 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4767,7 +4767,7 @@ - [X] move_account - [ ] remove_account_from_organization - [x] tag_resource -- [ ] untag_resource +- [x] untag_resource - [ ] update_organizational_unit - [ ] update_policy diff --git a/moto/organizations/models.py b/moto/organizations/models.py index b5059fe8d..2717d7ef8 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -467,5 +467,17 @@ class OrganizationsBackend(BaseBackend): tags = [{"Key": key, "Value": value} for key, value in account.tags.items()] return dict(Tags=tags) + def untag_resource(self, **kwargs): + account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) + + if account is None: + raise RESTError( + "InvalidInputException", + "You provided a value that does not match the required pattern.", + ) + + for key in kwargs["TagKeys"]: + account.tags.pop(key, None) + organizations_backend = OrganizationsBackend() diff --git a/moto/organizations/responses.py b/moto/organizations/responses.py index ab01ffb8a..7c42eb4ec 100644 --- a/moto/organizations/responses.py +++ b/moto/organizations/responses.py @@ -129,3 +129,8 @@ class OrganizationsResponse(BaseResponse): return json.dumps( self.organizations_backend.list_tags_for_resource(**self.request_params) ) + + def untag_resource(self): + return json.dumps( + self.organizations_backend.untag_resource(**self.request_params) + ) diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py index 27989c276..fb3ab3b24 100644 --- a/tests/test_organizations/test_organizations_boto3.py +++ b/tests/test_organizations/test_organizations_boto3.py @@ -676,3 +676,40 @@ def test_list_tags_for_resource_errors(): ex.response["Error"]["Message"].should.contain( "You provided a value that does not match the required pattern." ) + + +@mock_organizations +def test_untag_resource(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + account_id = client.create_account(AccountName=mockname, Email=mockemail)[ + "CreateAccountStatus" + ]["AccountId"] + client.tag_resource(ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]) + response = client.list_tags_for_resource(ResourceId=account_id) + response["Tags"].should.equal([{"Key": "key", "Value": "value"}]) + + # removing a non existing tag should not raise any error + client.untag_resource(ResourceId=account_id, TagKeys=["not-existing"]) + response = client.list_tags_for_resource(ResourceId=account_id) + response["Tags"].should.equal([{"Key": "key", "Value": "value"}]) + + client.untag_resource(ResourceId=account_id, TagKeys=["key"]) + response = client.list_tags_for_resource(ResourceId=account_id) + response["Tags"].should.have.length_of(0) + + +@mock_organizations +def test_untag_resource_errors(): + client = boto3.client("organizations", region_name="us-east-1") + client.create_organization(FeatureSet="ALL") + + with assert_raises(ClientError) as e: + client.untag_resource(ResourceId="000000000000", TagKeys=["key"]) + ex = e.exception + ex.operation_name.should.equal("UntagResource") + ex.response["Error"]["Code"].should.equal("400") + ex.response["Error"]["Message"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.contain( + "You provided a value that does not match the required pattern." + ) From 158db1f5d635a3504003e3a444fcbd31a4054aa2 Mon Sep 17 00:00:00 2001 From: gruebel Date: Thu, 21 Nov 2019 22:03:25 +0100 Subject: [PATCH 4/6] Move exception to dedicated class --- moto/organizations/exceptions.py | 15 +++++++++++++++ moto/organizations/models.py | 16 ++++------------ .../test_organizations_boto3.py | 18 +++++++++--------- 3 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 moto/organizations/exceptions.py diff --git a/moto/organizations/exceptions.py b/moto/organizations/exceptions.py new file mode 100644 index 000000000..834165bcb --- /dev/null +++ b/moto/organizations/exceptions.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + +import json +from werkzeug.exceptions import BadRequest + + +class InvalidInputError(BadRequest): + def __init__(self): + super(InvalidInputError, self).__init__() + self.description = json.dumps( + { + "message": "You provided a value that does not match the required pattern.", + "__type": "InvalidInputException", + } + ) diff --git a/moto/organizations/models.py b/moto/organizations/models.py index 2717d7ef8..7a9c73e9e 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -8,6 +8,7 @@ from moto.core import BaseBackend, BaseModel from moto.core.exceptions import RESTError from moto.core.utils import unix_time from moto.organizations import utils +from moto.organizations.exceptions import InvalidInputError class FakeOrganization(BaseModel): @@ -447,10 +448,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise RESTError( - "InvalidInputException", - "You provided a value that does not match the required pattern.", - ) + raise InvalidInputError new_tags = {tag["Key"]: tag["Value"] for tag in kwargs["Tags"]} account.tags.update(new_tags) @@ -459,10 +457,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise RESTError( - "InvalidInputException", - "You provided a value that does not match the required pattern.", - ) + raise InvalidInputError tags = [{"Key": key, "Value": value} for key, value in account.tags.items()] return dict(Tags=tags) @@ -471,10 +466,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise RESTError( - "InvalidInputException", - "You provided a value that does not match the required pattern.", - ) + raise InvalidInputError for key in kwargs["TagKeys"]: account.tags.pop(key, None) diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py index fb3ab3b24..dd79ae787 100644 --- a/tests/test_organizations/test_organizations_boto3.py +++ b/tests/test_organizations/test_organizations_boto3.py @@ -641,9 +641,9 @@ def test_tag_resource_errors(): ) ex = e.exception ex.operation_name.should.equal("TagResource") - ex.response["Error"]["Code"].should.equal("400") - ex.response["Error"]["Message"].should.contain("InvalidInputException") - ex.response["Error"]["Message"].should.contain( + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.equal( "You provided a value that does not match the required pattern." ) @@ -671,9 +671,9 @@ def test_list_tags_for_resource_errors(): client.list_tags_for_resource(ResourceId="000000000000") ex = e.exception ex.operation_name.should.equal("ListTagsForResource") - ex.response["Error"]["Code"].should.equal("400") - ex.response["Error"]["Message"].should.contain("InvalidInputException") - ex.response["Error"]["Message"].should.contain( + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.equal( "You provided a value that does not match the required pattern." ) @@ -708,8 +708,8 @@ def test_untag_resource_errors(): client.untag_resource(ResourceId="000000000000", TagKeys=["key"]) ex = e.exception ex.operation_name.should.equal("UntagResource") - ex.response["Error"]["Code"].should.equal("400") - ex.response["Error"]["Message"].should.contain("InvalidInputException") - ex.response["Error"]["Message"].should.contain( + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("InvalidInputException") + ex.response["Error"]["Message"].should.equal( "You provided a value that does not match the required pattern." ) From cd633f8bc5a9a96ca1462904fc3a0bcebfcd1433 Mon Sep 17 00:00:00 2001 From: gruebel Date: Thu, 21 Nov 2019 22:34:05 +0100 Subject: [PATCH 5/6] Change to JsonRESTError --- moto/organizations/exceptions.py | 17 +++++++---------- moto/organizations/models.py | 8 ++++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/moto/organizations/exceptions.py b/moto/organizations/exceptions.py index 834165bcb..01b98da7e 100644 --- a/moto/organizations/exceptions.py +++ b/moto/organizations/exceptions.py @@ -1,15 +1,12 @@ from __future__ import unicode_literals - -import json -from werkzeug.exceptions import BadRequest +from moto.core.exceptions import JsonRESTError -class InvalidInputError(BadRequest): +class InvalidInputException(JsonRESTError): + code = 400 + def __init__(self): - super(InvalidInputError, self).__init__() - self.description = json.dumps( - { - "message": "You provided a value that does not match the required pattern.", - "__type": "InvalidInputException", - } + super(InvalidInputException, self).__init__( + "InvalidInputException", + "You provided a value that does not match the required pattern.", ) diff --git a/moto/organizations/models.py b/moto/organizations/models.py index 7a9c73e9e..42e4dd00a 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -8,7 +8,7 @@ from moto.core import BaseBackend, BaseModel from moto.core.exceptions import RESTError from moto.core.utils import unix_time from moto.organizations import utils -from moto.organizations.exceptions import InvalidInputError +from moto.organizations.exceptions import InvalidInputException class FakeOrganization(BaseModel): @@ -448,7 +448,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise InvalidInputError + raise InvalidInputException new_tags = {tag["Key"]: tag["Value"] for tag in kwargs["Tags"]} account.tags.update(new_tags) @@ -457,7 +457,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise InvalidInputError + raise InvalidInputException tags = [{"Key": key, "Value": value} for key, value in account.tags.items()] return dict(Tags=tags) @@ -466,7 +466,7 @@ class OrganizationsBackend(BaseBackend): account = next((a for a in self.accounts if a.id == kwargs["ResourceId"]), None) if account is None: - raise InvalidInputError + raise InvalidInputException for key in kwargs["TagKeys"]: account.tags.pop(key, None) From ef7fce5a4fcd951a6c2bd1b9c6d21e6cf6a711e2 Mon Sep 17 00:00:00 2001 From: gruebel Date: Thu, 21 Nov 2019 22:35:20 +0100 Subject: [PATCH 6/6] Fixed failing tests, due to a new required parameter StreamEnabled --- tests/test_dynamodbstreams/test_dynamodbstreams.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_dynamodbstreams/test_dynamodbstreams.py b/tests/test_dynamodbstreams/test_dynamodbstreams.py index 01cf915af..a98f97bf3 100644 --- a/tests/test_dynamodbstreams/test_dynamodbstreams.py +++ b/tests/test_dynamodbstreams/test_dynamodbstreams.py @@ -213,7 +213,7 @@ class TestEdges: resp = conn.update_table( TableName="test-streams", - StreamSpecification={"StreamViewType": "KEYS_ONLY"}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "KEYS_ONLY"}, ) assert "StreamSpecification" in resp["TableDescription"] assert resp["TableDescription"]["StreamSpecification"] == { @@ -226,7 +226,10 @@ class TestEdges: with assert_raises(conn.exceptions.ResourceInUseException): resp = conn.update_table( TableName="test-streams", - StreamSpecification={"StreamViewType": "OLD_IMAGES"}, + StreamSpecification={ + "StreamEnabled": True, + "StreamViewType": "OLD_IMAGES", + }, ) def test_stream_with_range_key(self): @@ -243,7 +246,7 @@ class TestEdges: {"AttributeName": "color", "AttributeType": "S"}, ], ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}, - StreamSpecification={"StreamViewType": "NEW_IMAGES"}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGES"}, ) stream_arn = resp["TableDescription"]["LatestStreamArn"]