Skip to content

Commit 2162e61

Browse files
committed
OJ-3201: Add retry handling to nino-check lambda
1 parent 08cc5a3 commit 2162e61

7 files changed

Lines changed: 243 additions & 81 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ninoCheckEndpoint, createSession, getJarAuthorization } from "../endpoints";
2+
import { clearAttemptsTable, clearItemsFromTables, getItemByKey, queryItemsBySessionId } from "../../resources/dynamodb-helper";
3+
import { AUDIENCE, NINO } from "../env-variables";
4+
5+
jest.setTimeout(30_000);
6+
7+
describe("check-lambda retry logic", () => {
8+
const errorNino = "ER123456A";
9+
let sessionId: string;
10+
let sessionTableName: string;
11+
let privateApi: string;
12+
let issuer: string | undefined;
13+
14+
beforeEach(async () => {
15+
privateApi = `${process.env.PRIVATE_API}`;
16+
sessionTableName = `${process.env.SESSION_TABLE}`;
17+
const data = await getJarAuthorization();
18+
const request = await data.json();
19+
const session = await createSession(privateApi, request);
20+
const sessionData = await session.json();
21+
sessionId = sessionData.session_id;
22+
});
23+
24+
afterEach(async () => {
25+
await clearItemsFromTables(
26+
{
27+
tableName: `${process.env.PERSON_IDENTITY_TABLE}`,
28+
items: { sessionId: sessionId },
29+
},
30+
{
31+
tableName: `${process.env.NINO_USERS_TABLE}`,
32+
items: { sessionId: sessionId },
33+
},
34+
{
35+
tableName: sessionTableName,
36+
items: { sessionId: sessionId },
37+
}
38+
);
39+
await clearAttemptsTable(sessionId, `${process.env.USERS_ATTEMPTS_TABLE}`);
40+
});
41+
42+
it("should not attempt further matches after 2 attempts", async () => {
43+
const firstCheck = await ninoCheckEndpoint(privateApi, { "session-id": sessionId }, errorNino);
44+
const firstResBody = { ...(await firstCheck.json()) };
45+
expect(firstCheck.status).toEqual(200);
46+
expect(firstResBody).toStrictEqual({ requestRetry: true });
47+
const firstAttemptsItems = await queryItemsBySessionId(process.env.USERS_ATTEMPTS_TABLE!, sessionId);
48+
expect(firstAttemptsItems.Count).toEqual(1);
49+
expect(firstAttemptsItems.Items![0].attempt).toBe("FAIL");
50+
expect(firstAttemptsItems.Items![0].text).toBe("CID returned no record");
51+
52+
const secondCheck = await ninoCheckEndpoint(privateApi, { "session-id": sessionId }, errorNino);
53+
const secondResBody = { ...(await secondCheck.json()) };
54+
expect(secondCheck.status).toEqual(200);
55+
expect(secondResBody).toStrictEqual({ requestRetry: false });
56+
const secondAttemptsItems = await queryItemsBySessionId(process.env.USERS_ATTEMPTS_TABLE!, sessionId);
57+
expect(secondAttemptsItems.Count).toEqual(2);
58+
expect(secondAttemptsItems.Items![0].attempt).toBe("FAIL");
59+
expect(secondAttemptsItems.Items![1].attempt).toBe("FAIL");
60+
61+
const thirdCheck = await ninoCheckEndpoint(privateApi, { "session-id": sessionId }, errorNino);
62+
const thirdResBody = { ...(await thirdCheck.json()) };
63+
expect(thirdCheck.status).toEqual(200);
64+
expect(thirdResBody).toStrictEqual({ requestRetry: false });
65+
const thirdAttemptsItems = await queryItemsBySessionId(process.env.USERS_ATTEMPTS_TABLE!, sessionId);
66+
expect(thirdAttemptsItems.Count).toEqual(2);
67+
});
68+
69+
it("should successfully match even after a failed attempt", async () => {
70+
const firstCheck = await ninoCheckEndpoint(privateApi, { "session-id": sessionId }, errorNino);
71+
const firstResBody = { ...(await firstCheck.json()) };
72+
expect(firstCheck.status).toEqual(200);
73+
expect(firstResBody).toStrictEqual({ requestRetry: true });
74+
const firstAttemptsItems = await queryItemsBySessionId(process.env.USERS_ATTEMPTS_TABLE!, sessionId);
75+
expect(firstAttemptsItems.Count).toEqual(1);
76+
expect(firstAttemptsItems.Items![0].attempt).toBe("FAIL");
77+
78+
const secondCheck = await ninoCheckEndpoint(privateApi, { "session-id": sessionId }, NINO);
79+
const secondResBody = { ...(await secondCheck.json()) };
80+
expect(secondCheck.status).toEqual(200);
81+
expect(secondResBody).toStrictEqual({ requestRetry: false });
82+
const secondAttemptsItems = await queryItemsBySessionId(process.env.USERS_ATTEMPTS_TABLE!, sessionId);
83+
expect(secondAttemptsItems.Count).toEqual(2);
84+
expect(secondAttemptsItems.Items!.some(item => item.attempt === 'PASS')).toBe(true);
85+
expect(secondAttemptsItems.Items!.some(item => item.attempt === 'FAIL')).toBe(true);
86+
});
87+
});

