Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(FN-3163): MDM endpoints for automatically creating new DTFS customers in Salesforce (Dun and Bradstreet service only) #1056

Closed
wants to merge 12 commits into from
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ services:
COMPANIES_HOUSE_KEY:
COMPANIES_HOUSE_MAX_REDIRECTS:
COMPANIES_HOUSE_TIMEOUT:
DUN_AND_BRADSTREET_URL:
DUN_AND_BRADSTREET_KEY:
DUN_AND_BRADSTREET_MAX_REDIRECTS:
DUN_AND_BRADSTREET_TIMEOUT:
API_KEY:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}"]
Expand Down
20 changes: 20 additions & 0 deletions src/config/dun-and-bradstreet.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { getIntConfig } from '@ukef/helpers/get-int-config';

export interface DunAndBradstreetConfig {
baseUrl: string;
key: string;
maxRedirects: number;
timeout: number;
}

export default registerAs(
DUN_AND_BRADSTREET.CONFIG.KEY,
(): DunAndBradstreetConfig => ({
baseUrl: process.env.DUN_AND_BRADSTREET_URL,
key: process.env.DUN_AND_BRADSTREET_KEY,
maxRedirects: getIntConfig(process.env.DUN_AND_BRADSTREET_MAX_REDIRECTS, 5),
timeout: getIntConfig(process.env.DUN_AND_BRADSTREET_TIMEOUT, 30000),
}),
);
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import AppConfig from './app.config';
import CompaniesHouseConfig from './companies-house.config';
import DatabaseConfig from './database.config';
import DocConfig from './doc.config';
import DunAndBradstreetConfig from './dun-and-bradstreet.config';
import InformaticaConfig from './informatica.config';
import OrdnanceSurveyConfig from './ordnance-survey.config';

export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, DunAndBradstreetConfig, InformaticaConfig, OrdnanceSurveyConfig];
5 changes: 5 additions & 0 deletions src/constants/dun-and-bradstreet.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const DUN_AND_BRADSTREET = {
CONFIG: {
KEY: 'dunAndBradstreet',
},
};
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './companies-house.constant';
export * from './customers.constant';
export * from './database-name.constant';
export * from './date.constant';
export * from './dun-and-bradstreet.constant';
export * from './enums';
export * from './geospatial.constant';
export * from './govuk-notify.constant';
Expand Down
27 changes: 27 additions & 0 deletions src/helper-modules/dun-and-bradstreet/dun-and-bradstreet.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpModule } from '@ukef/modules/http/http.module';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';

@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { baseUrl, maxRedirects, timeout } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
return {
baseURL: baseUrl,
maxRedirects,
timeout,
};
},
}),
],
providers: [DunAndBradstreetService],
exports: [DunAndBradstreetService],
})
export class DunAndBradstreetModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import { AxiosError } from 'axios';
import { resetAllWhenMocks, when } from 'jest-when';
import { of, throwError } from 'rxjs';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';
import { DunAndBradstreetException } from './exception/dun-and-bradstreet.exception';

describe('DunAndBradstreetService', () => {
let httpServiceGet: jest.Mock;
let configServiceGet: jest.Mock;
let service: DunAndBradstreetService;

const valueGenerator = new RandomValueGenerator();

const testRegistrationNumber = '0' + valueGenerator.stringOfNumericCharacters({ length: 7 });
const expectedAccessToken = 'TEST_ACCESS_TOKEN';
const getAccessTokenMethodMock = jest
.spyOn(DunAndBradstreetService.prototype as any, 'getAccessToken')
.mockImplementation(() => Promise.resolve(expectedAccessToken));

const dunAndBradstreetpath = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${testRegistrationNumber}`;
const expectedDunsNumber = '123456789';
const getDunsNumberDunAndBradstreetResponse = {
matchCandidates: [
{
organization: {
duns: expectedDunsNumber,
},
},
],
};

const expectedHttpServiceGetArguments: [string, object] = [
dunAndBradstreetpath,
{
headers: {
Authorization: `Bearer ${expectedAccessToken}`,
},
},
];

const expectedHttpServiceGetResponse = of({
data: getDunsNumberDunAndBradstreetResponse,
status: 200,
statusText: 'OK',
config: undefined,
headers: undefined,
});

beforeAll(() => {
const httpService = new HttpService();
httpServiceGet = jest.fn();
httpService.get = httpServiceGet;

const configService = new ConfigService();
configServiceGet = jest.fn().mockReturnValue({ key: 'TEST API_KEY' });
configService.get = configServiceGet;

service = new DunAndBradstreetService(httpService, configService);
});

beforeEach(() => {
resetAllWhenMocks();
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getDunAndBradstreetNumberByRegistrationNumber', () => {
it('calls the Dun and Bradstreet API with the correct arguments', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledWith(...expectedHttpServiceGetArguments);
});

it('returns the results when the Dun and Bradstreet API returns a 200 response with results', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

const response = await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(response).toBe(expectedDunsNumber);
});

it('throws a DunAndBradstreetException if the Dun and Bradstreet API returns an unknown error response', async () => {
const axiosError = new AxiosError();
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getDunsNumberPromise = service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
await expect(getDunsNumberPromise).rejects.toBeInstanceOf(DunAndBradstreetException);
await expect(getDunsNumberPromise).rejects.toThrow('Failed to get response from Dun and Bradstreet API');
await expect(getDunsNumberPromise).rejects.toHaveProperty('innerError', axiosError);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpClient } from '@ukef/modules/http/http.client';

import { createWrapDunAndBradstreetHttpGetErrorCallback } from './wrap-dun-and-bradstreet-http-error-callback';

@Injectable()
export class DunAndBradstreetService {
private readonly httpClient: HttpClient;
private readonly encodedKey: string;

constructor(httpService: HttpService, configService: ConfigService) {
this.httpClient = new HttpClient(httpService);
const { key } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
this.encodedKey = key;
}

async getDunAndBradstreetNumberByRegistrationNumber(registrationNumber: string): Promise<string> {
const path = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${registrationNumber}`;
const accessToken = await this.getAccessToken();

const { data } = await this.httpClient.get<any>({
path,
headers: {
Authorization: 'Bearer ' + accessToken,
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get response from Dun and Bradstreet API',
knownErrors: [],
}),
});
return data?.matchCandidates[0]?.organization?.duns;
}

