diff --git a/azure-functions/acbs-function/.eslintrc.js b/azure-functions/acbs-function/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/azure-functions/acbs-function/.eslintrc.js +++ b/azure-functions/acbs-function/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/dtfs-central-api/.eslintrc.js b/dtfs-central-api/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/dtfs-central-api/.eslintrc.js +++ b/dtfs-central-api/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/e2e-tests/.eslintrc.js b/e2e-tests/.eslintrc.js index 2a82e264c1..0cee878ede 100644 --- a/e2e-tests/.eslintrc.js +++ b/e2e-tests/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/external-api/.eslintrc.js b/external-api/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/external-api/.eslintrc.js +++ b/external-api/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/gef-ui/.eslintrc.js b/gef-ui/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/gef-ui/.eslintrc.js +++ b/gef-ui/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/libs/common/.eslintrc.js b/libs/common/.eslintrc.js index 980bf2a2b2..d00d3b4565 100644 --- a/libs/common/.eslintrc.js +++ b/libs/common/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/libs/common/package.json b/libs/common/package.json index 72ddfcfacc..572f3e06ac 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -47,6 +47,7 @@ "unit-test-ff": "jest --coverage --verbose --config=unit.ff.jest.config.js --passWithNoTests" }, "dependencies": { + "@azure/msal-node": "^2.16.2", "@types/lodash": "^4.17.15", "axios": "1.7.8", "big.js": "^6.2.2", diff --git a/libs/common/src/errors/index.ts b/libs/common/src/errors/index.ts index 5e797408e7..27f3d325f2 100644 --- a/libs/common/src/errors/index.ts +++ b/libs/common/src/errors/index.ts @@ -18,5 +18,6 @@ export * from './user-session.error'; export * from './user-session-not-defined.error'; export * from './user-token-not-defined.error'; export * from './multiple-users-found.error'; +export * from './user-partial-login-data-not-defined.error'; export * from './amendment-not-found.error'; export * from './eligibility-criteria-not-found.error'; diff --git a/libs/common/src/errors/user-partial-login-data-not-defined.error.test.ts b/libs/common/src/errors/user-partial-login-data-not-defined.error.test.ts new file mode 100644 index 0000000000..40504a7ae1 --- /dev/null +++ b/libs/common/src/errors/user-partial-login-data-not-defined.error.test.ts @@ -0,0 +1,62 @@ +import { HttpStatusCode } from 'axios'; +import { ApiError } from './api.error'; +import { UserSessionError } from './user-session.error'; +import { UserPartialLoginDataNotDefinedError } from './user-partial-login-data-not-defined.error'; + +describe('UserPartialLoginDataNotDefinedError', () => { + it('should expose the message the error was created with', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception.message).toEqual('Expected session.loginData to be defined'); + }); + + it('should expose the 401 (Unauthorised) status code', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception.status).toEqual(HttpStatusCode.Unauthorized); + }); + + it('should expose the INVALID_USER_SESSION code', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception.code).toEqual('INVALID_USER_SESSION'); + }); + + it('should be an instance of UserPartialLoginDataNotDefinedError', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception).toBeInstanceOf(UserPartialLoginDataNotDefinedError); + }); + + it('should be an instance of UserSessionError', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception).toBeInstanceOf(UserSessionError); + }); + + it('should be an instance of ApiError', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception).toBeInstanceOf(ApiError); + }); + + it('should expose the name of the exception', () => { + // Act + const exception = new UserPartialLoginDataNotDefinedError(); + + // Assert + expect(exception.name).toEqual('UserPartialLoginDataNotDefinedError'); + }); +}); diff --git a/libs/common/src/errors/user-partial-login-data-not-defined.error.ts b/libs/common/src/errors/user-partial-login-data-not-defined.error.ts new file mode 100644 index 0000000000..a1dd4dfc5b --- /dev/null +++ b/libs/common/src/errors/user-partial-login-data-not-defined.error.ts @@ -0,0 +1,16 @@ +import { HttpStatusCode } from 'axios'; +import { UserSessionError } from './user-session.error'; + +/** + * Error to use when a partially logged in user's session does not contain the expected data + */ +export class UserPartialLoginDataNotDefinedError extends UserSessionError { + constructor() { + super({ + status: HttpStatusCode.Unauthorized, + message: 'Expected session.loginData to be defined', + }); + + this.name = this.constructor.name; + } +} diff --git a/libs/common/src/schemas/audit-database-record.ts b/libs/common/src/schemas/audit-database-record.ts index 925e444e0d..2894884704 100644 --- a/libs/common/src/schemas/audit-database-record.ts +++ b/libs/common/src/schemas/audit-database-record.ts @@ -1,12 +1,12 @@ import z from 'zod'; import { ISO_DATE_TIME_STAMP } from './iso-date-time-stamp'; -import { OBJECT_ID } from './object-id'; +import { OBJECT_ID_OR_OBJECT_ID_STRING } from './object-id'; export const AUDIT_DATABASE_RECORD = z .object({ lastUpdatedAt: ISO_DATE_TIME_STAMP, - lastUpdatedByPortalUserId: OBJECT_ID.nullable(), - lastUpdatedByTfmUserId: OBJECT_ID.nullable(), + lastUpdatedByPortalUserId: OBJECT_ID_OR_OBJECT_ID_STRING.nullable(), + lastUpdatedByTfmUserId: OBJECT_ID_OR_OBJECT_ID_STRING.nullable(), lastUpdatedByIsSystem: z.boolean().nullable(), noUserLoggedIn: z.boolean().nullable(), }) diff --git a/libs/common/src/schemas/index.ts b/libs/common/src/schemas/index.ts index 6f7a3c6201..cebc45b9c7 100644 --- a/libs/common/src/schemas/index.ts +++ b/libs/common/src/schemas/index.ts @@ -1,6 +1,6 @@ export * as PORTAL_USER from './portal-user'; export * as ISO_DATE_TIME_STAMP from './iso-date-time-stamp'; -export { OBJECT_ID } from './object-id'; +export * from './object-id'; export * from './deal-cancellation'; export * from './tfm'; export * from './unix-timestamp.schema'; diff --git a/libs/common/src/schemas/object-id.test.ts b/libs/common/src/schemas/object-id.test.ts index 63182d35fc..f325e00d1c 100644 --- a/libs/common/src/schemas/object-id.test.ts +++ b/libs/common/src/schemas/object-id.test.ts @@ -1,23 +1,72 @@ import { ObjectId } from 'mongodb'; -import { OBJECT_ID } from './object-id'; +import { OBJECT_ID, OBJECT_ID_OR_OBJECT_ID_STRING, OBJECT_ID_STRING } from './object-id'; import { withSchemaTests } from '../test-helpers'; describe('OBJECT_ID', () => { withSchemaTests({ - successTestCases: getSuccessTestCases(), - failureTestCases: getFailureTestCases(), + successTestCases: getObjectIdSuccessTestCases(), + failureTestCases: getObjectIdSharedFailureTestCases(), schema: OBJECT_ID, }); + + it('should transform a valid string ObjectId to an ObjectId', () => { + const stringObjectId = new ObjectId().toString(); + + const result = OBJECT_ID.parse(stringObjectId); + + expect(result).toEqual(new ObjectId(stringObjectId)); + }); }); -function getSuccessTestCases() { - return [ - { description: 'a valid ObjectId', aTestCase: () => new ObjectId() }, - { description: 'a valid string ObjectId', aTestCase: () => '075bcd157dcb851180e02a7c' }, - ]; +describe('OBJECT_ID_STRING', () => { + withSchemaTests({ + successTestCases: getObjectIdStringSuccessTestCases(), + failureTestCases: getObjectIdSharedFailureTestCases(), + schema: OBJECT_ID_STRING, + }); + + it('should transform a valid ObjectId to a string', () => { + const objectId = new ObjectId(); + + const result = OBJECT_ID_STRING.parse(objectId); + + expect(result).toEqual(objectId.toString()); + }); +}); + +describe('OBJECT_ID_OR_OBJECT_ID_STRING', () => { + withSchemaTests({ + successTestCases: [...getObjectIdSuccessTestCases(), ...getObjectIdStringSuccessTestCases()], + failureTestCases: getObjectIdSharedFailureTestCases(), + schema: OBJECT_ID_OR_OBJECT_ID_STRING, + }); + + it('should not transform a valid ObjectId to a string', () => { + const objectId = new ObjectId(); + + const result = OBJECT_ID_OR_OBJECT_ID_STRING.parse(objectId); + + expect(result).toEqual(objectId); + }); + + it('should not transform a valid string ObjectId to an ObjectId', () => { + const stringObjectId = new ObjectId().toString(); + + const result = OBJECT_ID_OR_OBJECT_ID_STRING.parse(stringObjectId); + + expect(result).toEqual(stringObjectId); + }); +}); + +function getObjectIdSuccessTestCases() { + return [{ description: 'a valid ObjectId', aTestCase: () => new ObjectId() }]; +} + +function getObjectIdStringSuccessTestCases() { + return [{ description: 'a valid string ObjectId', aTestCase: () => '075bcd157dcb851180e02a7c' }]; } -function getFailureTestCases() { +function getObjectIdSharedFailureTestCases() { return [ { description: 'a string', aTestCase: () => 'string' }, { description: 'an object', aTestCase: () => ({ An: 'object' }) }, diff --git a/libs/common/src/schemas/object-id.ts b/libs/common/src/schemas/object-id.ts index 89d2ca9c0c..3185ccdc59 100644 --- a/libs/common/src/schemas/object-id.ts +++ b/libs/common/src/schemas/object-id.ts @@ -1,4 +1,28 @@ import { ObjectId } from 'mongodb'; import z from 'zod'; -export const OBJECT_ID = z.union([z.instanceof(ObjectId), z.string().refine((id) => ObjectId.isValid(id))]); +/** + * A zod schema that represents a valid ObjectId as an ObjectId object + * This schema also transforms any valid string into an ObjectId object + */ +export const OBJECT_ID = z.union([ + z.instanceof(ObjectId), + z + .string() + .refine((id) => ObjectId.isValid(id)) + .transform((id) => new ObjectId(id)), +]); + +/** + * A zod schema that represents a valid ObjectId as a string + * This schema also transforms any valid ObjectId object into a string + */ +export const OBJECT_ID_STRING = z.union([z.string().refine((id) => ObjectId.isValid(id)), z.instanceof(ObjectId).transform((id) => id.toString())]); + +/** + * A zod schema that represents a valid ObjectId as an ObjectId object or a string + * This schema does not do any transformation, only validates. + * This is because we check to see if the value is an ObjectId prior to applying the OBJECT_ID_STRING + * schema, and zod union returns the first valid schema + */ +export const OBJECT_ID_OR_OBJECT_ID_STRING = z.union([z.instanceof(ObjectId), OBJECT_ID_STRING]); diff --git a/trade-finance-manager-ui/server/schemas/entra-id.schema.decoded-auth-code-request-state-schema.test.ts b/libs/common/src/schemas/tfm/entra-id.schema.decoded-auth-code-request-state-schema.test.ts similarity index 92% rename from trade-finance-manager-ui/server/schemas/entra-id.schema.decoded-auth-code-request-state-schema.test.ts rename to libs/common/src/schemas/tfm/entra-id.schema.decoded-auth-code-request-state-schema.test.ts index c704a83498..979c306c00 100644 --- a/trade-finance-manager-ui/server/schemas/entra-id.schema.decoded-auth-code-request-state-schema.test.ts +++ b/libs/common/src/schemas/tfm/entra-id.schema.decoded-auth-code-request-state-schema.test.ts @@ -1,5 +1,5 @@ -import { withSchemaTests } from '@ukef/dtfs2-common'; -import { DecodedAuthCodeRequestState } from '../types/entra-id'; +import { withSchemaTests } from '../../test-helpers'; +import { DecodedAuthCodeRequestState } from '../../types/tfm/entra-id'; import { DECODED_AUTH_CODE_REQUEST_STATE_SCHEMA } from './entra-id.schema'; describe('DECODED_AUTH_CODE_REQUEST_STATE_SCHEMA', () => { diff --git a/trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts b/libs/common/src/schemas/tfm/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts similarity index 94% rename from trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts rename to libs/common/src/schemas/tfm/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts index 828cb78fa3..547720e6d2 100644 --- a/trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts +++ b/libs/common/src/schemas/tfm/entra-id.schema.entra-id-auth-code-redirect-response-body-schema.test.ts @@ -1,5 +1,5 @@ -import { withSchemaTests } from '@ukef/dtfs2-common'; -import { EntraIdAuthCodeRedirectResponseBody } from '../types/entra-id'; +import { withSchemaTests } from '../../test-helpers'; +import { EntraIdAuthCodeRedirectResponseBody } from '../../types/tfm/entra-id'; import { ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA } from './entra-id.schema'; describe('ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA', () => { diff --git a/trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-authentication-result-schema.test.ts b/libs/common/src/schemas/tfm/entra-id.schema.entra-id-authentication-result-schema.test.ts similarity index 94% rename from trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-authentication-result-schema.test.ts rename to libs/common/src/schemas/tfm/entra-id.schema.entra-id-authentication-result-schema.test.ts index 43d729b605..9f714159db 100644 --- a/trade-finance-manager-ui/server/schemas/entra-id.schema.entra-id-authentication-result-schema.test.ts +++ b/libs/common/src/schemas/tfm/entra-id.schema.entra-id-authentication-result-schema.test.ts @@ -1,5 +1,5 @@ -import { anEntraIdUser, withEntraIdUserSchemaTests, withSchemaTests } from '@ukef/dtfs2-common'; -import { EntraIdAuthenticationResult } from '../types/entra-id'; +import { anEntraIdUser, withEntraIdUserSchemaTests, withSchemaTests } from '../../test-helpers'; +import { EntraIdAuthenticationResult } from '../../types/tfm/entra-id'; import { ENTRA_ID_AUTHENTICATION_RESULT_SCHEMA } from './entra-id.schema'; describe('ENTRA_ID_AUTHENTICATION_RESULT_SCHEMA', () => { diff --git a/trade-finance-manager-ui/server/schemas/entra-id.schema.ts b/libs/common/src/schemas/tfm/entra-id.schema.ts similarity index 91% rename from trade-finance-manager-ui/server/schemas/entra-id.schema.ts rename to libs/common/src/schemas/tfm/entra-id.schema.ts index 8b98d2f15d..2600db3502 100644 --- a/trade-finance-manager-ui/server/schemas/entra-id.schema.ts +++ b/libs/common/src/schemas/tfm/entra-id.schema.ts @@ -1,5 +1,5 @@ -import { ENTRA_ID_USER_SCHEMA } from '@ukef/dtfs2-common/schemas'; import { z } from 'zod'; +import { ENTRA_ID_USER_SCHEMA } from './entra-id-user.schema'; export const DECODED_AUTH_CODE_REQUEST_STATE_SCHEMA = z.object({ csrfToken: z.string(), diff --git a/libs/common/src/schemas/tfm/index.ts b/libs/common/src/schemas/tfm/index.ts index 841b938451..81e059d639 100644 --- a/libs/common/src/schemas/tfm/index.ts +++ b/libs/common/src/schemas/tfm/index.ts @@ -1,6 +1,7 @@ export * from './entra-id-user.schema'; +export * from './entra-id-user-to-upsert-tfm-user-request.schema'; +export * from './entra-id.schema'; export * from './create-tfm-user-request.schema'; export * from './update-tfm-user-request.schema'; export * from './upsert-tfm-user-request.schema'; -export * from './entra-id-user-to-upsert-tfm-user-request.schema'; export * from './tfm-team.schema'; diff --git a/libs/common/src/test-helpers/index.ts b/libs/common/src/test-helpers/index.ts index 78b1be18c0..ce7b8d76fa 100644 --- a/libs/common/src/test-helpers/index.ts +++ b/libs/common/src/test-helpers/index.ts @@ -6,5 +6,6 @@ export * from './portal-session-bank'; export * from './test-cases-backend'; export * from './schemas'; export * from './convert-milliseconds-to-seconds'; +export * from './mock-builders'; export * from './fee-record-correction-review-information'; export * from './record-correction-form-values'; diff --git a/libs/common/src/test-helpers/mock-builders/index.ts b/libs/common/src/test-helpers/mock-builders/index.ts new file mode 100644 index 0000000000..3bf4521b97 --- /dev/null +++ b/libs/common/src/test-helpers/mock-builders/index.ts @@ -0,0 +1 @@ +export * from './mock-builder.mock.builder'; diff --git a/trade-finance-manager-ui/test-helpers/mocks/mock-builder.mock.builder.ts b/libs/common/src/test-helpers/mock-builders/mock-builder.mock.builder.ts similarity index 100% rename from trade-finance-manager-ui/test-helpers/mocks/mock-builder.mock.builder.ts rename to libs/common/src/test-helpers/mock-builders/mock-builder.mock.builder.ts diff --git a/libs/common/src/test-helpers/mock-data/get-auth-code-url-params.ts b/libs/common/src/test-helpers/mock-data/get-auth-code-url-params.ts new file mode 100644 index 0000000000..c070f0176b --- /dev/null +++ b/libs/common/src/test-helpers/mock-data/get-auth-code-url-params.ts @@ -0,0 +1,5 @@ +import { GetAuthCodeUrlParams } from '../../types'; + +export const aGetAuthCodeUrlParams = (): GetAuthCodeUrlParams => ({ + successRedirect: 'an-example-redirect-url', +}); diff --git a/libs/common/src/test-helpers/mock-data/get-auth-code-url-response.ts b/libs/common/src/test-helpers/mock-data/get-auth-code-url-response.ts new file mode 100644 index 0000000000..4fb8d993dd --- /dev/null +++ b/libs/common/src/test-helpers/mock-data/get-auth-code-url-response.ts @@ -0,0 +1,9 @@ +import { GetAuthCodeUrlResponse } from '../../types'; + +export const aGetAuthCodeUrlResponse = (): GetAuthCodeUrlResponse => ({ + authCodeUrl: 'https://auth-code-url', + authCodeUrlRequest: { + scopes: ['user.read'], + redirectUri: 'https://redirect-uri', + }, +}); diff --git a/libs/common/src/test-helpers/mock-data/index.ts b/libs/common/src/test-helpers/mock-data/index.ts index 2657373607..5d092ce703 100644 --- a/libs/common/src/test-helpers/mock-data/index.ts +++ b/libs/common/src/test-helpers/mock-data/index.ts @@ -13,6 +13,8 @@ export * from './create-tfm-user-request'; export * from './upsert-tfm-user-request'; export * from './record-correction-mock'; export * from './fee-record-correction-request-transient-form-data.entity.mock-builder'; +export * from './get-auth-code-url-response'; +export * from './get-auth-code-url-params'; export * from './fee-record-correction-transient-form-data.entity.mock-builder'; export * from './fee-record-correction-request-review-response-body-mock'; export * from './record-correction-values'; diff --git a/trade-finance-manager-ui/server/types/entra-id.ts b/libs/common/src/types/tfm/entra-id.ts similarity index 64% rename from trade-finance-manager-ui/server/types/entra-id.ts rename to libs/common/src/types/tfm/entra-id.ts index a472571ac4..f8f7f8fc89 100644 --- a/trade-finance-manager-ui/server/types/entra-id.ts +++ b/libs/common/src/types/tfm/entra-id.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; -import { DECODED_AUTH_CODE_REQUEST_STATE_SCHEMA, ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA, ENTRA_ID_AUTHENTICATION_RESULT_SCHEMA } from '../schemas'; +import { + DECODED_AUTH_CODE_REQUEST_STATE_SCHEMA, + ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA, + ENTRA_ID_AUTHENTICATION_RESULT_SCHEMA, +} from '../../schemas/tfm/entra-id.schema'; export type DecodedAuthCodeRequestState = z.infer; diff --git a/libs/common/src/types/tfm/get-auth-code.ts b/libs/common/src/types/tfm/get-auth-code.ts new file mode 100644 index 0000000000..091458a281 --- /dev/null +++ b/libs/common/src/types/tfm/get-auth-code.ts @@ -0,0 +1,16 @@ +import { AuthorizationUrlRequest } from '@azure/msal-node'; +import { Response } from 'express'; +import { CustomExpressRequest } from '../express-custom-request'; + +export type GetAuthCodeUrlParams = { + successRedirect: string; +}; + +export type GetAuthCodeUrlResponse = { + authCodeUrl: string; + authCodeUrlRequest: AuthorizationUrlRequest; +}; + +export type GetAuthCodeUrlApiRequest = CustomExpressRequest<{ params: GetAuthCodeUrlParams }>; + +export type GetAuthCodeUrlApiResponse = Response; diff --git a/libs/common/src/types/tfm/index.ts b/libs/common/src/types/tfm/index.ts index b7c66167fd..a4eede05ba 100644 --- a/libs/common/src/types/tfm/index.ts +++ b/libs/common/src/types/tfm/index.ts @@ -3,6 +3,8 @@ export * from './facility-stage'; export * from './team-id'; export * from './team'; export * from './deal-cancellation-status'; +export * from './get-auth-code'; +export * from './entra-id'; export * from './entra-id-user'; export * from './create-tfm-user-request'; export * from './update-tfm-user-request'; diff --git a/package-lock.json b/package-lock.json index 9f2bed84e2..951ae8823a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -875,6 +875,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@azure/msal-node": "^2.16.2", "@types/lodash": "^4.17.15", "axios": "1.7.8", "big.js": "^6.2.2", @@ -28024,6 +28025,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@azure/msal-node": "^2.16.2", "@azure/storage-file-share": "12.14.0", "@babel/plugin-transform-runtime": "7.23.6", "@babel/preset-env": "7.23.6", @@ -28375,7 +28377,6 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@azure/msal-node": "^2.16.2", "@babel/polyfill": "^7.12.1", "@ministryofjustice/frontend": "3.0.2", "@ukef/dtfs2-common": "1.0.0", diff --git a/portal-api/.eslintrc.js b/portal-api/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/portal-api/.eslintrc.js +++ b/portal-api/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/portal/.eslintrc.js b/portal/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/portal/.eslintrc.js +++ b/portal/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/trade-finance-manager-api/.eslintrc.js b/trade-finance-manager-api/.eslintrc.js index b8fef1fdb4..0037a16cae 100644 --- a/trade-finance-manager-api/.eslintrc.js +++ b/trade-finance-manager-api/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/trade-finance-manager-api/package.json b/trade-finance-manager-api/package.json index ffba376549..cb476a078e 100644 --- a/trade-finance-manager-api/package.json +++ b/trade-finance-manager-api/package.json @@ -32,6 +32,7 @@ "unit-test-ff": "jest --coverage --verbose --config=unit.ff.jest.config.js --passWithNoTests" }, "dependencies": { + "@azure/msal-node": "^2.16.2", "@azure/storage-file-share": "12.14.0", "@babel/plugin-transform-runtime": "7.23.6", "@babel/preset-env": "7.23.6", diff --git a/trade-finance-manager-ui/test-helpers/mocks/entra-id.api.mock.builder.ts b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.api.mock.builder.ts similarity index 65% rename from trade-finance-manager-ui/test-helpers/mocks/entra-id.api.mock.builder.ts rename to trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.api.mock.builder.ts index 4c5fbff34c..d058a8d100 100644 --- a/trade-finance-manager-ui/test-helpers/mocks/entra-id.api.mock.builder.ts +++ b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.api.mock.builder.ts @@ -1,5 +1,5 @@ -import { EntraIdApi } from '../../server/third-party-apis/entra-id.api'; -import { BaseMockBuilder } from './mock-builder.mock.builder'; +import { BaseMockBuilder } from '@ukef/dtfs2-common'; +import { EntraIdApi } from '../../third-party-apis/entra-id.api'; export class EntraIdApiMockBuilder extends BaseMockBuilder { constructor() { diff --git a/trade-finance-manager-ui/test-helpers/mocks/entra-id.config.mock.builder.ts b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.config.mock.builder.ts similarity index 74% rename from trade-finance-manager-ui/test-helpers/mocks/entra-id.config.mock.builder.ts rename to trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.config.mock.builder.ts index af8cc333af..7e2deb6b94 100644 --- a/trade-finance-manager-ui/test-helpers/mocks/entra-id.config.mock.builder.ts +++ b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.config.mock.builder.ts @@ -1,5 +1,5 @@ -import { EntraIdConfig } from '../../server/configs/entra-id.config'; -import { BaseMockBuilder } from './mock-builder.mock.builder'; +import { BaseMockBuilder } from '@ukef/dtfs2-common'; +import { EntraIdConfig } from '../../configs/entra-id.config'; export class EntraIdConfigMockBuilder extends BaseMockBuilder { constructor() { diff --git a/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.service.mock.builder.ts b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.service.mock.builder.ts new file mode 100644 index 0000000000..b08749fece --- /dev/null +++ b/trade-finance-manager-api/src/v1/__mocks__/builders/entra-id.service.mock.builder.ts @@ -0,0 +1,14 @@ +import { aGetAuthCodeUrlResponse, BaseMockBuilder } from '@ukef/dtfs2-common'; +import { EntraIdService } from '../../services/entra-id.service'; + +export class EntraIdServiceMockBuilder extends BaseMockBuilder { + constructor() { + super({ + defaultInstance: { + getAuthCodeUrl: jest.fn(async () => { + return Promise.resolve(aGetAuthCodeUrlResponse()); + }), + }, + }); + } +} diff --git a/trade-finance-manager-api/src/v1/__mocks__/builders/index.ts b/trade-finance-manager-api/src/v1/__mocks__/builders/index.ts new file mode 100644 index 0000000000..cdfb146bf5 --- /dev/null +++ b/trade-finance-manager-api/src/v1/__mocks__/builders/index.ts @@ -0,0 +1,3 @@ +export * from './entra-id.api.mock.builder'; +export * from './entra-id.config.mock.builder'; +export * from './entra-id.service.mock.builder'; diff --git a/trade-finance-manager-ui/server/configs/entra-id.config.test.ts b/trade-finance-manager-api/src/v1/configs/entra-id.config.test.ts similarity index 100% rename from trade-finance-manager-ui/server/configs/entra-id.config.test.ts rename to trade-finance-manager-api/src/v1/configs/entra-id.config.test.ts diff --git a/trade-finance-manager-ui/server/configs/entra-id.config.ts b/trade-finance-manager-api/src/v1/configs/entra-id.config.ts similarity index 99% rename from trade-finance-manager-ui/server/configs/entra-id.config.ts rename to trade-finance-manager-api/src/v1/configs/entra-id.config.ts index ffd16090b6..03053001c1 100644 --- a/trade-finance-manager-ui/server/configs/entra-id.config.ts +++ b/trade-finance-manager-api/src/v1/configs/entra-id.config.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import dotenv from 'dotenv'; dotenv.config(); + export class EntraIdConfig { private static readonly entraIdEnvVarConfigSchema = z.object({ ENTRA_ID_CLIENT_ID: z.string(), diff --git a/trade-finance-manager-api/src/v1/controllers/sso.controller.get-auth-code-url.test.ts b/trade-finance-manager-api/src/v1/controllers/sso.controller.get-auth-code-url.test.ts new file mode 100644 index 0000000000..1a94170454 --- /dev/null +++ b/trade-finance-manager-api/src/v1/controllers/sso.controller.get-auth-code-url.test.ts @@ -0,0 +1,80 @@ +import { aGetAuthCodeUrlParams, aGetAuthCodeUrlResponse, GetAuthCodeUrlApiRequest, GetAuthCodeUrlApiResponse } from '@ukef/dtfs2-common'; +import { resetAllWhenMocks } from 'jest-when'; +import httpMocks from 'node-mocks-http'; +import { SsoController } from './sso.controller'; +import { EntraIdService } from '../services/entra-id.service'; +import { EntraIdServiceMockBuilder } from '../__mocks__/builders'; + +describe('SsoController', () => { + let ssoController: SsoController; + let entraIdService: EntraIdService; + + console.error = jest.fn(); + + const getAuthCodeUrlMock = jest.fn(); + + beforeEach(() => { + resetAllWhenMocks(); + jest.resetAllMocks(); + + entraIdService = new EntraIdServiceMockBuilder() + .with({ + getAuthCodeUrl: getAuthCodeUrlMock, + }) + .build(); + + ssoController = new SsoController({ entraIdService }); + }); + + it('should call getAuthCodeUrl with the correct params', async () => { + const getAuthCodeUrlParmas = aGetAuthCodeUrlParams(); + const { req, res } = getHttpMocks(getAuthCodeUrlParmas); + + await ssoController.getAuthCodeUrl(req, res); + + expect(getAuthCodeUrlMock).toHaveBeenCalledWith(getAuthCodeUrlParmas); + expect(getAuthCodeUrlMock).toHaveBeenCalledTimes(1); + }); + + it('should return auth code URL on success', async () => { + const { req, res } = getHttpMocks(aGetAuthCodeUrlParams()); + + const getAuthCodeUrlResponse = aGetAuthCodeUrlResponse(); + getAuthCodeUrlMock.mockResolvedValue(getAuthCodeUrlResponse); + + await ssoController.getAuthCodeUrl(req, res); + + expect(res._getJSONData()).toEqual(getAuthCodeUrlResponse); + }); + + it('should pass through thrown errors', async () => { + const getAuthCodeUrlParmas = aGetAuthCodeUrlParams(); + const { req, res } = getHttpMocks(getAuthCodeUrlParmas); + + const error = new Error('Test error'); + getAuthCodeUrlMock.mockRejectedValue(error); + + await expect(ssoController.getAuthCodeUrl(req, res)).rejects.toThrow(error); + }); + + it('should call console.error on error', async () => { + const getAuthCodeUrlParmas = aGetAuthCodeUrlParams(); + const { req, res } = getHttpMocks(getAuthCodeUrlParmas); + + const error = new Error('Test error'); + getAuthCodeUrlMock.mockRejectedValue(error); + + await ssoController.getAuthCodeUrl(req, res).catch(() => {}); + + expect(console.error).toHaveBeenCalledWith('An error occurred while getting the auth code URL:', error); + }); + + function getHttpMocks(params: GetAuthCodeUrlApiRequest['params']): { + req: httpMocks.MockRequest; + res: httpMocks.MockResponse; + } { + return httpMocks.createMocks({ + params, + }); + } +}); diff --git a/trade-finance-manager-api/src/v1/controllers/sso.controller.ts b/trade-finance-manager-api/src/v1/controllers/sso.controller.ts new file mode 100644 index 0000000000..dedd5ec98f --- /dev/null +++ b/trade-finance-manager-api/src/v1/controllers/sso.controller.ts @@ -0,0 +1,20 @@ +import { GetAuthCodeUrlApiRequest, GetAuthCodeUrlApiResponse } from '@ukef/dtfs2-common'; +import { EntraIdService } from '../services/entra-id.service'; + +export class SsoController { + private readonly entraIdService: EntraIdService; + + constructor({ entraIdService }: { entraIdService: EntraIdService }) { + this.entraIdService = entraIdService; + } + + async getAuthCodeUrl(req: GetAuthCodeUrlApiRequest, res: GetAuthCodeUrlApiResponse) { + try { + const getAuthCodeUrlResponse = await this.entraIdService.getAuthCodeUrl(req.params); + res.json(getAuthCodeUrlResponse); + } catch (error) { + console.error('An error occurred while getting the auth code URL:', error); + throw error; + } + } +} diff --git a/trade-finance-manager-api/src/v1/routes.js b/trade-finance-manager-api/src/v1/routes.js index 3badd0b281..7a216c8953 100644 --- a/trade-finance-manager-api/src/v1/routes.js +++ b/trade-finance-manager-api/src/v1/routes.js @@ -24,8 +24,12 @@ const checkApiKey = require('./middleware/headers/check-api-key'); const { teamsRoutes } = require('./teams/routes'); const { dealsOpenRouter, dealsAuthRouter } = require('./deals/routes'); const { tasksRouter } = require('./tasks/routes'); +const { ssoOpenRouter } = require('./sso/routes'); openRouter.use(checkApiKey); + +openRouter.use('/sso', ssoOpenRouter); + authRouter.use(passport.authenticate('jwt', { session: false })); authRouter.route('/api-docs').get(swaggerUi.setup(swaggerSpec, swaggerUiOptions)); diff --git a/trade-finance-manager-ui/server/services/entra-id.service.get-auth-code-url.test.ts b/trade-finance-manager-api/src/v1/services/entra-id.service.get-auth-code-url.test.ts similarity index 90% rename from trade-finance-manager-ui/server/services/entra-id.service.get-auth-code-url.test.ts rename to trade-finance-manager-api/src/v1/services/entra-id.service.get-auth-code-url.test.ts index aed8711a84..2ba1ba7019 100644 --- a/trade-finance-manager-ui/server/services/entra-id.service.get-auth-code-url.test.ts +++ b/trade-finance-manager-api/src/v1/services/entra-id.service.get-auth-code-url.test.ts @@ -1,8 +1,8 @@ import { ConfidentialClientApplication, CryptoProvider } from '@azure/msal-node'; -import { EntraIdConfigMockBuilder, EntraIdApiMockBuilder } from '../../test-helpers/mocks'; import { EntraIdConfig } from '../configs/entra-id.config'; import { EntraIdService } from './entra-id.service'; import { EntraIdApi } from '../third-party-apis/entra-id.api'; +import { EntraIdApiMockBuilder, EntraIdConfigMockBuilder } from '../__mocks__/builders'; jest.mock('@azure/msal-node', () => { return { @@ -46,7 +46,7 @@ describe('EntraIdService', () => { entraIdApi = new EntraIdApiMockBuilder().withDefaults().build(); }); - it('calls base64Encode with the expected stringifiedstate', async () => { + it('should call base64Encode with the expected stringifiedstate', async () => { // Arrange const successRedirect = 'a-success-redirect'; const service = getAEntraIdServiceInstance(); @@ -58,7 +58,7 @@ describe('EntraIdService', () => { expect(base64EncodeSpy).toHaveBeenCalledWith(JSON.stringify({ csrfToken: mockGuid, successRedirect })); }); - it('returns the auth code url from the msal app', async () => { + it('should return the auth code url from the msal app', async () => { // Arrange const service = getAEntraIdServiceInstance(); @@ -69,7 +69,7 @@ describe('EntraIdService', () => { expect(authCodeUrl).toEqual(authCodeUrlFromMsalApp); }); - it('returns the auth code url request', async () => { + it('should return the auth code url request', async () => { // Arrange const service = getAEntraIdServiceInstance(); diff --git a/trade-finance-manager-ui/server/services/entra-id.service.ts b/trade-finance-manager-api/src/v1/services/entra-id.service.ts similarity index 97% rename from trade-finance-manager-ui/server/services/entra-id.service.ts rename to trade-finance-manager-api/src/v1/services/entra-id.service.ts index 5f140f445e..07912ee6a4 100644 --- a/trade-finance-manager-ui/server/services/entra-id.service.ts +++ b/trade-finance-manager-api/src/v1/services/entra-id.service.ts @@ -1,5 +1,5 @@ import { AuthorizationUrlRequest, ConfidentialClientApplication, Configuration as MsalAppConfig, CryptoProvider } from '@azure/msal-node'; -import { DecodedAuthCodeRequestState } from '../types/entra-id'; +import { DecodedAuthCodeRequestState } from '@ukef/dtfs2-common'; import { EntraIdConfig } from '../configs/entra-id.config'; import { EntraIdApi } from '../third-party-apis/entra-id.api'; diff --git a/trade-finance-manager-api/src/v1/sso/routes.ts b/trade-finance-manager-api/src/v1/sso/routes.ts new file mode 100644 index 0000000000..ac5aaaab8b --- /dev/null +++ b/trade-finance-manager-api/src/v1/sso/routes.ts @@ -0,0 +1,17 @@ +import express from 'express'; +import { GetAuthCodeUrlApiRequest, GetAuthCodeUrlApiResponse } from '@ukef/dtfs2-common'; +import { SsoController } from '../controllers/sso.controller'; +import { EntraIdService } from '../services/entra-id.service'; +import { EntraIdApi } from '../third-party-apis/entra-id.api'; +import { EntraIdConfig } from '../configs/entra-id.config'; + +export const ssoOpenRouter = express.Router(); + +const entraIdConfig = new EntraIdConfig(); +const entraIdApi = new EntraIdApi({ entraIdConfig }); +const entraIdService = new EntraIdService({ entraIdConfig, entraIdApi }); +const ssoController = new SsoController({ entraIdService }); + +ssoOpenRouter.route('/auth-code-url').get((req: GetAuthCodeUrlApiRequest, res: GetAuthCodeUrlApiResponse, next) => { + ssoController.getAuthCodeUrl(req, res).catch(next); +}); diff --git a/trade-finance-manager-ui/server/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts b/trade-finance-manager-api/src/v1/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts similarity index 87% rename from trade-finance-manager-ui/server/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts rename to trade-finance-manager-api/src/v1/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts index 849676f3fa..1c5ee64811 100644 --- a/trade-finance-manager-ui/server/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts +++ b/trade-finance-manager-api/src/v1/third-party-apis/entra-id.api.get-authority-metadata-url.test.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import MockAdapter = require('axios-mock-adapter'); -import { EntraIdConfig } from '../configs/entra-id.config'; import { EntraIdApi } from './entra-id.api'; -import { EntraIdConfigMockBuilder } from '../../test-helpers/mocks'; +import { EntraIdConfig } from '../configs/entra-id.config'; +import { EntraIdConfigMockBuilder } from '../__mocks__/builders'; const mockAxios = new MockAdapter(axios); @@ -24,7 +24,7 @@ describe('EntraIdApi', () => { mockAxios.onGet(authorityMetadataUrl).reply(200, { aMetaDataObject: 'a-meta-data-value' }); }); - it('returns the response data from the api call', async () => { + it('should return the response data from the api call', async () => { // Act const result = entraIdApi.getAuthorityMetadataUrl(); diff --git a/trade-finance-manager-ui/server/third-party-apis/entra-id.api.ts b/trade-finance-manager-api/src/v1/third-party-apis/entra-id.api.ts similarity index 100% rename from trade-finance-manager-ui/server/third-party-apis/entra-id.api.ts rename to trade-finance-manager-api/src/v1/third-party-apis/entra-id.api.ts diff --git a/trade-finance-manager-ui/.eslintrc.js b/trade-finance-manager-ui/.eslintrc.js index 9b502a3a0a..8e487a5d10 100644 --- a/trade-finance-manager-ui/.eslintrc.js +++ b/trade-finance-manager-ui/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off', diff --git a/trade-finance-manager-ui/package.json b/trade-finance-manager-ui/package.json index 64131c73ff..cfcff96458 100644 --- a/trade-finance-manager-ui/package.json +++ b/trade-finance-manager-ui/package.json @@ -34,7 +34,6 @@ "unit-test-ff": "jest --coverage --verbose --config=unit.ff.jest.config.js --passWithNoTests" }, "dependencies": { - "@azure/msal-node": "^2.16.2", "@babel/polyfill": "^7.12.1", "@ministryofjustice/frontend": "3.0.2", "@ukef/dtfs2-common": "1.0.0", diff --git a/trade-finance-manager-ui/server/api.js b/trade-finance-manager-ui/server/api.js index b8b93df96f..96740ebc29 100644 --- a/trade-finance-manager-ui/server/api.js +++ b/trade-finance-manager-ui/server/api.js @@ -402,6 +402,7 @@ const createActivity = async (dealId, activityUpdate, token) => { } }; +// TODO DTFS2-7772 - remove this function const login = async (username, password) => { try { const response = await axios({ @@ -420,6 +421,29 @@ const login = async (username, password) => { } }; +/** + * Gets the auth code URL for the SSO login process + * @param {import('@ukef/dtfs2-common').GetAuthCodeUrlRequest} getAuthCodeUrlParams + * @returns {Promise} + */ +const getAuthCodeUrl = async ({ successRedirect }) => { + try { + const response = await axios({ + method: 'get', + url: `${TFM_API_URL}/v1/sso/auth-code-url`, + headers: { + [HEADERS.CONTENT_TYPE.KEY]: HEADERS.CONTENT_TYPE.VALUES.JSON, + }, + params: { successRedirect }, + }); + + return response.data; + } catch (error) { + console.error('Unable to get auth code url %o', error?.response?.data); + throw error; + } +}; + const updateUserPassword = async (userId, update, token) => { try { const isValidUserId = isValidMongoId(userId); @@ -1461,6 +1485,7 @@ module.exports = { updateLeadUnderwriter, createActivity, login, + getAuthCodeUrl, getFacilities, createFeedback, updateAmendment, diff --git a/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.post-sso-redirect.test.ts b/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.post-sso-redirect.test.ts index c136be35be..1b7a18c0a9 100644 --- a/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.post-sso-redirect.test.ts +++ b/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.post-sso-redirect.test.ts @@ -1,10 +1,9 @@ import httpMocks, { MockResponse } from 'node-mocks-http'; import { resetAllWhenMocks } from 'jest-when'; import { isVerifiedPayload } from '@ukef/dtfs2-common/payload-verification'; -import { CustomExpressRequest, InvalidPayloadError } from '@ukef/dtfs2-common'; +import { CustomExpressRequest, EntraIdAuthCodeRedirectResponseBody, InvalidPayloadError } from '@ukef/dtfs2-common'; import { Response } from 'express'; import { UnauthenticatedAuthController } from './unauthenticated-auth.controller'; -import { EntraIdAuthCodeRedirectResponseBody } from '../../../types/entra-id'; jest.mock('@ukef/dtfs2-common/payload-verification', () => ({ isVerifiedPayload: jest.fn(), @@ -30,7 +29,7 @@ describe('controllers - unauthenticated auth (sso)', () => { }); describe('postSsoRedirect', () => { - it('throws an error if body validation fails', () => { + it('should throw an error if body validation fails', () => { jest.mocked(isVerifiedPayload).mockReturnValue(false); expect(() => unauthenticatedAuthController.postSsoRedirect(req, res)).toThrow('Invalid payload from SSO redirect'); diff --git a/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.ts b/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.ts index 673fa7bf96..52098e9fc0 100644 --- a/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.ts +++ b/trade-finance-manager-ui/server/controllers/auth/auth-sso/unauthenticated-auth.controller.ts @@ -1,8 +1,7 @@ import { Response } from 'express'; -import { CustomExpressRequest, InvalidPayloadError } from '@ukef/dtfs2-common'; +import { CustomExpressRequest, EntraIdAuthCodeRedirectResponseBody, InvalidPayloadError } from '@ukef/dtfs2-common'; import { isVerifiedPayload } from '@ukef/dtfs2-common/payload-verification'; -import { EntraIdAuthCodeRedirectResponseBody } from '../../../types/entra-id'; -import { ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA } from '../../../schemas'; +import { ENTRA_ID_AUTH_CODE_REDIRECT_RESPONSE_BODY_SCHEMA } from '@ukef/dtfs2-common/schemas'; export class UnauthenticatedAuthController { postSsoRedirect(req: CustomExpressRequest<{ reqBody: EntraIdAuthCodeRedirectResponseBody }>, res: Response) { diff --git a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-login.test.ts b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-login.test.ts index 5d63c68c37..050ff49d3d 100644 --- a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-login.test.ts +++ b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-login.test.ts @@ -1,28 +1,28 @@ import httpMocks from 'node-mocks-http'; import { resetAllWhenMocks, when } from 'jest-when'; +import { aGetAuthCodeUrlResponse } from '@ukef/dtfs2-common'; import { aTfmSessionUser } from '../../../../test-helpers'; import { LoginController } from './login.controller'; -import { EntraIdService } from '../../../services/entra-id.service'; -import { EntraIdServiceMockBuilder } from '../../../../test-helpers/mocks'; +import { LoginService } from '../../../services/login.service'; +import { LoginServiceMockBuilder } from '../../../../test-helpers/mocks'; describe('controllers - login (sso)', () => { describe('getLogin', () => { - const mockAuthCodeUrl = `mock-auth-code-url`; - const mockAuthCodeUrlRequest = `mock-auth-code-url-request`; + const validGetAuthCodeUrlResponse = aGetAuthCodeUrlResponse(); + const aRedirectUrl = '/a-redirect-url'; let loginController: LoginController; - let entraIdService: EntraIdService; + let loginService: LoginService; const getAuthCodeUrlMock = jest.fn(); + const next = jest.fn(); beforeEach(() => { resetAllWhenMocks(); jest.resetAllMocks(); - entraIdService = new EntraIdServiceMockBuilder().with({ getAuthCodeUrl: getAuthCodeUrlMock }).build(); + loginService = new LoginServiceMockBuilder().with({ getAuthCodeUrl: getAuthCodeUrlMock }).build(); - loginController = new LoginController({ entraIdService }); - - mockSuccessfulGetAuthCodeUrl(); + loginController = new LoginController({ loginService }); }); describe('when there is a user session', () => { @@ -36,12 +36,12 @@ describe('controllers - login (sso)', () => { session: requestSession, }); - it('redirects to /home', async () => { + it('should redirect to /home', async () => { // Arrange const { req, res } = getHttpMocks(); // Act - await loginController.getLogin(req, res); + await loginController.getLogin(req, res, next); // Assert expect(res._getRedirectUrl()).toEqual('/home'); @@ -49,35 +49,90 @@ describe('controllers - login (sso)', () => { }); describe('when there is no user session', () => { - it('redirects to login URL', async () => { - // Arrange - const { req, res } = httpMocks.createMocks({ session: {} }); + describe('when an originalUrl exists on the request', () => { + it('should call getAuthCodeUrl with the originalUrl if present', async () => { + // Arrange + const { req, res } = httpMocks.createMocks({ session: {}, originalUrl: aRedirectUrl }); - // Act - await loginController.getLogin(req, res); + // Act + await loginController.getLogin(req, res, next); - // Assert - expect(res._getRedirectUrl()).toEqual(mockAuthCodeUrl); + // Assert + expect(getAuthCodeUrlMock).toHaveBeenCalledWith({ successRedirect: aRedirectUrl }); + }); }); - it('overrides session login data if present', async () => { - // Arrange - const { req, res } = httpMocks.createMocks({ session: { loginData: { authCodeUrlRequest: 'an old auth code url request', aField: 'another field' } } }); + describe('when an originalUrl does not exist on the request', () => { + it('should call getAuthCodeUrl with "/" as the successRedirect', async () => { + // Arrange + const { req, res } = httpMocks.createMocks({ session: {} }); - req.session.loginData = { authCodeUrlRequest: 'old-auth-code-url-request' }; + // Act + await loginController.getLogin(req, res, next); - // Act - await loginController.getLogin(req, res); + // Assert + expect(getAuthCodeUrlMock).toHaveBeenCalledWith({ successRedirect: '/' }); + }); + }); - // Assert - expect(req.session.loginData).toEqual({ authCodeUrlRequest: mockAuthCodeUrlRequest }); + describe('when the getAuthCodeUrl api call is successful', () => { + beforeEach(() => { + mockSuccessfulGetAuthCodeUrl(); + }); + + it('should redirect to auth code URL', async () => { + // Arrange + const { req, res } = httpMocks.createMocks({ session: {}, originalUrl: aRedirectUrl }); + + // Act + await loginController.getLogin(req, res, next); + + // Assert + expect(res._getRedirectUrl()).toEqual(validGetAuthCodeUrlResponse.authCodeUrl); + }); + + it('should override session login data if present', async () => { + // Arrange + const { req, res } = httpMocks.createMocks({ + session: { loginData: { authCodeUrlRequest: 'an old auth code url request', aField: 'another field' } }, + originalUrl: aRedirectUrl, + }); + + // Act + await loginController.getLogin(req, res, next); + + // Assert + expect(req.session.loginData).toEqual({ authCodeUrlRequest: validGetAuthCodeUrlResponse.authCodeUrlRequest }); + }); + }); + + describe('when the api call is unsuccessful', () => { + beforeEach(() => { + mockFailedGetAuthCodeUrl(); + }); + + it('should call next with error', async () => { + // Arrange + const { req, res } = httpMocks.createMocks({ session: {}, originalUrl: aRedirectUrl }); + + const error = new Error('getAuthCodeUrl error'); + getAuthCodeUrlMock.mockRejectedValueOnce(error); + + // Act + await loginController.getLogin(req, res, next); + + // Assert + expect(next).toHaveBeenCalledWith(error); + }); }); }); function mockSuccessfulGetAuthCodeUrl() { - when(getAuthCodeUrlMock) - .calledWith({ successRedirect: '/' }) - .mockResolvedValueOnce({ authCodeUrl: mockAuthCodeUrl, authCodeUrlRequest: mockAuthCodeUrlRequest }); + when(getAuthCodeUrlMock).calledWith({ successRedirect: aRedirectUrl }).mockResolvedValueOnce(validGetAuthCodeUrlResponse); + } + + function mockFailedGetAuthCodeUrl() { + when(getAuthCodeUrlMock).calledWith({ successRedirect: aRedirectUrl }).mockRejectedValueOnce(new Error('getAuthCodeUrl error')); } }); }); diff --git a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-logout.test.ts b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-logout.test.ts index 95fe7671d4..9059e4f766 100644 --- a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-logout.test.ts +++ b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.get-logout.test.ts @@ -1,19 +1,19 @@ import httpMocks from 'node-mocks-http'; import { LoginController } from './login.controller'; -import { EntraIdService } from '../../../services/entra-id.service'; -import { EntraIdServiceMockBuilder } from '../../../../test-helpers/mocks'; +import { LoginService } from '../../../services/login.service'; +import { LoginServiceMockBuilder } from '../../../../test-helpers/mocks'; describe('controllers - login (sso)', () => { describe('getLogout', () => { - let entraIdService: EntraIdService; + let loginService: LoginService; let loginController: LoginController; beforeEach(() => { - entraIdService = new EntraIdServiceMockBuilder().build(); - loginController = new LoginController({ entraIdService }); + loginService = new LoginServiceMockBuilder().build(); + loginController = new LoginController({ loginService }); }); - it('redirects to /', () => { + it('should redirect to /', () => { // Arrange const { req, res } = getHttpMocks(); @@ -24,7 +24,7 @@ describe('controllers - login (sso)', () => { expect(res._getRedirectUrl()).toEqual('/'); }); - it('destroys the session', () => { + it('should destroy the session', () => { // Arrange const { req, res } = getHttpMocks(); diff --git a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.ts b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.ts index bc2efb7470..2fc72ac444 100644 --- a/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.ts +++ b/trade-finance-manager-ui/server/controllers/login/login-sso/login.controller.ts @@ -1,26 +1,30 @@ -import { Request, Response } from 'express'; -import { EntraIdService } from '../../../services/entra-id.service'; +import { NextFunction, Request, Response } from 'express'; +import { LoginService } from '../../../services/login.service'; export class LoginController { - private readonly entraIdService: EntraIdService; + private readonly loginService: LoginService; - constructor({ entraIdService }: { entraIdService: EntraIdService }) { - this.entraIdService = entraIdService; + constructor({ loginService }: { loginService: LoginService }) { + this.loginService = loginService; } - public async getLogin(req: Request, res: Response) { - if (req.session.user) { - // User is already logged in. - return res.redirect('/home'); - } + public async getLogin(req: Request, res: Response, next: NextFunction) { + try { + if (req.session.user) { + // User is already logged in. + return res.redirect('/home'); + } - const { authCodeUrl, authCodeUrlRequest } = await this.entraIdService.getAuthCodeUrl({ successRedirect: '/' }); + const { authCodeUrl, authCodeUrlRequest } = await this.loginService.getAuthCodeUrl({ successRedirect: req.originalUrl ? req.originalUrl : '/' }); + // As this is the user logging in, there should be no existing login data in the session. + // if there is, it should be cleared and set to the authCodeUrlRequest. - // As this is the user logging in, there should be no existing login data in the session. - // if there is, it should be cleared and set to the authCodeUrlRequest. - req.session.loginData = { authCodeUrlRequest }; + req.session.loginData = { authCodeUrlRequest }; - return res.redirect(authCodeUrl); + return res.redirect(authCodeUrl); + } catch (error) { + return next(error); + } } // TODO DTFS2-6892: Update this logout handling diff --git a/trade-finance-manager-ui/server/routes/login/configs/login-sso.ts b/trade-finance-manager-ui/server/routes/login/configs/login-sso.ts index 39d8c4980d..47d1c70a6e 100644 --- a/trade-finance-manager-ui/server/routes/login/configs/login-sso.ts +++ b/trade-finance-manager-ui/server/routes/login/configs/login-sso.ts @@ -1,18 +1,14 @@ import express from 'express'; import { LoginController } from '../../../controllers/login/login-sso/login.controller'; import { GetRouter } from '../../../types/get-router'; -import { EntraIdService } from '../../../services/entra-id.service'; -import { EntraIdConfig } from '../../../configs/entra-id.config'; -import { EntraIdApi } from '../../../third-party-apis/entra-id.api'; +import { LoginService } from '../../../services/login.service'; export const getLoginSsoRouter: GetRouter = () => { - const entraIdConfig = new EntraIdConfig(); - const entraIdApi = new EntraIdApi({ entraIdConfig }); - const entraIdService = new EntraIdService({ entraIdConfig, entraIdApi }); - const loginController = new LoginController({ entraIdService }); + const loginService = new LoginService(); + const loginController = new LoginController({ loginService }); const loginSsoRouter = express.Router(); // eslint-disable-next-line @typescript-eslint/no-misused-promises - loginSsoRouter.get('/', (req, res) => loginController.getLogin(req, res)); + loginSsoRouter.get('/', (req, res, next) => loginController.getLogin(req, res, next)); return loginSsoRouter; }; diff --git a/trade-finance-manager-ui/server/schemas/index.ts b/trade-finance-manager-ui/server/schemas/index.ts deleted file mode 100644 index 47514b893e..0000000000 --- a/trade-finance-manager-ui/server/schemas/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './entra-id.schema'; diff --git a/trade-finance-manager-ui/server/services/login.service.get-auth-code-url.test.ts b/trade-finance-manager-ui/server/services/login.service.get-auth-code-url.test.ts new file mode 100644 index 0000000000..86c35c4d55 --- /dev/null +++ b/trade-finance-manager-ui/server/services/login.service.get-auth-code-url.test.ts @@ -0,0 +1,55 @@ +import { aGetAuthCodeUrlResponse, GetAuthCodeUrlResponse } from '@ukef/dtfs2-common'; +import { LoginService } from './login.service'; +import * as api from '../api'; + +jest.mock('../api'); + +describe('login service', () => { + describe('getAuthCodeUrl', () => { + const getAuthCodeUrlSpy = jest.spyOn(api, 'getAuthCodeUrl'); + const loginService = new LoginService(); + const successRedirect = '/'; + + afterEach(() => { + getAuthCodeUrlSpy.mockReset(); + }); + + it('should call api.getAuthCodeUrl with the request', async () => { + // Act + await loginService.getAuthCodeUrl({ successRedirect }); + + // Assert + expect(getAuthCodeUrlSpy).toHaveBeenCalledTimes(1); + expect(getAuthCodeUrlSpy).toHaveBeenCalledWith({ successRedirect }); + }); + + describe('when the getAuthCodeUrl api call is successful', () => { + const mockGetAuthCodeResponse: GetAuthCodeUrlResponse = aGetAuthCodeUrlResponse(); + + beforeEach(() => { + getAuthCodeUrlSpy.mockResolvedValueOnce(mockGetAuthCodeResponse); + }); + + it('should return the auth code url', async () => { + // Act + const result = await loginService.getAuthCodeUrl({ successRedirect }); + + // Assert + expect(result).toEqual(mockGetAuthCodeResponse); + }); + }); + + describe('when the getAuthCodeUrl api call is unsuccessful', () => { + const error = new Error('getAuthCodeUrl error'); + + beforeEach(() => { + getAuthCodeUrlSpy.mockRejectedValueOnce(error); + }); + + it('should throw the error', async () => { + // Act & Assert + await expect(loginService.getAuthCodeUrl({ successRedirect })).rejects.toThrow(error); + }); + }); + }); +}); diff --git a/trade-finance-manager-ui/server/services/login.service.ts b/trade-finance-manager-ui/server/services/login.service.ts new file mode 100644 index 0000000000..dc3ef1f71c --- /dev/null +++ b/trade-finance-manager-ui/server/services/login.service.ts @@ -0,0 +1,11 @@ +import { GetAuthCodeUrlParams, GetAuthCodeUrlResponse } from '@ukef/dtfs2-common'; +import * as api from '../api'; + +export class LoginService { + /** + * Gets the URL to redirect the user to in order to log in. + */ + public getAuthCodeUrl = async ({ successRedirect }: GetAuthCodeUrlParams): Promise => { + return api.getAuthCodeUrl({ successRedirect }); + }; +} diff --git a/trade-finance-manager-ui/test-helpers/mocks/extra-id.service.mock.builder.ts b/trade-finance-manager-ui/test-helpers/mocks/extra-id.service.mock.builder.ts deleted file mode 100644 index 6962bdd2fa..0000000000 --- a/trade-finance-manager-ui/test-helpers/mocks/extra-id.service.mock.builder.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AuthorizationCodeRequest } from '@azure/msal-node'; -import { EntraIdService } from '../../server/services/entra-id.service'; -import { BaseMockBuilder } from './mock-builder.mock.builder'; - -export class EntraIdServiceMockBuilder extends BaseMockBuilder { - constructor() { - super({ - defaultInstance: { - getAuthCodeUrl: jest.fn(async () => { - return Promise.resolve({ - authCodeUrl: 'a-auth-code-url', - authCodeUrlRequest: {} as AuthorizationCodeRequest, - }); - }), - }, - }); - } -} diff --git a/trade-finance-manager-ui/test-helpers/mocks/index.ts b/trade-finance-manager-ui/test-helpers/mocks/index.ts index 55cb5217ef..a485d3815b 100644 --- a/trade-finance-manager-ui/test-helpers/mocks/index.ts +++ b/trade-finance-manager-ui/test-helpers/mocks/index.ts @@ -1,3 +1 @@ -export * from './entra-id.api.mock.builder'; -export * from './entra-id.config.mock.builder'; -export * from './extra-id.service.mock.builder'; +export * from './login.service.mock.builder'; diff --git a/trade-finance-manager-ui/test-helpers/mocks/login.service.mock.builder.ts b/trade-finance-manager-ui/test-helpers/mocks/login.service.mock.builder.ts new file mode 100644 index 0000000000..8722999686 --- /dev/null +++ b/trade-finance-manager-ui/test-helpers/mocks/login.service.mock.builder.ts @@ -0,0 +1,14 @@ +import { aGetAuthCodeUrlResponse, BaseMockBuilder } from '@ukef/dtfs2-common'; +import { LoginService } from '../../server/services/login.service'; + +export class LoginServiceMockBuilder extends BaseMockBuilder { + constructor() { + super({ + defaultInstance: { + getAuthCodeUrl: jest.fn(async () => { + return Promise.resolve(aGetAuthCodeUrlResponse()); + }), + }, + }); + } +} diff --git a/utils/.eslintrc.js b/utils/.eslintrc.js index 2abbe77fa7..9d8f896f70 100644 --- a/utils/.eslintrc.js +++ b/utils/.eslintrc.js @@ -9,7 +9,19 @@ const baseRules = { 'no-underscore-dangle': [ 'error', { - allow: ['_id', '_csrf', '_getBuffer', '_getData', '_getHeaders', '_getStatusCode', '_getRedirectUrl', '_getRenderData', '_getRenderView', '_isEndCalled'], + allow: [ + '_id', + '_csrf', + '_getBuffer', + '_getData', + '_getJSONData', + '_getHeaders', + '_getStatusCode', + '_getRedirectUrl', + '_getRenderData', + '_getRenderView', + '_isEndCalled', + ], }, ], 'import/extensions': 'off',