diff --git a/integration-tests/aws-sdk/addCustomAttributes.test.ts b/integration-tests/aws-sdk/addCustomAttributes.test.ts new file mode 100644 index 00000000..19106d03 --- /dev/null +++ b/integration-tests/aws-sdk/addCustomAttributes.test.ts @@ -0,0 +1,60 @@ +import { USER_POOL_AWS_DEFAULTS } from "../../src/services/cognitoService"; +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.addCustomAttributes", + withCognitoSdk((Cognito) => { + it("updates a user pool", async () => { + const client = Cognito(); + + // create the user pool client + const up = await client + .createUserPool({ + PoolName: "pool", + }) + .promise(); + + const describeResponse = await client + .describeUserPool({ + UserPoolId: up.UserPool?.Id!, + }) + .promise(); + + expect(describeResponse.UserPool).toMatchObject({ + SchemaAttributes: USER_POOL_AWS_DEFAULTS.SchemaAttributes, + }); + + await client + .addCustomAttributes({ + UserPoolId: up.UserPool?.Id!, + CustomAttributes: [ + { + AttributeDataType: "String", + Name: "test", + }, + ], + }) + .promise(); + + const describeResponseAfterUpdate = await client + .describeUserPool({ + UserPoolId: up.UserPool?.Id!, + }) + .promise(); + + expect(describeResponseAfterUpdate.UserPool).toMatchObject({ + SchemaAttributes: [ + ...(USER_POOL_AWS_DEFAULTS.SchemaAttributes ?? []), + { + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Name: "custom:test", + Required: false, + StringAttributeConstraints: {}, + }, + ], + }); + }); + }) +); diff --git a/src/__tests__/testDataBuilder.ts b/src/__tests__/testDataBuilder.ts index 0481d78c..b0a7345c 100644 --- a/src/__tests__/testDataBuilder.ts +++ b/src/__tests__/testDataBuilder.ts @@ -1,5 +1,6 @@ import { v4 } from "uuid"; import { AppClient } from "../services/appClient"; +import { USER_POOL_AWS_DEFAULTS } from "../services/cognitoService"; import { Group, User, UserPool } from "../services/userPoolService"; export const id = (prefix: string, number?: number) => @@ -84,7 +85,8 @@ export const userPool = (partial?: Partial): UserPool => { MfaConfiguration: partial?.MfaConfiguration ?? undefined, Name: partial?.Name ?? undefined, Policies: partial?.Policies ?? undefined, - SchemaAttributes: partial?.SchemaAttributes ?? undefined, + SchemaAttributes: + partial?.SchemaAttributes ?? USER_POOL_AWS_DEFAULTS.SchemaAttributes, SmsAuthenticationMessage: partial?.SmsAuthenticationMessage ?? undefined, SmsConfiguration: partial?.SmsConfiguration ?? undefined, SmsConfigurationFailure: partial?.SmsConfigurationFailure ?? undefined, diff --git a/src/targets/addCustomAttributes.test.ts b/src/targets/addCustomAttributes.test.ts new file mode 100644 index 00000000..8543e2eb --- /dev/null +++ b/src/targets/addCustomAttributes.test.ts @@ -0,0 +1,146 @@ +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import * as TDB from "../__tests__/testDataBuilder"; +import { + GroupNotFoundError, + InvalidParameterError, + UserNotFoundError, +} from "../errors"; +import { CognitoService, UserPoolService } from "../services"; +import { + AddCustomAttributes, + AddCustomAttributesTarget, +} from "./addCustomAttributes"; + +const originalDate = new Date(); + +describe("AddCustomAttributes target", () => { + let addCustomAttributes: AddCustomAttributesTarget; + let clock: ClockFake; + let mockCognitoService: jest.Mocked; + + beforeEach(() => { + clock = new ClockFake(originalDate); + + mockCognitoService = newMockCognitoService(); + addCustomAttributes = AddCustomAttributes({ + clock, + cognito: mockCognitoService, + }); + }); + + it("appends a custom attribute to the user pool", async () => { + const userPool = TDB.userPool(); + const mockUserPoolService = newMockUserPoolService(userPool); + + mockCognitoService.getUserPool.mockResolvedValue(mockUserPoolService); + + const newDate = new Date(); + clock.advanceTo(newDate); + + await addCustomAttributes(TestContext, { + UserPoolId: "test", + CustomAttributes: [ + { + AttributeDataType: "String", + Name: "test", + }, + ], + }); + + expect(mockUserPoolService.updateOptions).toHaveBeenCalledWith( + TestContext, + { + ...userPool, + SchemaAttributes: [ + ...(userPool.SchemaAttributes ?? []), + { + Name: "custom:test", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + ], + LastModifiedDate: newDate, + } + ); + }); + + it("can create a custom attribute with no name", async () => { + const userPool = TDB.userPool(); + const mockUserPoolService = newMockUserPoolService(userPool); + + mockCognitoService.getUserPool.mockResolvedValue(mockUserPoolService); + + const newDate = new Date(); + clock.advanceTo(newDate); + + await addCustomAttributes(TestContext, { + UserPoolId: "test", + CustomAttributes: [ + { + AttributeDataType: "String", + }, + ], + }); + + expect(mockUserPoolService.updateOptions).toHaveBeenCalledWith( + TestContext, + { + ...userPool, + SchemaAttributes: [ + ...(userPool.SchemaAttributes ?? []), + { + Name: "custom:null", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + ], + LastModifiedDate: newDate, + } + ); + }); + + it("throws if an attribute with the name already exists", async () => { + const userPool = TDB.userPool({ + SchemaAttributes: [ + { + Name: "custom:test", + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + ], + }); + const mockUserPoolService = newMockUserPoolService(userPool); + + mockCognitoService.getUserPool.mockResolvedValue(mockUserPoolService); + + await expect( + addCustomAttributes(TestContext, { + UserPoolId: "test", + CustomAttributes: [ + { + AttributeDataType: "String", + Name: "test", + }, + ], + }) + ).rejects.toEqual( + new InvalidParameterError( + "custom:test: Existing attribute already has name custom:test." + ) + ); + + expect(mockUserPoolService.updateOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/targets/addCustomAttributes.ts b/src/targets/addCustomAttributes.ts new file mode 100644 index 00000000..14b7d99d --- /dev/null +++ b/src/targets/addCustomAttributes.ts @@ -0,0 +1,55 @@ +import { + AddCustomAttributesRequest, + AddCustomAttributesResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { InvalidParameterError } from "../errors"; +import { Services } from "../services"; +import { assertParameterLength } from "./utils/assertions"; +import { Target } from "./Target"; + +export type AddCustomAttributesTarget = Target< + AddCustomAttributesRequest, + AddCustomAttributesResponse +>; + +type AddCustomAttributesServices = Pick; + +export const AddCustomAttributes = + ({ + clock, + cognito, + }: AddCustomAttributesServices): AddCustomAttributesTarget => + async (ctx, req) => { + assertParameterLength("CustomAttributes", 1, 25, req.CustomAttributes); + + const userPool = await cognito.getUserPool(ctx, req.UserPoolId); + + await userPool.updateOptions(ctx, { + ...userPool.options, + SchemaAttributes: [ + ...(userPool.options.SchemaAttributes ?? []), + ...req.CustomAttributes.map(({ Name, ...attr }) => { + const name = `custom:${Name ?? "null"}`; + + if (userPool.options.SchemaAttributes?.find((x) => x.Name === name)) { + throw new InvalidParameterError( + `${name}: Existing attribute already has name ${name}.` + ); + } + + return { + AttributeDataType: "String", + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + Name: name, + ...attr, + }; + }), + ], + LastModifiedDate: clock.get(), + }); + + return {}; + }; diff --git a/src/targets/targets.ts b/src/targets/targets.ts index 4f03e503..3bd432b8 100644 --- a/src/targets/targets.ts +++ b/src/targets/targets.ts @@ -1,3 +1,4 @@ +import { AddCustomAttributes } from "./addCustomAttributes"; import { AdminAddUserToGroup } from "./adminAddUserToGroup"; import { AdminConfirmSignUp } from "./adminConfirmSignUp"; import { AdminCreateUser } from "./adminCreateUser"; @@ -46,6 +47,7 @@ import { UpdateUserPoolClient } from "./updateUserPoolClient"; import { VerifyUserAttribute } from "./verifyUserAttribute"; export const Targets = { + AddCustomAttributes, AdminAddUserToGroup, AdminConfirmSignUp, AdminCreateUser, diff --git a/src/targets/utils/assertions.ts b/src/targets/utils/assertions.ts new file mode 100644 index 00000000..ca582329 --- /dev/null +++ b/src/targets/utils/assertions.ts @@ -0,0 +1,46 @@ +import { InvalidParameterError } from "../../errors"; + +/** + * Assert a required parameter has a value. Throws an InvalidParameterError. + * + * @param name Name of the parameter to include in the error message + * @param parameter Parameter to assert + * @param message Custom full message for the error, optional + */ +export function assertRequiredParameter( + name: string, + parameter: T, + message?: string +): asserts parameter is NonNullable { + if (!parameter) { + throw new InvalidParameterError( + message ?? `Missing required parameter ${name}` + ); + } +} + +/** + * Asserts an array parameter has a length between min and max. Throws an InvalidParameterError. + * + * @param name Name of the parameter to include in the error message + * @param min Minimum length + * @param max Maximum length + * @param parameter Parameter to assert + */ +export function assertParameterLength( + name: string, + min: number, + max: number, + parameter: readonly T[] +): asserts parameter { + if (parameter.length < min) { + throw new InvalidParameterError( + `Invalid length for parameter ${name}, value: ${parameter.length}, valid min length: ${min}` + ); + } + if (parameter.length > max) { + throw new InvalidParameterError( + `Invalid length for parameter ${name}, value: ${parameter.length}, valid max length: ${max}` + ); + } +}