diff --git a/auth-microservice/src/api/auth/auth.service.spec.ts b/auth-microservice/src/api/auth/auth.service.spec.ts index f8f9873..ff86091 100644 --- a/auth-microservice/src/api/auth/auth.service.spec.ts +++ b/auth-microservice/src/api/auth/auth.service.spec.ts @@ -105,6 +105,56 @@ const mockEmailConfig: EmailConfigDto = { '

Welcome {{firstName}}!

Password: {{tempPassword}}

', }; +// Shared OTP/registration test builders (OTP-T-3, OTP-T-10) — collapse the +// repeated Cognito/email-service mock setup and delegate-rejection shape to +// control duplication on new code. +const mockOtpTokens = { + accessToken: 'mock-access-token', + idToken: 'mock-id-token', + refreshToken: 'mock-refresh-token', + expiresIn: 3600, + tokenType: 'Bearer', +}; + +const mockCognitoAuthenticationResult = { + AuthenticationResult: { + AccessToken: mockOtpTokens.accessToken, + IdToken: mockOtpTokens.idToken, + RefreshToken: mockOtpTokens.refreshToken, + ExpiresIn: mockOtpTokens.expiresIn, + TokenType: mockOtpTokens.tokenType, + }, +}; + +const expectDelegatedRejection = ( + call: () => Promise, + error: unknown, +) => expect(call()).rejects.toBe(error); + +// Happy-path registration mocks (validate config -> generate password -> +// validate password -> create Cognito user) shared by the OTP-T-10 +// PASSWORDLESS_DOMAINS case; the pre-existing registration tests keep their +// own inline setup unchanged. +const mockRegisterUserHappyPath = ( + cognitoSvc: CognitoService, + dynamicEmailSvc: DynamicEmailService, + passwordGenSvc: PasswordGeneratorService, + createUserResult: unknown, + tempPassword = 'TempPass123!', +) => { + jest.spyOn(dynamicEmailSvc, 'validateEmailConfig').mockReturnValue({ + isValid: true, + errors: [], + }); + jest + .spyOn(passwordGenSvc, 'generateSecurePassword') + .mockReturnValue(tempPassword); + jest + .spyOn(passwordGenSvc, 'validateCognitoPassword') + .mockReturnValue({ isValid: true, errors: [] }); + jest.spyOn(cognitoSvc, 'createUser').mockResolvedValue(createUserResult); +}; + describe('AuthService', () => { let service: AuthService; let httpService: HttpService; @@ -839,22 +889,12 @@ describe('AuthService', () => { emailConfig: mockEmailConfig, }; - jest.spyOn(dynamicEmailService, 'validateEmailConfig').mockReturnValue({ - isValid: true, - errors: [], - }); - jest - .spyOn(passwordGeneratorService, 'generateSecurePassword') - .mockReturnValue('TempPass123!'); - jest - .spyOn(passwordGeneratorService, 'validateCognitoPassword') - .mockReturnValue({ - isValid: true, - errors: [], - }); - jest - .spyOn(cognitoService, 'createUser') - .mockResolvedValue(mockCreateUserResult); + mockRegisterUserHappyPath( + cognitoService, + dynamicEmailService, + passwordGeneratorService, + mockCreateUserResult, + ); jest.spyOn(cognitoService, 'isPasswordlessDomain').mockReturnValue(true); jest.spyOn(dynamicEmailService, 'getEmailStats').mockReturnValue({ variableCount: 6, @@ -1123,7 +1163,7 @@ describe('AuthService', () => { jest.spyOn(cognitoService, 'startEmailOtp').mockRejectedValueOnce(error); - await expect(service.startEmailOtp(dto)).rejects.toBe(error); + await expectDelegatedRejection(() => service.startEmailOtp(dto), error); expect(error.getResponse()).toMatchObject({ code: 'CHALLENGE_NOT_SUPPORTED', }); @@ -1138,15 +1178,7 @@ describe('AuthService', () => { code: '12345678', session: 'session-value', }; - const cognitoResult = { - tokens: { - accessToken: 'mock-access-token', - idToken: 'mock-id-token', - refreshToken: 'mock-refresh-token', - expiresIn: 3600, - tokenType: 'Bearer', - }, - }; + const cognitoResult = { tokens: mockOtpTokens }; jest .spyOn(cognitoService, 'verifyEmailOtp') @@ -1173,24 +1205,12 @@ describe('AuthService', () => { password: 'password123', }; - jest.spyOn(cognitoService, 'verifyEmailOtp').mockResolvedValueOnce({ - tokens: { - accessToken: 'mock-access-token', - idToken: 'mock-id-token', - refreshToken: 'mock-refresh-token', - expiresIn: 3600, - tokenType: 'Bearer', - }, - }); - jest.spyOn(cognitoService, 'loginWithCustomPassword').mockResolvedValueOnce({ - AuthenticationResult: { - AccessToken: 'mock-access-token', - IdToken: 'mock-id-token', - RefreshToken: 'mock-refresh-token', - ExpiresIn: 3600, - TokenType: 'Bearer', - }, - }); + jest + .spyOn(cognitoService, 'verifyEmailOtp') + .mockResolvedValueOnce({ tokens: mockOtpTokens }); + jest + .spyOn(cognitoService, 'loginWithCustomPassword') + .mockResolvedValueOnce(mockCognitoAuthenticationResult); const otpResult = await service.verifyEmailOtp(otpDto); const passwordResult = @@ -1210,11 +1230,9 @@ describe('AuthService', () => { HttpStatus.UNAUTHORIZED, ); - jest - .spyOn(cognitoService, 'verifyEmailOtp') - .mockRejectedValueOnce(error); + jest.spyOn(cognitoService, 'verifyEmailOtp').mockRejectedValueOnce(error); - await expect(service.verifyEmailOtp(dto)).rejects.toBe(error); + await expectDelegatedRejection(() => service.verifyEmailOtp(dto), error); }); }); }); diff --git a/auth-microservice/src/api/auth/services/cognito/cognito.service.spec.ts b/auth-microservice/src/api/auth/services/cognito/cognito.service.spec.ts index ceff3e3..66fa355 100644 --- a/auth-microservice/src/api/auth/services/cognito/cognito.service.spec.ts +++ b/auth-microservice/src/api/auth/services/cognito/cognito.service.spec.ts @@ -41,6 +41,51 @@ jest.mock('crypto', () => { }; }); +// Shared OTP test builders (OTP-T-2/T-10) — collapse the repeated +// fetch-mock / error-mapping-assertion shapes across the startEmailOtp and +// verifyEmailOtp describe blocks to control duplication on new code. +const mockFetchOnce = (status: number, body: unknown) => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValueOnce(body), + }); +}; + +const mockFetchRejectOnce = (error: unknown) => { + (global.fetch as jest.Mock).mockRejectedValueOnce(error); +}; + +const makeCognitoError = (type: string, message: string) => ({ + __type: type, + message, +}); + +const makeInitiateAuthResponse = ( + overrides: Partial<{ + ChallengeName: string; + Session: string; + ChallengeParameters: Record; + AvailableChallenges: string[]; + }> = {}, +) => ({ + ChallengeName: 'EMAIL_OTP', + Session: 'session-value', + ChallengeParameters: {}, + ...overrides, +}); + +const expectOtpRejection = async ( + promise: Promise, + code: string, + status: HttpStatus, +) => { + await expect(promise).rejects.toMatchObject({ + response: { code }, + status, + }); +}; + describe('CognitoService', () => { let service: CognitoService; let configService: ConfigService; @@ -444,17 +489,17 @@ describe('CognitoService', () => { (AdminCreateUserCommand as unknown as jest.Mock).mock.calls.length - 1 ][0]; + const createDomainUser = (username: string, firstName: string) => + service.createUser(username, 'TempPass123!', firstName, 'User', username); + + beforeEach(() => { + cognitoClient.send.mockResolvedValue(mockUserResponse); + }); + it('omits TemporaryPassword and suppresses messaging for a listed domain', async () => { withConfig(passwordlessConfig); - cognitoClient.send.mockResolvedValue(mockUserResponse); - await service.createUser( - 'center@icrisat.org', - 'TempPass123!', - 'Center', - 'User', - 'center@icrisat.org', - ); + await createDomainUser('center@icrisat.org', 'Center'); expect(AdminCreateUserCommand).toHaveBeenCalledWith({ UserPoolId: mockConfig.COGNITO_USER_POOL_ID, @@ -472,30 +517,16 @@ describe('CognitoService', () => { it('matches the domain case-insensitively', async () => { withConfig(passwordlessConfig); - cognitoClient.send.mockResolvedValue(mockUserResponse); - await service.createUser( - 'Center@ICRISAT.ORG', - 'TempPass123!', - 'Center', - 'User', - 'Center@ICRISAT.ORG', - ); + await createDomainUser('Center@ICRISAT.ORG', 'Center'); expect(lastCommandInput().TemporaryPassword).toBeUndefined(); }); it("keeps today's temporary-password flow byte-identical for a non-listed domain", async () => { withConfig(passwordlessConfig); - cognitoClient.send.mockResolvedValue(mockUserResponse); - await service.createUser( - 'other@example.com', - 'TempPass123!', - 'Other', - 'User', - 'other@example.com', - ); + await createDomainUser('other@example.com', 'Other'); expect(AdminCreateUserCommand).toHaveBeenCalledWith({ UserPoolId: mockConfig.COGNITO_USER_POOL_ID, @@ -513,29 +544,14 @@ describe('CognitoService', () => { it('does not treat a suffix match as a listed domain', async () => { withConfig(passwordlessConfig); - cognitoClient.send.mockResolvedValue(mockUserResponse); - await service.createUser( - 'attacker@evil-icrisat.org', - 'TempPass123!', - 'Attacker', - 'User', - 'attacker@evil-icrisat.org', - ); + await createDomainUser('attacker@evil-icrisat.org', 'Attacker'); expect(lastCommandInput().TemporaryPassword).toBe('TempPass123!'); }); it('keeps the feature off when PASSWORDLESS_DOMAINS is unset', async () => { - cognitoClient.send.mockResolvedValue(mockUserResponse); - - await service.createUser( - 'center@icrisat.org', - 'TempPass123!', - 'Center', - 'User', - 'center@icrisat.org', - ); + await createDomainUser('center@icrisat.org', 'Center'); expect(lastCommandInput().TemporaryPassword).toBe('TempPass123!'); }); @@ -545,9 +561,7 @@ describe('CognitoService', () => { withConfig(passwordlessConfig); expect(service.isPasswordlessDomain('a@CIFOR-ICRAF.ORG')).toBe(true); expect(service.isPasswordlessDomain('a@icrisat.org')).toBe(true); - expect(service.isPasswordlessDomain('a@evil-icrisat.org')).toBe( - false, - ); + expect(service.isPasswordlessDomain('a@evil-icrisat.org')).toBe(false); expect(service.isPasswordlessDomain('a@example.com')).toBe(false); }); @@ -1374,99 +1388,84 @@ describe('CognitoService', () => { // OTP-T-2: startEmailOtp / verifyEmailOtp + mapCognitoError // Fixture shapes pinned by the OTP-T-1 spike, docs/specs/changes/cognito-email-otp-login/fixtures/cognito/ describe('mapCognitoError', () => { - it('maps CodeMismatchException (respond-to-auth.code-mismatch.json) to CODE_MISMATCH', () => { - expect( - service['mapCognitoError']( - 'CodeMismatchException', - 'Invalid code or auth state for the user.', - ), - ).toBe('CODE_MISMATCH'); - }); - - it('maps ExpiredCodeException to CODE_EXPIRED', () => { - expect( - service['mapCognitoError']('ExpiredCodeException', 'Code expired.'), - ).toBe('CODE_EXPIRED'); - }); - - it('maps TooManyFailedAttemptsException to ATTEMPTS_EXCEEDED', () => { - expect( - service['mapCognitoError']( - 'TooManyFailedAttemptsException', - 'Attempt limit exceeded, please try after some time.', - ), - ).toBe('ATTEMPTS_EXCEEDED'); - }); - - it('maps a NotAuthorizedException carrying an attempts message to ATTEMPTS_EXCEEDED', () => { - expect( - service['mapCognitoError']( - 'NotAuthorizedException', - 'Attempt limit exceeded, please try after some time.', - ), - ).toBe('ATTEMPTS_EXCEEDED'); - }); - - it('maps the session-is-expired NotAuthorizedException (respond-to-auth.session-expired.json) to CODE_EXPIRED', () => { - expect( - service['mapCognitoError']( - 'NotAuthorizedException', - 'Invalid session for the user, session is expired.', - ), - ).toBe('CODE_EXPIRED'); - }); - - it('maps the reused-session NotAuthorizedException to NOT_AUTHORIZED', () => { - expect( - service['mapCognitoError']( - 'NotAuthorizedException', - 'Invalid session for the user, session can only be used once.', - ), - ).toBe('NOT_AUTHORIZED'); - }); - - it('maps a generic NotAuthorizedException to NOT_AUTHORIZED', () => { - expect( - service['mapCognitoError']( - 'NotAuthorizedException', - 'Incorrect username or password.', - ), - ).toBe('NOT_AUTHORIZED'); - }); - - it('passes through the CHALLENGE_NOT_SUPPORTED sentinel', () => { - expect( - service['mapCognitoError']( - 'CHALLENGE_NOT_SUPPORTED', - 'Unsupported authentication challenge.', - ), - ).toBe('CHALLENGE_NOT_SUPPORTED'); - }); - - it('maps an unrecognised error type to UPSTREAM_ERROR', () => { - expect( - service['mapCognitoError']('ServiceUnavailableException', 'down'), - ).toBe('UPSTREAM_ERROR'); + it.each([ + { + title: + 'maps CodeMismatchException (respond-to-auth.code-mismatch.json) to CODE_MISMATCH', + type: 'CodeMismatchException', + message: 'Invalid code or auth state for the user.', + expected: 'CODE_MISMATCH', + }, + { + title: 'maps ExpiredCodeException to CODE_EXPIRED', + type: 'ExpiredCodeException', + message: 'Code expired.', + expected: 'CODE_EXPIRED', + }, + { + title: 'maps TooManyFailedAttemptsException to ATTEMPTS_EXCEEDED', + type: 'TooManyFailedAttemptsException', + message: 'Attempt limit exceeded, please try after some time.', + expected: 'ATTEMPTS_EXCEEDED', + }, + { + title: + 'maps a NotAuthorizedException carrying an attempts message to ATTEMPTS_EXCEEDED', + type: 'NotAuthorizedException', + message: 'Attempt limit exceeded, please try after some time.', + expected: 'ATTEMPTS_EXCEEDED', + }, + { + title: + 'maps the session-is-expired NotAuthorizedException (respond-to-auth.session-expired.json) to CODE_EXPIRED', + type: 'NotAuthorizedException', + message: 'Invalid session for the user, session is expired.', + expected: 'CODE_EXPIRED', + }, + { + title: + 'maps the reused-session NotAuthorizedException to NOT_AUTHORIZED', + type: 'NotAuthorizedException', + message: 'Invalid session for the user, session can only be used once.', + expected: 'NOT_AUTHORIZED', + }, + { + title: 'maps a generic NotAuthorizedException to NOT_AUTHORIZED', + type: 'NotAuthorizedException', + message: 'Incorrect username or password.', + expected: 'NOT_AUTHORIZED', + }, + { + title: 'passes through the CHALLENGE_NOT_SUPPORTED sentinel', + type: 'CHALLENGE_NOT_SUPPORTED', + message: 'Unsupported authentication challenge.', + expected: 'CHALLENGE_NOT_SUPPORTED', + }, + { + title: 'maps an unrecognised error type to UPSTREAM_ERROR', + type: 'ServiceUnavailableException', + message: 'down', + expected: 'UPSTREAM_ERROR', + }, + ])('$title', ({ type, message, expected }) => { + expect(service['mapCognitoError'](type, message)).toBe(expected); }); }); describe('startEmailOtp', () => { - // initiate-auth.email-otp.confirmed-user.json - const directEmailOtpChallenge = { - ChallengeName: 'EMAIL_OTP', - Session: 'session-from-initiate-auth', - ChallengeParameters: { - CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', - CODE_DELIVERY_DESTINATION: 'j***@g***', - }, - AvailableChallenges: ['EMAIL_OTP'], - }; - it('calls InitiateAuth with USER_AUTH + PREFERRED_CHALLENGE=EMAIL_OTP and returns the direct challenge', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce(directEmailOtpChallenge), - }); + // initiate-auth.email-otp.confirmed-user.json + mockFetchOnce( + 200, + makeInitiateAuthResponse({ + Session: 'session-from-initiate-auth', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@g***', + }, + AvailableChallenges: ['EMAIL_OTP'], + }), + ); const result = await service.startEmailOtp('user@icrisat.org'); @@ -1499,30 +1498,24 @@ describe('CognitoService', () => { it('answers a SELECT_CHALLENGE offering EMAIL_OTP and returns the new session', async () => { // initiate-auth.email-otp.force-change-password.json shape, but with EMAIL_OTP offered - const selectChallenge = { - ChallengeName: 'SELECT_CHALLENGE', - Session: 'session-from-select-challenge', - ChallengeParameters: {}, - AvailableChallenges: ['EMAIL_OTP', 'PASSWORD'], - }; - const secondReply = { - ChallengeName: 'EMAIL_OTP', - Session: 'session-after-select', - ChallengeParameters: { - CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', - CODE_DELIVERY_DESTINATION: 'j***@i***', - }, - }; - - (global.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce(selectChallenge), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce(secondReply), - }); + mockFetchOnce( + 200, + makeInitiateAuthResponse({ + ChallengeName: 'SELECT_CHALLENGE', + Session: 'session-from-select-challenge', + AvailableChallenges: ['EMAIL_OTP', 'PASSWORD'], + }), + ); + mockFetchOnce( + 200, + makeInitiateAuthResponse({ + Session: 'session-after-select', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@i***', + }, + }), + ); const result = await service.startEmailOtp('user@icrisat.org'); @@ -1559,43 +1552,36 @@ describe('CognitoService', () => { it('maps a SELECT_CHALLENGE without EMAIL_OTP available (force-change-password fixture) to CHALLENGE_NOT_SUPPORTED', async () => { // initiate-auth.email-otp.force-change-password.json - const forceChangePassword = { - ChallengeName: 'SELECT_CHALLENGE', - Session: 'session-force-change-password', - ChallengeParameters: {}, - AvailableChallenges: ['PASSWORD_SRP', 'PASSWORD'], - }; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce(forceChangePassword), - }); + mockFetchOnce( + 200, + makeInitiateAuthResponse({ + ChallengeName: 'SELECT_CHALLENGE', + Session: 'session-force-change-password', + AvailableChallenges: ['PASSWORD_SRP', 'PASSWORD'], + }), + ); - await expect( + await expectOtpRejection( service.startEmailOtp('locked@icrisat.org'), - ).rejects.toMatchObject({ - response: { code: 'CHALLENGE_NOT_SUPPORTED' }, - status: HttpStatus.UNAUTHORIZED, - }); + 'CHALLENGE_NOT_SUPPORTED', + HttpStatus.UNAUTHORIZED, + ); expect(global.fetch).toHaveBeenCalledTimes(1); }); it('returns the simulated EMAIL_OTP challenge for an email unknown to Cognito (initiate-auth.unknown-user.json)', async () => { - const unknownUser = { - ChallengeName: 'EMAIL_OTP', - Session: 'session-unknown-user', - ChallengeParameters: { - CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', - CODE_DELIVERY_DESTINATION: 'n***@i***', - }, - AvailableChallenges: ['EMAIL_OTP'], - }; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce(unknownUser), - }); + mockFetchOnce( + 200, + makeInitiateAuthResponse({ + Session: 'session-unknown-user', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'n***@i***', + }, + AvailableChallenges: ['EMAIL_OTP'], + }), + ); const result = await service.startEmailOtp('nobody@icrisat.org'); @@ -1607,14 +1593,13 @@ describe('CognitoService', () => { }); it('maps a NotAuthorizedException on InitiateAuth to NOT_AUTHORIZED without leaking the raw message', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValueOnce({ - __type: 'NotAuthorizedException', - message: 'Incorrect username or password.', - }), - }); + mockFetchOnce( + 400, + makeCognitoError( + 'NotAuthorizedException', + 'Incorrect username or password.', + ), + ); await expect( service.startEmailOtp('user@icrisat.org'), @@ -1628,50 +1613,37 @@ describe('CognitoService', () => { }); it('rejects an unsupported direct challenge (SMS_MFA) with CHALLENGE_NOT_SUPPORTED', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce({ - ChallengeName: 'SMS_MFA', - Session: 'session-sms', - }), - }); + mockFetchOnce(200, { ChallengeName: 'SMS_MFA', Session: 'session-sms' }); - await expect( + await expectOtpRejection( service.startEmailOtp('user@icrisat.org'), - ).rejects.toMatchObject({ - response: { code: 'CHALLENGE_NOT_SUPPORTED' }, - status: HttpStatus.UNAUTHORIZED, - }); + 'CHALLENGE_NOT_SUPPORTED', + HttpStatus.UNAUTHORIZED, + ); }); it('maps a fetch network failure to UPSTREAM_ERROR (502)', async () => { - (global.fetch as jest.Mock).mockRejectedValueOnce( - new Error('Network connection failed'), - ); + mockFetchRejectOnce(new Error('Network connection failed')); - await expect( + await expectOtpRejection( service.startEmailOtp('user@icrisat.org'), - ).rejects.toMatchObject({ - response: { code: 'UPSTREAM_ERROR' }, - status: HttpStatus.BAD_GATEWAY, - }); + 'UPSTREAM_ERROR', + HttpStatus.BAD_GATEWAY, + ); }); }); describe('verifyEmailOtp', () => { it('calls RespondToAuthChallenge with EMAIL_OTP_CODE and returns tokens on success (respond-to-auth.success.json)', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce({ - ChallengeParameters: {}, - AuthenticationResult: { - AccessToken: 'access-token-value', - ExpiresIn: 3600, - TokenType: 'Bearer', - RefreshToken: 'refresh-token-value', - IdToken: 'id-token-value', - }, - }), + mockFetchOnce(200, { + ChallengeParameters: {}, + AuthenticationResult: { + AccessToken: 'access-token-value', + ExpiresIn: 3600, + TokenType: 'Bearer', + RefreshToken: 'refresh-token-value', + IdToken: 'id-token-value', + }, }); const result = await service.verifyEmailOtp( @@ -1713,115 +1685,67 @@ describe('CognitoService', () => { }); }); - it('maps CodeMismatchException (respond-to-auth.code-mismatch.json) to CODE_MISMATCH', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValueOnce({ - __type: 'CodeMismatchException', - message: 'Invalid code or auth state for the user.', - }), - }); + // Each row exercises the shared `RespondToAuthChallenge` error-response + // -> mapCognitoError -> HttpException path (OTP-T-2 fixtures). + it.each([ + { + title: + 'maps CodeMismatchException (respond-to-auth.code-mismatch.json) to CODE_MISMATCH', + type: 'CodeMismatchException', + message: 'Invalid code or auth state for the user.', + code: 'CODE_MISMATCH', + }, + { + title: + 'maps the session-is-expired error (respond-to-auth.session-expired.json) to CODE_EXPIRED', + type: 'NotAuthorizedException', + message: 'Invalid session for the user, session is expired.', + code: 'CODE_EXPIRED', + }, + { + title: 'maps the reused-session error to NOT_AUTHORIZED', + type: 'NotAuthorizedException', + message: 'Invalid session for the user, session can only be used once.', + code: 'NOT_AUTHORIZED', + }, + { + title: + 'maps an attempts-exceeded NotAuthorizedException to ATTEMPTS_EXCEEDED', + type: 'NotAuthorizedException', + message: 'Attempt limit exceeded, please try after some time.', + code: 'ATTEMPTS_EXCEEDED', + }, + ])('$title', async ({ type, message, code }) => { + mockFetchOnce(400, makeCognitoError(type, message)); - await expect( + await expectOtpRejection( service.verifyEmailOtp('user@icrisat.org', '00000000', 'session-value'), - ).rejects.toMatchObject({ - response: { code: 'CODE_MISMATCH' }, - status: HttpStatus.UNAUTHORIZED, - }); - }); - - it('maps the session-is-expired error (respond-to-auth.session-expired.json) to CODE_EXPIRED', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValueOnce({ - __type: 'NotAuthorizedException', - message: 'Invalid session for the user, session is expired.', - }), - }); - - await expect( - service.verifyEmailOtp( - 'user@icrisat.org', - '12345678', - 'expired-session', - ), - ).rejects.toMatchObject({ - response: { code: 'CODE_EXPIRED' }, - status: HttpStatus.UNAUTHORIZED, - }); - }); - - it('maps the reused-session error to NOT_AUTHORIZED', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValueOnce({ - __type: 'NotAuthorizedException', - message: - 'Invalid session for the user, session can only be used once.', - }), - }); - - await expect( - service.verifyEmailOtp( - 'user@icrisat.org', - '12345678', - 'reused-session', - ), - ).rejects.toMatchObject({ - response: { code: 'NOT_AUTHORIZED' }, - status: HttpStatus.UNAUTHORIZED, - }); - }); - - it('maps an attempts-exceeded NotAuthorizedException to ATTEMPTS_EXCEEDED', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValueOnce({ - __type: 'NotAuthorizedException', - message: 'Attempt limit exceeded, please try after some time.', - }), - }); - - await expect( - service.verifyEmailOtp('user@icrisat.org', '12345678', 'session-value'), - ).rejects.toMatchObject({ - response: { code: 'ATTEMPTS_EXCEEDED' }, - status: HttpStatus.UNAUTHORIZED, - }); + code, + HttpStatus.UNAUTHORIZED, + ); }); it('maps a challenge returned instead of tokens to CHALLENGE_NOT_SUPPORTED', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce({ - ChallengeName: 'SMS_MFA', - Session: 'another-session', - }), + mockFetchOnce(200, { + ChallengeName: 'SMS_MFA', + Session: 'another-session', }); - await expect( + await expectOtpRejection( service.verifyEmailOtp('user@icrisat.org', '12345678', 'session-value'), - ).rejects.toMatchObject({ - response: { code: 'CHALLENGE_NOT_SUPPORTED' }, - status: HttpStatus.UNAUTHORIZED, - }); + 'CHALLENGE_NOT_SUPPORTED', + HttpStatus.UNAUTHORIZED, + ); }); it('maps a fetch network failure to UPSTREAM_ERROR (502)', async () => { - (global.fetch as jest.Mock).mockRejectedValueOnce( - new Error('fetch failed'), - ); + mockFetchRejectOnce(new Error('fetch failed')); - await expect( + await expectOtpRejection( service.verifyEmailOtp('user@icrisat.org', '12345678', 'session-value'), - ).rejects.toMatchObject({ - response: { code: 'UPSTREAM_ERROR' }, - status: HttpStatus.BAD_GATEWAY, - }); + 'UPSTREAM_ERROR', + HttpStatus.BAD_GATEWAY, + ); }); });