Skip to content

Commit 3706202

Browse files
OJ-3227 - Refactor database functions & querying
1 parent 731f2ed commit 3706202

37 files changed

Lines changed: 440 additions & 827 deletions

lambdas/abandon/src/services/abandon-dynamo-service.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,26 @@
11
import { getRecordBySessionId } from "../../../common/src/database/get-record-by-session-id";
22
import { SessionItem } from "../../../common/src/database/types/session-item";
33
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
4-
import { RecordExpiredError, RecordNotFoundError } from "../../../common/src/database/exceptions/errors";
4+
import { RecordNotFoundError } from "../../../common/src/database/exceptions/errors";
55
import { CriError } from "../../../common/src/errors/cri-error";
66
import { logger } from "../../../common/src/util/logger";
77

88
const dynamoClient = new DynamoDBClient();
99

1010
export async function retrieveSessionRecord(sessionTableName: string, sessionId: string) {
1111
try {
12-
const [sessionItem] = await getRecordBySessionId<SessionItem>(
12+
const sessionItem = await getRecordBySessionId<SessionItem>(
13+
dynamoClient,
1314
sessionTableName,
1415
sessionId,
1516
logger,
16-
{},
17-
dynamoClient
17+
"expiryDate"
1818
);
1919
return sessionItem;
2020
} catch (error: unknown) {
2121
if (error instanceof RecordNotFoundError) {
2222
throw new CriError(400, "Session not found");
2323
}
24-
if (error instanceof RecordExpiredError) {
25-
throw new CriError(400, "Session expired");
26-
}
2724
throw error;
2825
}
2926
}

lambdas/abandon/tests/abandon-handler.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ describe("abandon-handler", () => {
5959
expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, {
6060
ExpressionAttributeValues: {
6161
":value": { S: "session-123" },
62+
":expiry": { N: expect.stringMatching(/\d+/) },
6263
},
63-
KeyConditionExpression: "sessionId = :value",
64+
KeyConditionExpression: "sessionId = :value and expiryDate > :expiry",
6465
TableName: "session-table",
6566
});
6667
expect(ddbMock).toHaveReceivedCommandWith(UpdateItemCommand, {

lambdas/abandon/tests/services/abandon-dynamo-service.test.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ describe("abandon-dynamo-service", () => {
3131
expect(sessionItem.clientSessionId).toBe("gov-123");
3232
expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, {
3333
ExpressionAttributeValues: {
34+
":expiry": { N: expect.stringMatching(/\d+/) },
3435
":value": { S: "session-123" },
3536
},
36-
KeyConditionExpression: "sessionId = :value",
37+
KeyConditionExpression: "sessionId = :value and expiryDate > :expiry",
3738
TableName: "session-table",
3839
});
3940
});
@@ -55,28 +56,6 @@ describe("abandon-dynamo-service", () => {
5556
}
5657
});
5758

58-
it("should throw when Session expired", async () => {
59-
expect.assertions(3);
60-
61-
ddbMock.on(QueryCommand).resolves({
62-
Items: [
63-
{
64-
sessionId: { S: "session-123" },
65-
expiryDate: { N: "1" },
66-
},
67-
],
68-
Count: 1,
69-
});
70-
71-
try {
72-
await retrieveSessionRecord("session-table", "session-123");
73-
} catch (err) {
74-
expect(err).toBeInstanceOf(CriError);
75-
expect((err as CriError).message).toBe("Session expired");
76-
expect((err as CriError).status).toBe(400);
77-
}
78-
});
79-
8059
it("should throw an exception when it errors retrievings a record", async () => {
8160
ddbMock.on(QueryCommand).rejects();
8261

lambdas/issue-credential/src/helpers/function-config.ts renamed to lambdas/common/src/config/base-function-config.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuditConfig, TableNames } from "../types/input";
1+
import { AuditConfig, TableNames } from "../../../issue-credential/src/types/input";
22

33
const envVarNames = {
44
sessionTable: "SESSION_TABLE",
@@ -10,16 +10,18 @@ const envVarNames = {
1010
auditIssuer: "AUDIT_ISSUER",
1111
};
1212

13-
export class IssueCredentialFunctionConfig {
13+
export class BaseFunctionConfig {
1414
public readonly tableNames: TableNames;
1515
public readonly audit: AuditConfig;
1616

17+
public static checkEnvEntry(name: string) {
18+
if (!process.env[name]) {
19+
throw new Error(`Missing required environment variable at init: ${name}`);
20+
}
21+
}
22+
1723
constructor() {
18-
Object.values(envVarNames).forEach((name) => {
19-
if (!process.env[name]) {
20-
throw new Error(`Missing required environment variable at init: ${name}`);
21-
}
22-
});
24+
Object.values(envVarNames).forEach(BaseFunctionConfig.checkEnvEntry);
2325

2426
this.tableNames = {
2527
sessionTable: process.env[envVarNames.sessionTable] as string,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { DynamoDBClient, QueryCommand, QueryCommandInput } from "@aws-sdk/client-dynamodb";
2+
3+
export async function countAttempts(
4+
attemptTable: string,
5+
dynamoClient: DynamoDBClient,
6+
sessionId: string,
7+
status?: "PASS" | "FAIL"
8+
): Promise<number> {
9+
const statusQueryString = status ? [`attempt = :status`] : "";
10+
const statusAttribute: QueryCommandInput["ExpressionAttributeValues"] = status ? { ":status": { S: status } } : {};
11+
12+
const command = new QueryCommand({
13+
TableName: attemptTable,
14+
Select: "COUNT",
15+
KeyConditionExpression: ["sessionId = :value", "ttl > :ttl", ...statusQueryString].join(" and "),
16+
ExpressionAttributeValues: {
17+
":value": {
18+
S: sessionId,
19+
},
20+
":ttl": {
21+
N: Math.floor(Date.now() / 1000).toString(),
22+
},
23+
...statusAttribute,
24+
},
25+
});
26+
27+
const result = await dynamoClient.send(command);
28+
29+
return result.Count ?? 0;
30+
}

lambdas/common/src/database/exceptions/errors.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,3 @@
1-
export class RecordExpiredError extends Error {
2-
public readonly name = "RecordExpiredError";
3-
4-
constructor(
5-
public readonly tableName: string,
6-
public readonly sessionId: string,
7-
public readonly expiryDates: number[]
8-
) {
9-
super();
10-
}
11-
12-
public get message() {
13-
const expiries = this.expiryDates
14-
// multiply by 1000 as expiryDate is in Unix seconds but Date expects milliseconds
15-
.map((r) => new Date(r * 1000).toISOString())
16-
.join(", ");
17-
18-
return `Found only expired records on the ${this.tableName} table: ${expiries}.`;
19-
}
20-
}
21-
221
export class RecordNotFoundError extends Error {
232
public readonly name = "RecordNotFoundError";
243

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,59 @@
1-
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
1+
import { DynamoDBClient, QueryCommand, QueryCommandOutput } from "@aws-sdk/client-dynamodb";
22
import { unmarshall } from "@aws-sdk/util-dynamodb";
33
import { withRetry } from "../util/retry";
4-
import { isRecordExpired, RecordWithExpiry } from "./util/is-record-expired";
5-
import { RecordExpiredError, RecordNotFoundError, TooManyRecordsError } from "./exceptions/errors";
4+
import { RecordNotFoundError, TooManyRecordsError } from "./exceptions/errors";
65
import { Logger } from "@aws-lambda-powertools/logger";
6+
import { UnixSecondsTimestamp } from "../types/brands";
77

8-
export type SessionIdRecord = { sessionId: string } & RecordWithExpiry;
8+
export type SessionIdRecord = { sessionId: string; expiryDate?: UnixSecondsTimestamp };
99

1010
/**
1111
* Retrieves a record from DynamoDB, given a table name and session ID.
12-
* Handles retries and expiry validation, and returns an array of valid entries.
13-
* The array will usually have length 1, but it's possible that multiple rows will be returned.
12+
* Handles retries and expiry validation, and returns a single valid entry.
1413
*
1514
* Use a type parameter to set the type of the entity that will be returned.
1615
*/
1716
export async function getRecordBySessionId<
1817
/** The type that will be returned by the function. Must include sessionId key. */
1918
ReturnType extends SessionIdRecord,
20-
>(
21-
/** The name of the table in DynamoDB. Probably looks like "some-table-some-stack". */
22-
tableName: string,
23-
/** The session ID to search for. */
24-
sessionId: string,
25-
logger: Logger,
26-
opts?: { allowNoEntries?: boolean; allowMultipleEntries?: boolean },
27-
dynamoClient = new DynamoDBClient()
28-
) {
29-
const allowNoEntries = opts?.allowNoEntries ?? false;
30-
const allowMultipleEntries = opts?.allowMultipleEntries ?? false;
31-
19+
>(dynamoClient: DynamoDBClient, tableName: string, sessionId: string, logger: Logger, expiryColumn: keyof ReturnType) {
3220
async function queryRecord() {
3321
const command = new QueryCommand({
3422
TableName: tableName,
35-
KeyConditionExpression: "sessionId = :value",
23+
KeyConditionExpression: `sessionId = :value and ${String(expiryColumn)} > :expiry`,
3624
ExpressionAttributeValues: {
3725
":value": {
3826
S: sessionId,
3927
},
28+
":expiry": {
29+
N: Math.floor(Date.now() / 1000).toString(),
30+
},
4031
},
4132
});
4233

4334
const result = await dynamoClient.send(command);
4435

45-
if (!allowNoEntries && (result.Count === 0 || !result.Items)) {
46-
throw new RecordNotFoundError(tableName, sessionId);
36+
if (result.Count === 0 || !result.Items) {
37+
throw new Error();
4738
}
4839

49-
return result.Items ?? [];
40+
return result.Items;
5041
}
5142

52-
const queryResult = await withRetry(queryRecord, logger, {
53-
maxRetries: 3,
54-
baseDelay: 300,
55-
});
56-
57-
const retrievedRecords = queryResult.map((v) => unmarshall(v)) as ReturnType[];
43+
let queryResult: QueryCommandOutput["Items"];
5844

59-
const validRecords = retrievedRecords.filter((v) => !isRecordExpired(v));
60-
61-
if (retrievedRecords.length > 0 && validRecords.length === 0) {
62-
throw new RecordExpiredError(
63-
tableName,
64-
sessionId,
65-
retrievedRecords.map((v) => v.expiryDate ?? v.ttl ?? -1)
66-
);
45+
try {
46+
queryResult = await withRetry(queryRecord, logger, {
47+
maxRetries: 3,
48+
baseDelay: 300,
49+
});
50+
} catch {
51+
throw new RecordNotFoundError(tableName, sessionId);
6752
}
6853

69-
if (!allowMultipleEntries && validRecords.length > 1) {
70-
throw new TooManyRecordsError(tableName, sessionId, validRecords.length);
54+
if (queryResult.length > 1) {
55+
throw new TooManyRecordsError(tableName, sessionId, queryResult.length);
7156
}
7257

73-
return validRecords;
58+
return unmarshall(queryResult[0]) as ReturnType;
7459
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getRecordBySessionId } from "./get-record-by-session-id";
2+
import { PersonIdentityItem } from "./types/person-identity";
3+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
4+
import { logger } from "../util/logger";
5+
import { RecordNotFoundError } from "./exceptions/errors";
6+
import { CriError } from "../errors/cri-error";
7+
import { safeStringifyError } from "../util/stringify-error";
8+
9+
export async function retrievePersonIdentity(
10+
personIdentityTableName: string,
11+
dynamoClient: DynamoDBClient,
12+
sessionId: string
13+
): Promise<PersonIdentityItem> {
14+
try {
15+
const personIdentity = await getRecordBySessionId<PersonIdentityItem>(
16+
dynamoClient,
17+
personIdentityTableName,
18+
sessionId,
19+
logger,
20+
"expiryDate"
21+
);
22+
23+
return personIdentity;
24+
} catch (error) {
25+
if (error instanceof RecordNotFoundError) {
26+
logger.info(`No valid person identity record found.`);
27+
throw new CriError(500, `No person identity entry found for the given session ID.`);
28+
}
29+
30+
logger.error(`Caught unexpected person identity retrieval error: ${safeStringifyError(error)}`);
31+
32+
throw new CriError(500, "Unexpected error getting person identity");
33+
}
34+
}

lambdas/common/src/database/util/is-record-expired.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

lambdas/issue-credential/tests/helpers/function-config.test.ts renamed to lambdas/common/tests/config/function-config.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IssueCredentialFunctionConfig } from "../../src/helpers/function-config";
1+
import { BaseFunctionConfig } from "../../src/config/base-function-config";
22

33
const originalProcessEnv = JSON.parse(JSON.stringify(process.env));
44

@@ -25,7 +25,7 @@ describe("NINo Check function getConfig()", () => {
2525
});
2626

2727
it("returns the right data for a certain input", () => {
28-
const config = new IssueCredentialFunctionConfig();
28+
const config = new BaseFunctionConfig();
2929

3030
expect(config).toEqual({
3131
tableNames: {
@@ -44,6 +44,6 @@ describe("NINo Check function getConfig()", () => {
4444

4545
it("throws an error for a missing config value", () => {
4646
process.env.NINO_USER_TABLE = "";
47-
expect(() => new IssueCredentialFunctionConfig()).toThrow("NINO_USER_TABLE");
47+
expect(() => new BaseFunctionConfig()).toThrow("NINO_USER_TABLE");
4848
});
4949
});

0 commit comments

Comments
 (0)