diff --git a/integration-tests/aws-sdk/createUserPool.test.ts b/integration-tests/aws-sdk/createUserPool.test.ts index c9f855bc..20f45cc5 100644 --- a/integration-tests/aws-sdk/createUserPool.test.ts +++ b/integration-tests/aws-sdk/createUserPool.test.ts @@ -1,3 +1,4 @@ +import Pino from "pino"; import { ClockFake } from "../../src/__tests__/clockFake"; import { UUID } from "../../src/__tests__/patterns"; import { USER_POOL_AWS_DEFAULTS } from "../../src/services/cognitoService"; @@ -38,6 +39,7 @@ describe( }, { clock, + logger: Pino(), } ) ); diff --git a/src/services/cognitoService.ts b/src/services/cognitoService.ts index 500317a1..8b883ab0 100644 --- a/src/services/cognitoService.ts +++ b/src/services/cognitoService.ts @@ -1,3 +1,4 @@ +import mergeWith from "lodash.mergewith"; import * as path from "path"; import { ResourceNotFoundError } from "../errors"; import { UserPoolDefaults } from "../server/config"; @@ -309,11 +310,12 @@ export class CognitoServiceImpl implements CognitoService { const service = await this.userPoolServiceFactory.create( ctx, this.clients, - { - ...USER_POOL_AWS_DEFAULTS, - ...this.userPoolDefaultConfig, - ...userPool, - } + mergeWith( + {}, + USER_POOL_AWS_DEFAULTS, + this.userPoolDefaultConfig, + userPool + ) ); return service.config; diff --git a/src/targets/createUserPool.test.ts b/src/targets/createUserPool.test.ts index 2fb7741c..33249140 100644 --- a/src/targets/createUserPool.test.ts +++ b/src/targets/createUserPool.test.ts @@ -4,6 +4,7 @@ import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { TestContext } from "../__tests__/testContext"; import * as TDB from "../__tests__/testDataBuilder"; import { CognitoService } from "../services"; +import { USER_POOL_AWS_DEFAULTS } from "../services/cognitoService"; import { CreateUserPool, CreateUserPoolTarget } from "./createUserPool"; const originalDate = new Date(); @@ -38,7 +39,296 @@ describe("CreateUserPool target", () => { Id: expect.stringMatching(/^local_[\w\d]{8}$/), LastModifiedDate: originalDate, Name: "test-pool", - PoolName: "test-pool", + SchemaAttributes: USER_POOL_AWS_DEFAULTS.SchemaAttributes, + } + ); + + expect(result).toEqual({ + UserPool: createdUserPool, + }); + }); + + it("creates a new user pool with a custom attribute", async () => { + const createdUserPool = TDB.userPool(); + mockCognitoService.createUserPool.mockResolvedValue(createdUserPool); + + const result = await createUserPool(TestContext, { + PoolName: "test-pool", + Schema: [ + { + Name: "my_attribute", + AttributeDataType: "String", + }, + ], + }); + + expect(mockCognitoService.createUserPool).toHaveBeenCalledWith( + TestContext, + { + Arn: expect.stringMatching( + /^arn:aws:cognito-idp:local:local:userpool\/local_[\w\d]{8}$/ + ), + CreationDate: originalDate, + Id: expect.stringMatching(/^local_[\w\d]{8}$/), + LastModifiedDate: originalDate, + Name: "test-pool", + SchemaAttributes: [ + ...(USER_POOL_AWS_DEFAULTS.SchemaAttributes ?? []), + { + Name: "custom:my_attribute", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + ], + } + ); + + expect(result).toEqual({ + UserPool: createdUserPool, + }); + }); + + it("creates a new user pool with an overridden attribute", async () => { + const createdUserPool = TDB.userPool(); + mockCognitoService.createUserPool.mockResolvedValue(createdUserPool); + + const result = await createUserPool(TestContext, { + PoolName: "test-pool", + Schema: [ + { + Name: "email", + AttributeDataType: "String", + Required: true, + }, + ], + }); + + expect(mockCognitoService.createUserPool).toHaveBeenCalledWith( + TestContext, + { + Arn: expect.stringMatching( + /^arn:aws:cognito-idp:local:local:userpool\/local_[\w\d]{8}$/ + ), + CreationDate: originalDate, + Id: expect.stringMatching(/^local_[\w\d]{8}$/), + LastModifiedDate: originalDate, + Name: "test-pool", + SchemaAttributes: [ + { + Name: "sub", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: false, + Required: true, + StringAttributeConstraints: { + MinLength: "1", + MaxLength: "2048", + }, + }, + { + Name: "name", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "given_name", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "family_name", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "middle_name", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "nickname", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "preferred_username", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "profile", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "picture", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "website", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "email", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: true, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "email_verified", + AttributeDataType: "Boolean", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + }, + { + Name: "gender", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "birthdate", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "10", + MaxLength: "10", + }, + }, + { + Name: "zoneinfo", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "locale", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "phone_number", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "phone_number_verified", + AttributeDataType: "Boolean", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + }, + { + Name: "address", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: "0", + MaxLength: "2048", + }, + }, + { + Name: "updated_at", + AttributeDataType: "Number", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + NumberAttributeConstraints: { + MinValue: "0", + }, + }, + ], } ); diff --git a/src/targets/createUserPool.ts b/src/targets/createUserPool.ts index 7abc88c9..965df1af 100644 --- a/src/targets/createUserPool.ts +++ b/src/targets/createUserPool.ts @@ -1,9 +1,11 @@ import { CreateUserPoolRequest, CreateUserPoolResponse, + SchemaAttributesListType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; import shortUUID from "short-uuid"; import { Services } from "../services"; +import { USER_POOL_AWS_DEFAULTS } from "../services/cognitoService"; import { Target } from "./router"; const REGION = "local"; @@ -20,18 +22,88 @@ export type CreateUserPoolTarget = Target< type CreateUserPoolServices = Pick; +/** + * createSchemaAttributes combines the default list of User Pool Schema Attributes with the Schema provided by the + * caller in their request. Any attributes from the caller which match a default attribute are treated as an override + * and merged with the default, and any remaining attributes from the caller have their name prefixed with "custom:". + * + * @param defaultAttributes Cognito's default Schema Attributes + * @param requestSchema Schema provided by the caller + */ +const createSchemaAttributes = ( + defaultAttributes: SchemaAttributesListType, + requestSchema: SchemaAttributesListType +): SchemaAttributesListType => { + const overrides = Object.fromEntries( + requestSchema.map((x) => [x.Name as string, x]) + ); + const defaultAttributeNames = defaultAttributes.map((x) => x.Name); + const overriddenAttributes = defaultAttributes.map((attr) => { + if (!attr.Name) { + return attr; + } + + const override = overrides[attr.Name]; + return { + ...attr, + ...override, + }; + }); + const customAttributes = requestSchema + .filter((x) => !defaultAttributeNames.includes(x.Name)) + .map((attr) => { + const type = attr.AttributeDataType ?? "String"; + + return { + Name: `custom:${attr.Name}`, + AttributeDataType: type, + DeveloperOnlyAttribute: attr.DeveloperOnlyAttribute ?? false, + Mutable: attr.Mutable ?? true, + Required: attr.Required ?? false, + StringAttributeConstraints: + type === "String" ? attr.StringAttributeConstraints ?? {} : undefined, + NumberAttributeConstraints: + type === "Number" ? attr.NumberAttributeConstraints ?? {} : undefined, + }; + }); + + return [...overriddenAttributes, ...customAttributes]; +}; + export const CreateUserPool = ({ cognito, clock }: CreateUserPoolServices): CreateUserPoolTarget => async (ctx, req) => { const now = clock.get(); const userPoolId = `${REGION}_${generator.new().slice(0, 8)}`; const userPool = await cognito.createUserPool(ctx, { - ...req, + AccountRecoverySetting: req.AccountRecoverySetting, + AdminCreateUserConfig: req.AdminCreateUserConfig, + AliasAttributes: req.AliasAttributes, Arn: `arn:aws:cognito-idp:${REGION}:${ACCOUNT_ID}:userpool/${userPoolId}`, + AutoVerifiedAttributes: req.AutoVerifiedAttributes, CreationDate: now, + DeviceConfiguration: req.DeviceConfiguration, + EmailConfiguration: req.EmailConfiguration, + EmailVerificationMessage: req.EmailVerificationMessage, + EmailVerificationSubject: req.EmailVerificationSubject, Id: userPoolId, + LambdaConfig: req.LambdaConfig, LastModifiedDate: now, + MfaConfiguration: req.MfaConfiguration, Name: req.PoolName, + Policies: req.Policies, + SchemaAttributes: createSchemaAttributes( + USER_POOL_AWS_DEFAULTS.SchemaAttributes ?? [], + req.Schema ?? [] + ), + SmsAuthenticationMessage: req.SmsAuthenticationMessage, + SmsConfiguration: req.SmsConfiguration, + SmsVerificationMessage: req.SmsVerificationMessage, + UsernameAttributes: req.UsernameAttributes, + UsernameConfiguration: req.UsernameConfiguration, + UserPoolAddOns: req.UserPoolAddOns, + UserPoolTags: req.UserPoolTags, + VerificationMessageTemplate: req.VerificationMessageTemplate, }); return {