integration-tests/resources/dynamodb-helper.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2-
import {
3-
DeleteCommand,
4-
DynamoDBDocumentClient,
5-
GetCommand,
6-
PutCommand,
7-
QueryCommand,
8-
} from "@aws-sdk/lib-dynamodb";
2+
import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
93
import { createSendCommand } from "./aws-helper";
104

115
type Keys = Record<string, unknown>;
@@ -20,6 +14,15 @@ const sendCommand = createSendCommand(() =>
2014
export const getItemByKey = (tableName: string, key: Keys) =>
2115
sendCommand(GetCommand, { TableName: tableName, Key: key });
2216

17+
export const queryItemsBySessionId = (tableName: string, sessionId: string) =>
18+
sendCommand(QueryCommand, {
19+
TableName: tableName,
20+
KeyConditionExpression: "sessionId = :sessionId",
21+
ExpressionAttributeValues: {
22+
":sessionId": sessionId ,
23+
},
24+
});
25+
2326
export const populateTable = (tableName: string, items: Keys) =>
2427
sendCommand(PutCommand, { TableName: tableName, Item: items });
2528

lambdas/nino-check/src/handler.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda
22
import { initOpenTelemetry } from "../../open-telemetry/src/otel-setup";
33
import { writeCompletedCheck } from "./helpers/write-completed-check";
44
import { NinoCheckFunctionConfig } from "./helpers/function-config";
5-
import { getHmrcConfig, saveAttempt, saveTxn, handlePdvResponse } from "./helpers/nino";
5+
import { getHmrcConfig, saveTxn, handleResponseAndSaveAttempt } from "./helpers/nino";
66
import { InputBody } from "./types/input";
77
import { CriError } from "../../common/src/errors/cri-error";
88
import { handleErrorResponse } from "../../common/src/errors/cri-error-response";
@@ -91,15 +91,17 @@ class NinoCheckHandler implements LambdaInterface {
9191

9292
await sendResponseReceivedEvent(functionConfig.audit, session, pdvRes.txn, deviceInformationHeader);
9393

94-
const ninoMatch = handlePdvResponse(pdvRes);
95-
96-
if (ninoMatch) {
97-
await saveAttempt(dynamoClient, functionConfig.tableNames.attemptTable, session, pdvRes);
98-
}
94+
const ninoMatch = await handleResponseAndSaveAttempt(
95+
dynamoClient,
96+
functionConfig.tableNames.attemptTable,
97+
session,
98+
pdvRes
99+
);
99100

100101
logger.info(`Completed NINo verification - ninoMatch=${ninoMatch}.`);
101102

102103
if (!ninoMatch && !isFinalAttempt) {
104+
captureMetric(`RetryAttemptsSentMetric`);
103105
logger.info(`Failed to verify. This was not the last attempt - requesting the user retries.`);
104106
return { statusCode: 200, body: JSON.stringify({ requestRetry: true }) };
105107
}

lambdas/nino-check/src/helpers/nino.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getParametersByName } from "@aws-lambda-powertools/parameters/ssm";
22
import { ISO8601DateString } from "../../../common/src/types/brands";
3-
import { PdvFunctionOutput } from "../hmrc-apis/types/pdv";
3+
import { PdvApiErrorBody, PdvFunctionOutput } from "../hmrc-apis/types/pdv";
44
import { marshall } from "@aws-sdk/util-dynamodb";
55
import { CriError } from "../../../common/src/errors/cri-error";
66
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
@@ -62,54 +62,70 @@ export async function saveTxn(dynamoClient: DynamoDBClient, sessionTableName: st
6262
logger.info(`Saved txn to session table: ${txnRes.$metadata.httpStatusCode}.`);
6363
}
6464

65-
export async function saveAttempt(
65+
async function saveAttempt(
6666
dynamoClient: DynamoDBClient,
6767
attemptTableName: string,
6868
session: NinoSessionItem,
69-
pdvRes: PdvFunctionOutput
69+
matchOutcome: "PASS" | "FAIL",
70+
status: number,
71+
error?: string
7072
) {
7173
const newAttempt: AttemptItem = {
7274
sessionId: session.sessionId,
7375
timestamp: new Date().toISOString() as ISO8601DateString,
74-
status: pdvRes.httpStatus.toString(),
75-
attempt: "PASS",
76+
status: status.toString(),
77+
attempt: matchOutcome,
78+
text: error,
7679
ttl: session.expiryDate,
7780
};
7881

7982
const putAttemptCmd = new PutItemCommand({
8083
TableName: attemptTableName,
81-
Item: marshall(newAttempt),
84+
Item: marshall(newAttempt, { removeUndefinedValues: true }),
8285
});
8386

8487
const attemptRes = await dynamoClient.send(putAttemptCmd);
8588

8689
logger.info(`Saved attempt: ${attemptRes.$metadata.httpStatusCode}`);
8790
}
8891

89-
export function handlePdvResponse(pdvRes: PdvFunctionOutput): boolean {
90-
const ninoMatch = pdvRes.httpStatus === 200;
92+
export async function handleResponseAndSaveAttempt(
93+
dynamoClient: DynamoDBClient,
94+
attemptTableName: string,
95+
session: NinoSessionItem,
96+
pdvRes: PdvFunctionOutput
97+
): Promise<boolean> {
98+
const responseStatus = pdvRes.httpStatus;
9199

92-
if (ninoMatch) {
100+
if (responseStatus === 200) {
93101
captureMetric(`SuccessfulFirstAttemptMetric`);
94-
} else if (pdvRes.httpStatus === 424) {
102+
await saveAttempt(dynamoClient, attemptTableName, session, "PASS", pdvRes.httpStatus);
103+
return true;
104+
}
105+
106+
if (pdvRes.httpStatus === 401 && "errors" in (pdvRes.parsedBody ?? {})) {
107+
logger.info(`Failed PDV match received.`);
108+
const errorText = (pdvRes.parsedBody as PdvApiErrorBody).errors;
109+
await saveAttempt(dynamoClient, attemptTableName, session, "FAIL", pdvRes.httpStatus, errorText);
110+
return false;
111+
}
112+
113+
if (pdvRes.httpStatus === 424) {
95114
captureMetric(`DeceasedUserMetric`);
96115
logger.info(`Deceased response received`);
97-
} else if (pdvRes.httpStatus === 401 && "errors" in (pdvRes.parsedBody ?? {})) {
98-
captureMetric(`RetryAttemptsSentMetric`);
99-
logger.info(`Failed PDV match received.`);
100-
} else if (pdvRes.parsedBody && "code" in pdvRes.parsedBody && pdvRes.parsedBody?.code === "INVALID_CREDENTIALS") {
116+
await saveAttempt(dynamoClient, attemptTableName, session, "FAIL", pdvRes.httpStatus, pdvRes.body);
117+
return false;
118+
}
119+
120+
if (pdvRes.parsedBody && "code" in pdvRes.parsedBody && pdvRes.parsedBody?.code === "INVALID_CREDENTIALS") {
101121
captureMetric(`FailedHMRCAuthMetric`);
102122
logger.info(
103123
`Failed to authenticate with HMRC API: response had a code of ${pdvRes.parsedBody.code} & http status of ${pdvRes.httpStatus}`
104124
);
105-
106125
throw new CriError(500, "Failed to authenticate with HMRC API");
107-
} else {
108-
captureMetric(`HMRCAPIErrorMetric`);
109-
logger.info(`Received an unexpected error response from the PDV API - status: ${pdvRes.httpStatus}`);
110-
111-
throw new CriError(500, "Unexpected error with the PDV API");
112126
}
113127

114-
return ninoMatch;
128+
captureMetric(`HMRCAPIErrorMetric`);
129+
logger.info(`Received an unexpected error response from the PDV API - status: ${responseStatus}`);
130+
throw new CriError(500, "Unexpected error with the PDV API");
115131
}

lambdas/nino-check/tests/handler.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { mockLogger } from "../../common/tests/logger";
2121
import { handler } from "../src/handler";
2222
import { retrieveSession } from "../src/helpers/retrieve-session";
2323
import { NinoCheckFunctionConfig } from "../src/helpers/function-config";
24-
import { getHmrcConfig, handlePdvResponse, saveAttempt, saveTxn } from "../src/helpers/nino";
24+
import { getHmrcConfig, handleResponseAndSaveAttempt, saveTxn } from "../src/helpers/nino";
2525
import { retrieveAttempts } from "../src/helpers/retrieve-attempts";
2626
import { retrievePersonIdentity } from "../src/helpers/retrieve-person-identity";
2727
import { sendRequestSentEvent, sendResponseReceivedEvent } from "../src/helpers/audit";
@@ -30,6 +30,7 @@ import { writeCompletedCheck } from "../src/helpers/write-completed-check";
3030
import { getTokenFromOtg } from "../src/hmrc-apis/otg";
3131
import { buildPdvInput } from "../src/helpers/build-pdv-input";
3232
import { captureMetric } from "../../common/src/util/metrics";
33+
import { CriError } from "../../common/src/errors/cri-error";
3334

3435
const mockContext: Context = {
3536
awsRequestId: "",
@@ -73,7 +74,7 @@ const handlerInput: Parameters<typeof handler> = [
7374
(retrievePersonIdentity as unknown as jest.Mock).mockResolvedValue(mockPersonIdentity);
7475
(getTokenFromOtg as unknown as jest.Mock).mockResolvedValue(mockOtgToken);
7576
(matchUserDetailsWithPdv as unknown as jest.Mock).mockResolvedValue(mockPdvRes);
76-
(handlePdvResponse as unknown as jest.Mock).mockResolvedValue(true);
77+
(handleResponseAndSaveAttempt as unknown as jest.Mock).mockResolvedValue(true);
7778

7879
describe("nino-check handler", () => {
7980
beforeEach(() => {
@@ -116,7 +117,7 @@ describe("nino-check handler", () => {
116117
mockPdvRes.txn,
117118
mockDeviceInformationHeader
118119
);
119-
expect(saveAttempt).toHaveBeenCalledWith(
120+
expect(handleResponseAndSaveAttempt).toHaveBeenCalledWith(
120121
mockDynamoClient,
121122
mockFunctionConfig.tableNames.attemptTable,
122123
mockSession,
@@ -181,7 +182,7 @@ describe("nino-check handler", () => {
181182
});
182183

183184
it("behaves correctly if ninoMatch is false", async () => {
184-
(handlePdvResponse as unknown as jest.Mock).mockReturnValueOnce(false);
185+
(handleResponseAndSaveAttempt as unknown as jest.Mock).mockReturnValueOnce(false);
185186

186187
const response = await handler(...handlerInput);
187188

@@ -190,6 +191,31 @@ describe("nino-check handler", () => {
190191
body: JSON.stringify({ requestRetry: true }),
191192
});
192193

194+
expect(writeCompletedCheck).not.toHaveBeenCalled();
195+
expect(captureMetric).toHaveBeenCalledWith("RetryAttemptsSentMetric");
196+
});
197+
198+
it("should return 200 if nino match is false but its the final attempt", async () => {
199+
(retrieveAttempts as unknown as jest.Mock).mockResolvedValueOnce(1);
200+
(handleResponseAndSaveAttempt as unknown as jest.Mock).mockReturnValueOnce(false);
201+
202+
const response = await handler(...handlerInput);
203+
204+
expect(response).toStrictEqual({
205+
statusCode: 200,
206+
body: JSON.stringify({ requestRetry: false }),
207+
});
208+
});
209+
210+
it("should return 500 if unexpected Error recieved from PDV request", async () => {
211+
(handleResponseAndSaveAttempt as unknown as jest.Mock).mockImplementationOnce(() => {
212+
throw new CriError(500, "Error");
213+
});
214+
215+
const response = await handler(...handlerInput);
216+
217+
expect(response).toStrictEqual(internalServerError);
218+
193219
expect(writeCompletedCheck).not.toHaveBeenCalled();
194220
});
195221
});

0 commit comments

Comments
 (0)