diff --git a/README.md b/README.md index 2e6bb68b..e33803be 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | DeleteIdentityProvider | ❌ | | DeleteResourceServer | ❌ | | DeleteUser | ✅ | -| DeleteUserAttributes | ❌ | +| DeleteUserAttributes | ✅ | | DeleteUserPool | ❌ | | DeleteUserPoolClient | ❌ | | DeleteUserPoolDomain | ❌ | diff --git a/integration-tests/aws-sdk/deleteUserAttributes.test.ts b/integration-tests/aws-sdk/deleteUserAttributes.test.ts new file mode 100644 index 00000000..e8796f68 --- /dev/null +++ b/integration-tests/aws-sdk/deleteUserAttributes.test.ts @@ -0,0 +1,91 @@ +import { UUID } from "../../src/__tests__/patterns"; +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.deleteUserAttributes", + withCognitoSdk((Cognito) => { + it("updates a user's attributes", async () => { + const client = Cognito(); + + const pool = await client + .createUserPool({ + PoolName: "test", + AutoVerifiedAttributes: ["email"], + }) + .promise(); + const userPoolId = pool.UserPool?.Id as string; + + const upc = await client + .createUserPoolClient({ + UserPoolId: userPoolId, + ClientName: "test", + }) + .promise(); + + await client + .adminCreateUser({ + UserAttributes: [ + { Name: "email", Value: "example@example.com" }, + { Name: "custom:example", Value: "1" }, + ], + Username: "abc", + UserPoolId: userPoolId, + TemporaryPassword: "def", + DesiredDeliveryMediums: ["EMAIL"], + }) + .promise(); + + await client + .adminConfirmSignUp({ + UserPoolId: userPoolId, + Username: "abc", + }) + .promise(); + + // login as the user + const initiateAuthResponse = await client + .initiateAuth({ + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "def", + }, + ClientId: upc.UserPoolClient?.ClientId as string, + }) + .promise(); + + let user = await client + .adminGetUser({ + UserPoolId: userPoolId, + Username: "abc", + }) + .promise(); + + expect(user.UserAttributes).toEqual([ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example@example.com" }, + { Name: "custom:example", Value: "1" }, + ]); + + await client + .deleteUserAttributes({ + AccessToken: initiateAuthResponse.AuthenticationResult + ?.AccessToken as string, + UserAttributeNames: ["custom:example"], + }) + .promise(); + + user = await client + .adminGetUser({ + UserPoolId: userPoolId, + Username: "abc", + }) + .promise(); + + expect(user.UserAttributes).toEqual([ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example@example.com" }, + ]); + }); + }) +); diff --git a/integration-tests/aws-sdk/updateUserAttributes.test.ts b/integration-tests/aws-sdk/updateUserAttributes.test.ts index 54181483..82654ebc 100644 --- a/integration-tests/aws-sdk/updateUserAttributes.test.ts +++ b/integration-tests/aws-sdk/updateUserAttributes.test.ts @@ -1,4 +1,3 @@ -import Pino from "pino"; import { UUID } from "../../src/__tests__/patterns"; import { withCognitoSdk } from "./setup"; diff --git a/src/targets/deleteUserAttributes.test.ts b/src/targets/deleteUserAttributes.test.ts new file mode 100644 index 00000000..c0255416 --- /dev/null +++ b/src/targets/deleteUserAttributes.test.ts @@ -0,0 +1,92 @@ +import jwt from "jsonwebtoken"; +import * as uuid from "uuid"; +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import PrivateKey from "../keys/cognitoLocal.private.json"; +import { UserPoolService } from "../services"; +import { attribute } from "../services/userPoolService"; +import { + DeleteUserAttributes, + DeleteUserAttributesTarget, +} from "./deleteUserAttributes"; +import * as TDB from "../__tests__/testDataBuilder"; + +const clock = new ClockFake(new Date()); + +const validToken = jwt.sign( + { + sub: "0000-0000", + event_id: "0", + token_use: "access", + scope: "aws.cognito.signin.user.admin", + auth_time: new Date(), + jti: uuid.v4(), + client_id: "test", + username: "0000-0000", + }, + PrivateKey.pem, + { + algorithm: "RS256", + issuer: `http://localhost:9229/test`, + expiresIn: "24h", + keyid: "CognitoLocal", + } +); + +describe("DeleteUserAttributes target", () => { + let deleteUserAttributes: DeleteUserAttributesTarget; + let mockUserPoolService: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + deleteUserAttributes = DeleteUserAttributes({ + clock, + cognito: newMockCognitoService(mockUserPoolService), + }); + }); + + it("throws if the user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + deleteUserAttributes(TestContext, { + AccessToken: validToken, + UserAttributeNames: ["custom:example"], + }) + ).rejects.toEqual(new NotAuthorizedError()); + }); + + it("throws if the token is invalid", async () => { + await expect( + deleteUserAttributes(TestContext, { + AccessToken: "invalid token", + UserAttributeNames: ["custom:example"], + }) + ).rejects.toEqual(new InvalidParameterError()); + }); + + it("saves the updated attributes on the user", async () => { + const user = TDB.user({ + Attributes: [ + attribute("email", "example@example.com"), + attribute("custom:example", "1"), + ], + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await deleteUserAttributes(TestContext, { + AccessToken: validToken, + UserAttributeNames: ["custom:example"], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Attributes: [attribute("email", "example@example.com")], + UserLastModifiedDate: clock.get(), + }); + }); +}); diff --git a/src/targets/deleteUserAttributes.ts b/src/targets/deleteUserAttributes.ts new file mode 100644 index 00000000..cd189f51 --- /dev/null +++ b/src/targets/deleteUserAttributes.ts @@ -0,0 +1,49 @@ +import { + DeleteUserAttributesRequest, + DeleteUserAttributesResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import jwt from "jsonwebtoken"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import { Services } from "../services"; +import { Token } from "../services/tokenGenerator"; +import { attributesRemove } from "../services/userPoolService"; +import { Target } from "./router"; + +export type DeleteUserAttributesTarget = Target< + DeleteUserAttributesRequest, + DeleteUserAttributesResponse +>; + +type DeleteUserAttributesServices = Pick; + +export const DeleteUserAttributes = + ({ + clock, + cognito, + }: DeleteUserAttributesServices): DeleteUserAttributesTarget => + async (ctx, req) => { + const decodedToken = jwt.decode(req.AccessToken) as Token | null; + if (!decodedToken) { + ctx.logger.info("Unable to decode token"); + throw new InvalidParameterError(); + } + + const userPool = await cognito.getUserPoolForClientId( + ctx, + decodedToken.client_id + ); + const user = await userPool.getUserByUsername(ctx, decodedToken.sub); + if (!user) { + throw new NotAuthorizedError(); + } + + const updatedUser = { + ...user, + Attributes: attributesRemove(user.Attributes, ...req.UserAttributeNames), + UserLastModifiedDate: clock.get(), + }; + + await userPool.saveUser(ctx, updatedUser); + + return {}; + }; diff --git a/src/targets/router.ts b/src/targets/router.ts index 4d63ec89..babcae2b 100644 --- a/src/targets/router.ts +++ b/src/targets/router.ts @@ -9,6 +9,7 @@ import { CreateGroup } from "./createGroup"; import { CreateUserPool } from "./createUserPool"; import { CreateUserPoolClient } from "./createUserPoolClient"; import { DeleteUser } from "./deleteUser"; +import { DeleteUserAttributes } from "./deleteUserAttributes"; import { DescribeUserPoolClient } from "./describeUserPoolClient"; import { ForgotPassword } from "./forgotPassword"; import { ChangePassword } from "./changePassword"; @@ -46,6 +47,7 @@ export const Targets = { CreateUserPool, CreateUserPoolClient, DeleteUser, + DeleteUserAttributes, DescribeUserPoolClient, ForgotPassword, GetUser,