Skip to content

Commit e45bf30

Browse files
OJ-3227 - Improvements to common code
1 parent e910eec commit e45bf30

39 files changed

Lines changed: 544 additions & 629 deletions

infrastructure/template.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ Resources:
839839
ISSUER: !Sub "{{resolve:ssm:/${CommonStackName}/verifiable-credential/issuer}}"
840840
EVENT_BUS_NAME: !Ref CheckHmrcEventBus
841841
EVENT_BUS_SOURCE: !FindInMap [EnvironmentConfiguration, !Ref Environment, DOMAINNAME]
842+
LOG_FULL_ERRORS: !If [IsProdEnvironment, "false", "true"]
842843
Policies:
843844
- DynamoDBReadPolicy:
844845
TableName: !Sub "{{resolve:ssm:/${CommonStackName}/SessionTableName}}"
@@ -1417,6 +1418,7 @@ Resources:
14171418
AUDIT_EVENT_BUS: !Ref CheckHmrcEventBus
14181419
AUDIT_SOURCE: !FindInMap [EnvironmentConfiguration, !Ref Environment, DOMAINNAME]
14191420
AUDIT_ISSUER: !Sub "{{resolve:ssm:/${CommonStackName}/verifiable-credential/issuer}}"
1421+
LOG_FULL_ERRORS: !If [IsProdEnvironment, "false", "true"]
14201422

