Skip to content

Commit c724ccb

Browse files
OJ-3197 - Second pass at single-Lambda check HMRC
1 parent 11d4c1c commit c724ccb

36 files changed

Lines changed: 2795 additions & 19 deletions

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export class RecordExpiredError extends Error {
2+
public readonly name = "RecordExpiredError";
3+
24
constructor(
35
public readonly tableName: string,
46
public readonly sessionId: string,
@@ -7,7 +9,7 @@ export class RecordExpiredError extends Error {
79
super();
810
}
911

10-
get message() {
12+
public get message() {
1113
return `Found only expired records on the ${
1214
this.tableName
1315
} table for sessionId ${this.sessionId}: ${this.expiryDates
@@ -18,14 +20,32 @@ export class RecordExpiredError extends Error {
1820
}
1921

2022
export class RecordNotFoundError extends Error {
23+
public readonly name = "RecordNotFoundError";
24+
2125
constructor(
2226
public readonly tableName: string,
2327
public readonly sessionId: string
2428
) {
2529
super();
2630
}
2731

28-
get message() {
32+
public get message() {
2933
return `Failed to find a valid entry in the ${this.tableName} table with session ID ${this.sessionId}.`;
3034
}
3135
}
36+
37+
export class TooManyRecordsError extends Error {
38+
public readonly name = "TooManyRecordsError";
39+
40+
constructor(
41+
public readonly tableName: string,
42+
public readonly sessionId: string,
43+
public readonly recordCount: number
44+
) {
45+
super();
46+
}
47+
48+
public get message() {
49+
return `Found ${this.recordCount} records in ${this.tableName} for sessionId ${this.sessionId}! This should not be possible as sessionId should be unique.`;
50+
}
51+
}

lambdas/common/src/database/get-record-by-session-id.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@ import { Logger } from "@aws-lambda-powertools/logger";
1111
* The array will usually have length 1, but it's possible that multiple rows will be returned.
1212
*
1313
* Use a type parameter to set the type of the entity that will be returned.
14+
*
15+
* NB: as some tables may not guarantee that sessionId is unique (eg, multiple attempts for the
16+
* same session), this function only guarantees that there will be at least one non-expired
17+
* record in the returned array. If you're expecting only one, you can check the length of the
18+
* returned array and use the TooManyRecordsException available in ./exceptions/errors if it
19+
* suits your needs.
1420
*/
1521
export async function getRecordBySessionId<
1622
/**
17-
* The type that will be returned by the function. Must include sessionId and expiryDate keys.
23+
* The type that will be returned by the function. Must include sessionId key.
24+
*
25+
* If an expiryDate column exists, it will validate it.
1826
*/
19-
ReturnType extends { sessionId: string; expiryDate: number },
27+
ReturnType extends { sessionId: string; expiryDate?: number },
2028
>(
2129
/** The name of the table in DynamoDB. Probably looks like "some-table-some-stack". */
2230
tableName: string,
@@ -61,7 +69,7 @@ export async function getRecordBySessionId<
6169
throw new RecordExpiredError(
6270
tableName,
6371
sessionId,
64-
retrievedRecords.map((v) => v.expiryDate)
72+
retrievedRecords.map((v) => v.expiryDate ?? -1)
6573
);
6674
}
6775

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Logger } from "@aws-lambda-powertools/logger";
2+
import {
3+
DynamoDBClient,
4+
PutItemCommand,
5+
PutItemCommandOutput,
6+
} from "@aws-sdk/client-dynamodb";
7+
import { marshall } from "@aws-sdk/util-dynamodb";
8+
9+
// TODO NB: This function needs review - retry logic, error handling etc are missing.
10+
export async function insertRecord<T>(
11+
tableName: string,
12+
record: T,
13+
logger: Logger,
14+
dynamoClient: DynamoDBClient = new DynamoDBClient()
15+
): Promise<PutItemCommandOutput> {
16+
const saveCmd = new PutItemCommand({
17+
TableName: tableName,
18+
Item: marshall(record),
19+
});
20+
21+
const saveRes = await dynamoClient.send(saveCmd);
22+
23+
return saveRes;
24+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Logger } from "@aws-lambda-powertools/logger";
2+
import {
3+
DynamoDBClient,
4+
PutItemCommand,
5+
PutItemCommandOutput,
6+
} from "@aws-sdk/client-dynamodb";
7+
import { marshall } from "@aws-sdk/util-dynamodb";
8+
9+
// TODO NB: This function needs review - retry logic, error handling etc are missing.
10+
export async function updateRecordBySessionId<T extends { sessionId: string }>(
11+
tableName: string,
12+
record: Partial<T> & { sessionId: string },
13+
logger: Logger,
14+
dynamoClient: DynamoDBClient = new DynamoDBClient()
15+
): Promise<PutItemCommandOutput> {
16+
const { sessionId, ...properties } = record;
17+
18+
const txnCmd = new PutItemCommand({
19+
TableName: tableName,
20+
ConditionExpression: "sessionId = :value",
21+
ExpressionAttributeValues: {
22+
":value": {
23+
S: sessionId,
24+
},
25+
},
26+
Item: marshall(properties),
27+
});
28+
29+
const txnRes = await dynamoClient.send(txnCmd);
30+
31+
return txnRes;
32+
}
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
export function isRecordExpired(record: { expiryDate: number }) {
2-
// expiryDate is in Unix seconds, not milliseconds
3-
const now = Math.floor(Date.now() / 1000);
4-
return now > record.expiryDate;
1+
export function isRecordExpired(record: { expiryDate?: number }) {
2+
if ("expiryDate" in record) {
3+
// expiryDate is in Unix seconds, not milliseconds
4+
const now = Math.floor(Date.now() / 1000);
5+
return now > (record.expiryDate ?? 0);
6+
}
7+
return false;
58
}

lambdas/common/src/types/brands.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type Brand<T, N extends string> = T & {
2+
___brand___: N;
3+
};
4+
5+
export type UnixTimestamp = Brand<number, "UnixTimestamp">;
6+
7+
export type ISO8601DateString = Brand<string, "ISO8601DateString">;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
DynamoDBClient,
3+
PutItemCommand,
4+
PutItemCommandInput,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { insertRecord } from "../../src/database/insert-record";
7+
import { SessionItem } from "../../src/database/types/session-item";
8+
import { mockLogger } from "../logger";
9+
import { marshall } from "@aws-sdk/util-dynamodb";
10+
11+
const mockTableName = "some-stack-some-table";
12+
13+
const mockSessionItem: SessionItem = {
14+
expiryDate: 999,
15+
sessionId: "some-session",
16+
clientId: "aaa-aaa-aaa-aaa",
17+
clientSessionId: "blahblah-blahblah",
18+
authorizationCodeExpiryDate: 987,
19+
redirectUri: "example.com/redirect",
20+
accessToken: "yes-go-ahead",
21+
accessTokenExpiryDate: 456,
22+
clientIpAddress: "127.0.0.1",
23+
subject: "bob",
24+
};
25+
26+
jest.mock("@aws-sdk/client-dynamodb", () => ({
27+
PutItemCommand: jest.fn().mockImplementation((input) => ({
28+
type: "PutItemCommandClassInstance",
29+
input,
30+
})),
31+
DynamoDBClient: jest.fn().mockImplementation(() => ({
32+
send: jest.fn(),
33+
})),
34+
}));
35+
36+
const mockPutItemInput: PutItemCommandInput = {
37+
TableName: mockTableName,
38+
Item: marshall(mockSessionItem),
39+
};
40+
41+
const mockPutItemCommand = new PutItemCommand(mockPutItemInput);
42+
43+
const dynamoClient = new DynamoDBClient();
44+
45+
function buildMockInsertRes(status: number) {
46+
return {
47+
Attributes: undefined,
48+
ConsumedCapacity: undefined,
49+
ItemCollectionMetrics: undefined,
50+
$metadata: {
51+
httoStatusCode: status,
52+
},
53+
};
54+
}
55+
56+
const mockSuccessRes = buildMockInsertRes(201);
57+
58+
describe("insertRecord()", () => {
59+
it("calls the Dynamo client correctly for a given record", async () => {
60+
dynamoClient.send = jest.fn().mockResolvedValue(mockSuccessRes);
61+
62+
const result = await insertRecord<SessionItem>(
63+
mockTableName,
64+
mockSessionItem,
65+
mockLogger,
66+
dynamoClient
67+
);
68+
69+
expect(result).toEqual(mockSuccessRes);
70+
expect(dynamoClient.send).toHaveBeenCalledTimes(1);
71+
expect(dynamoClient.send).toHaveBeenCalledWith(mockPutItemCommand);
72+
expect(PutItemCommand).toHaveBeenCalledWith(mockPutItemInput);
73+
});
74+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
DynamoDBClient,
3+
PutItemCommand,
4+
PutItemCommandInput,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { SessionItem } from "../../src/database/types/session-item";
7+
import { mockLogger } from "../logger";
8+
import { marshall } from "@aws-sdk/util-dynamodb";
9+
import { updateRecordBySessionId } from "../../src/database/update-record-by-session-id";
10+
11+
const mockTableName = "some-stack-some-table";
12+
13+
const sessionId = "some-session";
14+
15+
const mockSessionData: Omit<SessionItem, "sessionId"> = {
16+
expiryDate: 999,
17+
clientId: "aaa-aaa-aaa-aaa",
18+
clientSessionId: "blahblah-blahblah",
19+
authorizationCodeExpiryDate: 987,
20+
redirectUri: "example.com/redirect",
21+
accessToken: "yes-go-ahead",
22+
accessTokenExpiryDate: 456,
23+
clientIpAddress: "127.0.0.1",
24+
subject: "bob",
25+
};
26+
27+
jest.mock("@aws-sdk/client-dynamodb", () => ({
28+
PutItemCommand: jest.fn().mockImplementation((input) => ({
29+
type: "PutItemCommandClassInstance",
30+
input,
31+
})),
32+
DynamoDBClient: jest.fn().mockImplementation(() => ({
33+
send: jest.fn(),
34+
})),
35+
}));
36+
37+
const mockPutItemInput: PutItemCommandInput = {
38+
TableName: mockTableName,
39+
ConditionExpression: "sessionId = :value",
40+
ExpressionAttributeValues: {
41+
":value": {
42+
S: sessionId,
43+
},
44+
},
45+
Item: marshall(mockSessionData),
46+
};
47+
48+
const mockPutItemCommand = new PutItemCommand(mockPutItemInput);
49+
50+
const dynamoClient = new DynamoDBClient();
51+
52+
function buildMockInsertRes(status: number) {
53+
return {
54+
Attributes: undefined,
55+
ConsumedCapacity: undefined,
56+
ItemCollectionMetrics: undefined,
57+
$metadata: {
58+
httoStatusCode: status,
59+
},
60+
};
61+
}
62+
63+
const mockSuccessRes = buildMockInsertRes(201);
64+
65+
describe("updateRecordBySessionId()", () => {
66+
it("calls the Dynamo client correctly for a given record", async () => {
67+
dynamoClient.send = jest.fn().mockResolvedValue(mockSuccessRes);
68+
69+
const result = await updateRecordBySessionId<SessionItem>(
70+
mockTableName,
71+
{ sessionId, ...mockSessionData },
72+
mockLogger,
73+
dynamoClient
74+
);
75+
76+
expect(result).toEqual(mockSuccessRes);
77+
expect(dynamoClient.send).toHaveBeenCalledTimes(1);
78+
expect(dynamoClient.send).toHaveBeenCalledWith(mockPutItemCommand);
79+
expect(PutItemCommand).toHaveBeenCalledWith(mockPutItemInput);
80+
});
81+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { isRecordExpired } from "../../../src/database/util/is-record-expired";
2+
3+
describe("isRecordExpired()", () => {
4+
it("returns true for times in the past", () => {
5+
expect(
6+
isRecordExpired({ expiryDate: Date.now() / 1000 - 30 })
7+
).toStrictEqual(true);
8+
});
9+
10+
it(`returns false for times in the future`, () => {
11+
expect(
12+
isRecordExpired({ expiryDate: Date.now() / 1000 + 30 })
13+
).toStrictEqual(false);
14+
});
15+
16+
it(`returns true for negative times`, () => {
17+
expect(isRecordExpired({ expiryDate: -300 })).toStrictEqual(true);
18+
});
19+
});

lambdas/common/tests/logger.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ export const mockLogger = {
77
error: jest.fn(),
88
critical: jest.fn(),
99
} as unknown as Logger;
10+
11+
export const mockLogHelper = {
12+
context: {},
13+
govJourneyId: "my-journey",
14+
logger: mockLogger,
15+
handlerStartTime: new Date().toISOString(),
16+
logEntry: jest.fn(),
17+
logError: jest.fn(),
18+
};

0 commit comments

Comments
 (0)