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,
+ );
});
});