14211423
NinoCheckFunctionLogGroup:
14221424
Type: AWS::Logs::LogGroup

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
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,
15-
logger,
16-
{},
17-
dynamoClient
16+
"expiryDate"
1817
);
1918
return sessionItem;
2019
} catch (error: unknown) {
2120
if (error instanceof RecordNotFoundError) {
2221
throw new CriError(400, "Session not found");
2322
}
24-
if (error instanceof RecordExpiredError) {
25-
throw new CriError(400, "Session expired");
26-
}
2723
throw error;
2824
}
2925
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,13 @@ describe("abandon-handler", () => {
5959
expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, {
6060
ExpressionAttributeValues: {
6161
":value": { S: "session-123" },
62+
":expiry": { N: expect.stringMatching(/\d+/) },
6263
},
6364
KeyConditionExpression: "sessionId = :value",
65+
FilterExpression: "#expiry > :expiry",
66+
ExpressionAttributeNames: {
67+
"#expiry": "expiryDate",
68+
},
6469
TableName: "session-table",
6570
});
6671
expect(ddbMock).toHaveReceivedCommandWith(UpdateItemCommand, {

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

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,14 @@ 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
},
3637
KeyConditionExpression: "sessionId = :value",
38+
FilterExpression: "#expiry > :expiry",
39+
ExpressionAttributeNames: {
40+
"#expiry": "expiryDate",
41+
},
3742
TableName: "session-table",
3843
});
3944
});
@@ -55,28 +60,6 @@ describe("abandon-dynamo-service", () => {
5560
}
5661
});
5762

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-
8063
it("should throw an exception when it errors retrievings a record", async () => {
8164
ddbMock.on(QueryCommand).rejects();
8265

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { AuditConfig, TableNames } from "../../../issue-credential/src/types/input";
2+
3+
const envVarNames = {
4+
sessionTable: "SESSION_TABLE",
5+
personIdentityTable: "PERSON_IDENTITY_TABLE",
6+
attemptTable: "ATTEMPT_TABLE",
7+
ninoUserTable: "NINO_USER_TABLE",
8+
auditEventBus: "AUDIT_EVENT_BUS",
9+
auditSource: "AUDIT_SOURCE",
10+
auditIssuer: "AUDIT_ISSUER",
11+
};
12+
13+
export class BaseFunctionConfig {
14+
public readonly tableNames: TableNames;
15+
public readonly audit: AuditConfig;
16+
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+
23+
constructor() {
24+
Object.values(envVarNames).forEach(BaseFunctionConfig.checkEnvEntry);
25+
26+
this.tableNames = {
27+
sessionTable: process.env[envVarNames.sessionTable] as string,
28+
personIdentityTable: process.env[envVarNames.personIdentityTable] as string,
29+
attemptTable: process.env[envVarNames.attemptTable] as string,
30+
ninoUserTable: process.env[envVarNames.ninoUserTable] as string,
31+
};
32+
this.audit = {
33+
eventBus: process.env[envVarNames.auditEventBus] as string,
34+
source: process.env[envVarNames.auditSource] as string,
35+
issuer: process.env[envVarNames.auditIssuer] as string,
36+
};
37+
}
38+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
// Need to use ExpressionAttributeNames for ttl as it is a reserved word
13+
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
14+
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html
15+
16+
const command = new QueryCommand({
17+
TableName: attemptTable,
18+
Select: "COUNT",
19+
KeyConditionExpression: "sessionId = :value",
20+
FilterExpression: ["#ttl > :ttl", ...statusQueryString].join(" and "),
21+
ExpressionAttributeNames: { "#ttl": "ttl" },
22+
ExpressionAttributeValues: {
23+
":value": {
24+
S: sessionId,
25+
},
26+
":ttl": {
27+
N: Math.floor(Date.now() / 1000).toString(),
28+
},
29+
...statusAttribute,
30+
},
31+
});
32+
33+
const result = await dynamoClient.send(command);
34+
35+
return result.Count ?? 0;
36+
}

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 & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,62 @@
11
import { DynamoDBClient, QueryCommand } 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";
6-
import { Logger } from "@aws-lambda-powertools/logger";
4+
import { RecordNotFoundError, TooManyRecordsError } from "./exceptions/errors";
5+
import { UnixSecondsTimestamp } from "../types/brands";
6+
import { logger } from "../util/logger";
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, expiryColumn: keyof ReturnType) {
3220
async function queryRecord() {
21+
// Use ExpressionAttributeNames as it's possible the expiry column name is a reserved word.
22+
// Notably, 'ttl' is a reserved word.
23+
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
24+
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html
25+
3326
const command = new QueryCommand({
3427
TableName: tableName,
35-
KeyConditionExpression: "sessionId = :value",
28+
KeyConditionExpression: `sessionId = :value`,
29+
FilterExpression: `#expiry > :expiry`,
30+
ExpressionAttributeNames: {
31+
"#expiry": String(expiryColumn),
32+
},
3633
ExpressionAttributeValues: {
3734
":value": {
3835
S: sessionId,
3936
},
37+
":expiry": {
38+
N: Math.floor(Date.now() / 1000).toString(),
39+
},
4040
},
4141
});
4242

4343
const result = await dynamoClient.send(command);
4444

45-
if (!allowNoEntries && (result.Count === 0 || !result.Items)) {
45+
if (result.Count === 0 || !result.Items) {
4646
throw new RecordNotFoundError(tableName, sessionId);
4747
}
4848

49-
return result.Items ?? [];
49+
return result.Items;
5050
}
5151

5252
const queryResult = await withRetry(queryRecord, logger, {
5353
maxRetries: 3,
5454
baseDelay: 300,
5555
});
5656

57-
const retrievedRecords = queryResult.map((v) => unmarshall(v)) as ReturnType[];
58-
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-
);
67-
}
68-
69-
if (!allowMultipleEntries && validRecords.length > 1) {
70-
throw new TooManyRecordsError(tableName, sessionId, validRecords.length);
57+
if (queryResult.length > 1) {
58+
throw new TooManyRecordsError(tableName, sessionId, queryResult.length);
7159
}
7260

73-
return validRecords;
61+
return unmarshall(queryResult[0]) as ReturnType;
7462
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
"expiryDate"
20+
);
21+
22+
return personIdentity;
23+
} catch (error) {
24+
if (error instanceof RecordNotFoundError) {
25+
logger.info(`No valid person identity record found.`);
26+
throw new CriError(500, `No person identity entry found for the given session ID.`);
27+
}
28+
29+
logger.error(`Caught unexpected person identity retrieval error: ${safeStringifyError(error)}`);
30+
31+
throw new CriError(500, "Unexpected error getting person identity");
32+
}
33+
}

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

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

0 commit comments

Comments
 (0)