diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py index bb5284f80..aee43e22a 100644 --- a/moto/cognitoidp/models.py +++ b/moto/cognitoidp/models.py @@ -39,6 +39,280 @@ class UserStatus(str, enum.Enum): RESET_REQUIRED = "RESET_REQUIRED" +class CognitoIdpUserPoolAttribute(BaseModel): + + STANDARD_SCHEMA = { + "sub": { + "AttributeDataType": "String", + "Mutable": False, + "Required": True, + "StringAttributeConstraints": {"MinLength": "1", "MaxLength": "2048"}, + }, + "name": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "given_name": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "family_name": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "middle_name": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "nickname": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "preferred_username": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "profile": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "picture": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "website": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "email": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "email_verified": { + "AttributeDataType": "Boolean", + "Mutable": True, + "Required": False, + }, + "gender": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "birthdate": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "10", "MaxLength": "10"}, + }, + "zoneinfo": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "locale": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "phone_number": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "phone_number_verified": { + "AttributeDataType": "Boolean", + "Mutable": True, + "Required": False, + }, + "address": { + "AttributeDataType": "String", + "Mutable": True, + "Required": False, + "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"}, + }, + "updated_at": { + "AttributeDataType": "Number", + "Mutable": True, + "Required": False, + "NumberAttributeConstraints": {"MinValue": "0"}, + }, + } + + ATTRIBUTE_DATE_TYPES = {"Boolean", "DateTime", "String", "Number"} + + def __init__(self, name, schema): + self.name = name + self.custom = self.name not in CognitoIdpUserPoolAttribute.STANDARD_SCHEMA + attribute_data_type = schema.get("AttributeDataType", None) + if ( + attribute_data_type + and attribute_data_type + not in CognitoIdpUserPoolAttribute.ATTRIBUTE_DATE_TYPES + ): + raise InvalidParameterException( + f"Validation error detected: Value '{attribute_data_type}' failed to satisfy constraint: Member must satisfy enum value set: [Boolean, Number, String, DateTime]" + ) + + if self.custom: + self._init_custom(schema) + else: + self._init_standard(schema) + + def _init_custom(self, schema): + self.name = "custom:" + self.name + attribute_data_type = schema.get("AttributeDataType", None) + if not attribute_data_type: + raise InvalidParameterException( + "Invalid AttributeDataType input, consider using the provided AttributeDataType enum." + ) + self.data_type = attribute_data_type + self.developer_only = schema.get("DeveloperOnlyAttribute", False) + if self.developer_only: + self.name = "dev:" + self.name + self.mutable = schema.get("Mutable", True) + if schema.get("Required", False): + raise InvalidParameterException( + "Required custom attributes are not supported currently." + ) + self.required = False + self._init_constraints(schema, None) + + def _init_standard(self, schema): + attribute_data_type = schema.get("AttributeDataType", None) + default_attribute_data_type = CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[ + self.name + ]["AttributeDataType"] + if attribute_data_type and attribute_data_type != default_attribute_data_type: + raise InvalidParameterException( + f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {self.name}" + ) + self.data_type = default_attribute_data_type + if schema.get("DeveloperOnlyAttribute", False): + raise InvalidParameterException( + f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {self.name}" + ) + else: + self.developer_only = False + self.mutable = schema.get( + "Mutable", + CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name]["Mutable"], + ) + self.required = schema.get( + "Required", + CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name]["Required"], + ) + constraints_key = None + if self.data_type == "Number": + constraints_key = "NumberAttributeConstraints" + elif self.data_type == "String": + constraints_key = "StringAttributeConstraints" + default_constraints = ( + None + if not constraints_key + else CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name][constraints_key] + ) + self._init_constraints(schema, default_constraints) + + def _init_constraints(self, schema, default_constraints): + def numeric_limit(num, constraint_type): + if not num: + return + parsed = None + try: + parsed = int(num) + except ValueError: + pass + if parsed is None or parsed < 0: + raise InvalidParameterException( + f"Invalid {constraint_type} for schema attribute {self.name}" + ) + return parsed + + self.string_constraints = None + self.number_constraints = None + + if "AttributeDataType" in schema: + # Quirk - schema is set/validated only if AttributeDataType is specified + if self.data_type == "String": + string_constraints = schema.get( + "StringAttributeConstraints", default_constraints + ) + if not string_constraints: + return + min_len = numeric_limit( + string_constraints.get("MinLength", None), + "StringAttributeConstraints", + ) + max_len = numeric_limit( + string_constraints.get("MaxLength", None), + "StringAttributeConstraints", + ) + if (min_len and min_len > 2048) or (max_len and max_len > 2048): + raise InvalidParameterException( + f"user.{self.name}: String attributes cannot have a length of more than 2048" + ) + if min_len and max_len and min_len > max_len: + raise InvalidParameterException( + f"user.{self.name}: Max length cannot be less than min length." + ) + self.string_constraints = string_constraints + elif self.data_type == "Number": + number_constraints = schema.get( + "NumberAttributeConstraints", default_constraints + ) + if not number_constraints: + return + # No limits on either min or max value + min_val = numeric_limit( + number_constraints.get("MinValue", None), + "NumberAttributeConstraints", + ) + max_val = numeric_limit( + number_constraints.get("MaxValue", None), + "NumberAttributeConstraints", + ) + if min_val and max_val and min_val > max_val: + raise InvalidParameterException( + f"user.{self.name}: Max value cannot be less than min value." + ) + self.number_constraints = number_constraints + + def to_json(self): + return { + "Name": self.name, + "AttributeDataType": self.data_type, + "DeveloperOnlyAttribute": self.developer_only, + "Mutable": self.mutable, + "Required": self.required, + "NumberAttributeConstraints": self.number_constraints, + "StringAttributeConstraints": self.string_constraints, + } + + class CognitoIdpUserPool(BaseModel): def __init__(self, region, name, extended_config): self.region = region @@ -56,6 +330,21 @@ class CognitoIdpUserPool(BaseModel): self.sms_mfa_config = None self.token_mfa_config = None + self.schema_attributes = { + schema["Name"]: CognitoIdpUserPoolAttribute(schema["Name"], schema) + for schema in extended_config.pop("Schema", {}) + } + for ( + standard_attribute_name, + standard_attribute_schema, + ) in CognitoIdpUserPoolAttribute.STANDARD_SCHEMA.items(): + if standard_attribute_name not in self.schema_attributes: + self.schema_attributes[ + standard_attribute_name + ] = CognitoIdpUserPoolAttribute( + standard_attribute_name, standard_attribute_schema + ) + self.clients = OrderedDict() self.identity_providers = OrderedDict() self.groups = OrderedDict() @@ -99,6 +388,13 @@ class CognitoIdpUserPool(BaseModel): user_pool_json = self._base_json() if extended: user_pool_json.update(self.extended_config) + user_pool_json.update( + { + "SchemaAttributes": [ + att.to_json() for att in self.schema_attributes.values() + ] + } + ) else: user_pool_json["LambdaConfig"] = ( self.extended_config.get("LambdaConfig") or {} diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py index 65d145e5d..601a91494 100644 --- a/tests/test_cognitoidp/test_cognitoidp.py +++ b/tests/test_cognitoidp/test_cognitoidp.py @@ -43,6 +43,404 @@ def test_create_user_pool(): result["UserPool"]["LambdaConfig"]["PreSignUp"].should.equal(value) +@mock_cognitoidp +def test_create_user_pool_should_have_all_default_attributes_in_schema(): + conn = boto3.client("cognito-idp", "us-west-2") + + name = str(uuid.uuid4()) + result = conn.create_user_pool(PoolName=name) + + result_schema = result["UserPool"]["SchemaAttributes"] + result_schema = {s["Name"]: s for s in result_schema} + + described_schema = conn.describe_user_pool(UserPoolId=result["UserPool"]["Id"])[ + "UserPool" + ]["SchemaAttributes"] + described_schema = {s["Name"]: s for s in described_schema} + + for schema in result_schema, described_schema: + for ( + default_attr_name, + default_attr, + ) in moto.cognitoidp.models.CognitoIdpUserPoolAttribute.STANDARD_SCHEMA.items(): + attribute = schema[default_attr_name] + attribute["Required"].should.equal(default_attr["Required"]) + attribute["AttributeDataType"].should.equal( + default_attr["AttributeDataType"] + ) + attribute["Mutable"].should.equal(default_attr["Mutable"]) + attribute.get("StringAttributeConstraints", None).should.equal( + default_attr.get("StringAttributeConstraints", None) + ) + attribute.get("NumberAttributeConstraints", None).should.equal( + default_attr.get("NumberAttributeConstraints", None) + ) + attribute["DeveloperOnlyAttribute"].should.be.false + + +@mock_cognitoidp +def test_create_user_pool_unknown_attribute_data_type(): + conn = boto3.client("cognito-idp", "us-west-2") + + name = str(uuid.uuid4()) + + attribute_data_type = "Banana" + with pytest.raises(ClientError) as ex: + conn.create_user_pool( + PoolName=name, + Schema=[{"Name": "custom", "AttributeDataType": attribute_data_type,},], + ) + + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"Validation error detected: Value '{attribute_data_type}' failed to satisfy constraint: Member must satisfy enum value set: [Boolean, Number, String, DateTime]" + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +def test_create_user_pool_custom_attribute_without_data_type(): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[{"Name": "custom",},]) + + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + "Invalid AttributeDataType input, consider using the provided AttributeDataType enum." + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +def test_create_user_pool_custom_attribute_defaults(): + conn = boto3.client("cognito-idp", "us-west-2") + res = conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + {"Name": "string", "AttributeDataType": "String",}, + {"Name": "number", "AttributeDataType": "Number",}, + ], + ) + string_attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:string" + ) + string_attribute["DeveloperOnlyAttribute"].should.be.false + string_attribute["Mutable"].should.be.true + string_attribute.get("StringAttributeConstraints").should.be.none + + number_attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:number" + ) + number_attribute["DeveloperOnlyAttribute"].should.be.false + number_attribute["Mutable"].should.be.true + number_attribute.get("NumberAttributeConstraints").should.be.none + + +@mock_cognitoidp +def test_create_user_pool_custom_attribute_developer_only(): + conn = boto3.client("cognito-idp", "us-west-2") + res = conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + { + "Name": "banana", + "AttributeDataType": "String", + "DeveloperOnlyAttribute": True, + }, + ], + ) + # Note that this time we are looking for 'dev:xyz' attribute + attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "dev:custom:banana" + ) + attribute["DeveloperOnlyAttribute"].should.be.true + + +@mock_cognitoidp +def test_create_user_pool_custom_attribute_required(): + conn = boto3.client("cognito-idp", "us-west-2") + + with pytest.raises(ClientError) as ex: + conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + {"Name": "banana", "AttributeDataType": "String", "Required": True}, + ], + ) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + "Required custom attributes are not supported currently." + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +@pytest.mark.parametrize( + "attribute", + [ + {"Name": "email", "AttributeDataType": "Number"}, + {"Name": "email", "DeveloperOnlyAttribute": True}, + ], + ids=["standard_attribute", "developer_only"], +) +def test_create_user_pool_standard_attribute_with_changed_data_type_or_developer_only( + attribute, +): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute]) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {attribute['Name']}" + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +def test_create_user_pool_attribute_with_schema(): + conn = boto3.client("cognito-idp", "us-west-2") + res = conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + { + "Name": "string", + "AttributeDataType": "String", + "NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"}, + "StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"}, + }, + { + "Name": "number", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"}, + "StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"}, + }, + { + "Name": "boolean", + "AttributeDataType": "Boolean", + "NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"}, + "StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"}, + }, + ], + ) + string_attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:string" + ) + string_attribute["StringAttributeConstraints"].should.equal( + {"MinLength": "10", "MaxLength": "20"} + ) + string_attribute.get("NumberAttributeConstraints").should.be.none + + number_attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:number" + ) + number_attribute["NumberAttributeConstraints"].should.equal( + {"MinValue": "10", "MaxValue": "20"} + ) + number_attribute.get("StringAttributeConstraints").should.be.none + + boolean_attribute = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:boolean" + ) + boolean_attribute.get("NumberAttributeConstraints").should.be.none + boolean_attribute.get("StringAttributeConstraints").should.be.none + + +@mock_cognitoidp +def test_create_user_pool_attribute_partial_schema(): + conn = boto3.client("cognito-idp", "us-west-2") + res = conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + { + "Name": "string_no_min", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MaxLength": "10"}, + }, + { + "Name": "string_no_max", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MinLength": "10"}, + }, + { + "Name": "number_no_min", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MaxValue": "10"}, + }, + { + "Name": "number_no_max", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MinValue": "10"}, + }, + ], + ) + string_no_min = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:string_no_min" + ) + string_no_max = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:string_no_max" + ) + number_no_min = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:number_no_min" + ) + number_no_max = next( + attr + for attr in res["UserPool"]["SchemaAttributes"] + if attr["Name"] == "custom:number_no_max" + ) + + string_no_min["StringAttributeConstraints"]["MaxLength"].should.equal("10") + string_no_min["StringAttributeConstraints"].get("MinLength", None).should.be.none + string_no_max["StringAttributeConstraints"]["MinLength"].should.equal("10") + string_no_max["StringAttributeConstraints"].get("MaxLength", None).should.be.none + number_no_min["NumberAttributeConstraints"]["MaxValue"].should.equal("10") + number_no_min["NumberAttributeConstraints"].get("MinValue", None).should.be.none + number_no_max["NumberAttributeConstraints"]["MinValue"].should.equal("10") + number_no_max["NumberAttributeConstraints"].get("MaxValue", None).should.be.none + + +@mock_cognitoidp +@pytest.mark.parametrize( + ("constraint_type", "attribute"), + [ + ( + "StringAttributeConstraints", + { + "Name": "email", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MinLength": "invalid_value"}, + }, + ), + ( + "StringAttributeConstraints", + { + "Name": "email", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MaxLength": "invalid_value"}, + }, + ), + ( + "NumberAttributeConstraints", + { + "Name": "updated_at", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MaxValue": "invalid_value"}, + }, + ), + ( + "NumberAttributeConstraints", + { + "Name": "updated_at", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MinValue": "invalid_value"}, + }, + ), + ], + ids=[ + "invalid_min_length", + "invalid_max_length", + "invalid_max_value", + "invalid_min_value", + ], +) +def test_create_user_pool_invalid_schema_values(constraint_type, attribute): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute]) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"Invalid {constraint_type} for schema attribute {attribute['Name']}" + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +@pytest.mark.parametrize( + "attribute", + [ + { + "Name": "email", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MinLength": "2049"}, + }, + { + "Name": "email", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MaxLength": "2049"}, + }, + ], + ids=["invalid_min_length", "invalid_max_length"], +) +def test_create_user_pool_string_schema_max_length_over_2048(attribute): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute]) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"user.{attribute['Name']}: String attributes cannot have a length of more than 2048" + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +def test_create_user_pool_string_schema_min_bigger_than_max(): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + { + "Name": "email", + "AttributeDataType": "String", + "StringAttributeConstraints": {"MinLength": "2", "MaxLength": "1"}, + } + ], + ) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"user.email: Max length cannot be less than min length." + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + +@mock_cognitoidp +def test_create_user_pool_number_schema_min_bigger_than_max(): + conn = boto3.client("cognito-idp", "us-west-2") + with pytest.raises(ClientError) as ex: + conn.create_user_pool( + PoolName=str(uuid.uuid4()), + Schema=[ + { + "Name": "updated_at", + "AttributeDataType": "Number", + "NumberAttributeConstraints": {"MinValue": "2", "MaxValue": "1"}, + } + ], + ) + ex.value.response["Error"]["Code"].should.equal("InvalidParameterException") + ex.value.response["Error"]["Message"].should.equal( + f"user.updated_at: Max value cannot be less than min value." + ) + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + + @mock_cognitoidp def test_list_user_pools(): conn = boto3.client("cognito-idp", "us-west-2")