private async getAccessToken(): Promise<string> {
const path = '/v3/token';
const response = await this.httpClient.post<any, any>({
path,
body: {
grant_type: 'client_credentials',
},
headers: {
Authorization: 'Basic ' + this.encodedKey,
'Content-Type': 'application/x-www-form-urlencoded',
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get access token',
knownErrors: [],
}),
});
return response.data.accessToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';

import { DunAndBradstreetException } from './dun-and-bradstreet.exception';

describe('DunAndBradstreetException', () => {
const valueGenerator = new RandomValueGenerator();
const message = valueGenerator.string();

it('exposes the message it was created with', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.message).toBe(message);
});

it('exposes the name of the exception', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.name).toBe('DunAndBradstreetException');
});

it('exposes the inner error it was created with', () => {
const innerError = new Error();

const exception = new DunAndBradstreetException(message, innerError);

expect(exception.innerError).toBe(innerError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class DunAndBradstreetException extends Error {
constructor(
message: string,
public readonly innerError?: Error,
) {
super(message);
this.name = this.constructor.name;
}
}
5 changes: 5 additions & 0 deletions src/helper-modules/dun-and-bradstreet/known-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AxiosError } from 'axios';

export type KnownErrors = KnownError[];

type KnownError = { checkHasError: (error: Error) => boolean; throwError: (error: AxiosError) => never };
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AxiosError } from 'axios';
import { ObservableInput, throwError } from 'rxjs';

import { DunAndBradstreetException } from './exception/dun-and-bradstreet.exception';
import { KnownErrors } from './known-errors';

type DunAndBradstreetHttpErrorCallback = (error: Error) => ObservableInput<never>;

export const createWrapDunAndBradstreetHttpGetErrorCallback =
({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): DunAndBradstreetHttpErrorCallback =>
(error: Error) => {
if (error instanceof AxiosError && error?.response) {
knownErrors.forEach(({ checkHasError, throwError }) => checkHasError(error) && throwError(error));
}

return throwError(() => new DunAndBradstreetException(messageForUnknownError, error));
};
5 changes: 5 additions & 0 deletions src/logging/log-keys-to-redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ describe('logKeysToRedact', () => {
logKey: valueGenerator.string(),
headersLogKey: valueGenerator.string(),
},
incomingResponse: {
logKey: valueGenerator.string(),
bodyLogKey: valueGenerator.string(),
accessTokenLogKey: valueGenerator.string(),
},
error: {
logKey: valueGenerator.string(),
sensitiveChildKeys: [valueGenerator.string(), valueGenerator.string()],
Expand Down
15 changes: 14 additions & 1 deletion src/logging/log-keys-to-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export interface LogKeysToRedactOptions {
logKey: string;
headersLogKey: string;
};
incomingResponse: {
logKey: string;
bodyLogKey: string;
accessTokenLogKey: string;
};

error: {
logKey: string;
sensitiveChildKeys: string[];
Expand All @@ -20,13 +26,14 @@ export interface LogKeysToRedactOptions {
};
}

export const logKeysToRedact = ({ redactLogs, clientRequest, outgoingRequest, error, dbError }: LogKeysToRedactOptions): string[] => {
export const logKeysToRedact = ({ redactLogs, clientRequest, outgoingRequest, incomingResponse, error, dbError }: LogKeysToRedactOptions): string[] => {
if (!redactLogs) {
return [];
}
const keys = [
...getClientRequestLogKeysToRedact(clientRequest),
...getOutgoingRequestLogKeysToRedact(outgoingRequest),
...getIncomingResponseLogKeysToRedact(incomingResponse),
...getErrorLogKeysToRedact(error),
...getDbErrorLogKeysToRedact(dbError),
];
Expand All @@ -39,10 +46,16 @@ const getClientRequestLogKeysToRedact = ({ logKey, headersLogKey }: LogKeysToRed
buildKeyToRedact([logKey, headersLogKey]),
];

const getIncomingResponseLogKeysToRedact = ({ logKey, bodyLogKey, accessTokenLogKey }: LogKeysToRedactOptions['incomingResponse']): string[] => [
// We redact the client request body as they contain the Dun and Bradstreet access token
buildKeyToRedact([logKey, bodyLogKey, accessTokenLogKey]),
];

const getOutgoingRequestLogKeysToRedact = ({ logKey, headersLogKey }: LogKeysToRedactOptions['outgoingRequest']): string[] => {
return [
// We redact the outgoing request headers as they contain:
// - our Basic auth details for Informatica
// - our Client auth details for Dun and Bradstreet
buildKeyToRedact([logKey, headersLogKey]),
];
};
Expand Down
Loading
Loading