Skip to content

Commit 88929e7

Browse files
committed
Address review comments
1 parent 26f15d4 commit 88929e7

File tree

6 files changed

+49
-31
lines changed

6 files changed

+49
-31
lines changed

lambdas/api-handler/src/config/deps.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ function createMIRepository(documentClient: DynamoDBDocumentClient, log: pino.Lo
3333
const ddbClient = new DynamoDBClient({});
3434
const docClient = DynamoDBDocumentClient.from(ddbClient);
3535
const config = {
36-
miTableName: envVars.MI_TABLE_NAME,
37-
ttlHours: envVars.LETTER_TTL_HOURS
36+
miTableName: envVars.MI_TABLE_NAME
3837
};
3938

4039
return new MIRepository(docClient, log, config);

lambdas/api-handler/src/contracts/mi.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,8 @@ export const PostMIRequestResourceSchema = z.object({
1414
}).strict();
1515

1616
export const PostMIResponseResourceSchema = z.object({
17-
type: z.literal('ManagementInformation'),
1817
id: z.string(),
19-
attributes: z.object({
20-
lineItem: z.string(),
21-
timestamp: z.string(),
22-
quantity: z.number(),
23-
specificationId: z.string().optional(),
24-
groupId: z.string().optional(),
25-
stockRemaining: z.number().optional(),
26-
}).strict()
18+
...PostMIRequestResourceSchema.shape,
2719
}).strict();
2820

2921
export const PostMIRequestSchema = makeDocumentSchema(PostMIRequestResourceSchema);

lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ const postMIRequest : PostMIRequest = {
2727
const requestBody = JSON.stringify(postMIRequest, null, 2);
2828

2929
const postMIResponse : PostMIResponse = {
30-
data: {
31-
id: 'id1',
32-
...postMIRequest.data
33-
}
30+
data: {
31+
id: 'id1',
32+
...postMIRequest.data
33+
}
3434
};
3535

3636
const mockedPostMIOperation = jest.mocked(miService.postMI);
@@ -72,11 +72,9 @@ describe('postMI API Handler', () => {
7272
});
7373

7474

75-
it.each([['not a date string', false], ['2025-10-16T00:00:00', false], ['2025-16-10T00:00:00Z', false],
76-
['2025-10-16T00:00:00Z', true], ['2025-10-16T00:00:00.000000Z', true]])
77-
('validates the timestamp', async (timestamp: string, valid: boolean) => {
75+
it('rejects invalid timestamps', async() => {
7876
const modifiedRequest = JSON.parse(requestBody);
79-
modifiedRequest['data']['attributes']['timestamp'] = timestamp;
77+
modifiedRequest['data']['attributes']['timestamp'] = '2025-02-31T00:00:00Z';
8078
const event = makeApiGwEvent({
8179
path: '/mi',
8280
body: JSON.stringify(modifiedRequest),
@@ -87,7 +85,7 @@ describe('postMI API Handler', () => {
8785
const result = await postMI(event, mockDeep<Context>(), jest.fn());
8886

8987
expect(result).toEqual(expect.objectContaining({
90-
statusCode: valid? 201: 400
88+
statusCode: 400
9189
}));
9290
});
9391

lambdas/api-handler/src/handlers/post-mi.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { postMI as postMIOperation } from '../services/mi-operations';
33
import { ApiErrorDetail } from "../contracts/errors";
44
import { ValidationError } from "../errors";
55
import { mapErrorToResponse } from "../mappers/error-mapper";
6-
import { assertNotEmpty, validateCommonHeaders } from "../utils/validation";
6+
import { assertNotEmpty, validateCommonHeaders, validateIso8601Timestamp } from "../utils/validation";
77
import { PostMIRequest, PostMIRequestSchema } from "../contracts/mi";
88
import { mapToMI } from "../mappers/mi-mapper";
99
import { Deps } from "../config/deps";
@@ -44,13 +44,4 @@ export function createPostMIHandler(deps: Deps): APIGatewayProxyHandler {
4444
return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger);
4545
}
4646
}
47-
48-
function validateIso8601Timestamp(timestamp: string) {
49-
50-
// If timestamp looks like a date, but is not valid (e.g. 2025-02-31T13:45:56Z), then new Date(timestamp).valueOf()
51-
// will return NaN
52-
if (! /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z/.test(timestamp) || Number.isNaN(new Date(timestamp).valueOf())) {
53-
throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp);
54-
}
55-
}
5647
};

lambdas/api-handler/src/utils/__tests__/validation.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { assertNotEmpty, lowerCaseKeys } from "../validation";
1+
import { ValidationError } from "../../errors";
2+
import { assertNotEmpty, lowerCaseKeys, validateIso8601Timestamp } from "../validation";
23

34
describe("assertNotEmpty", () => {
45
const error = new Error();
@@ -65,3 +66,16 @@ describe("lowerCaseKeys", () => {
6566
expect(result).toEqual({});
6667
});
6768
});
69+
70+
describe('validateIso8601Timestamp', () => {
71+
it.each([['2025-10-16T00:00:00.000Z'], ['2025-10-16T00:00:00Z'], ['2025-10-16T00:00:00.0Z'], ['2025-10-16T00:00:00.999999Z']])
72+
('permits valid timestamps', (timestamp: string) => {
73+
validateIso8601Timestamp(timestamp);
74+
});
75+
76+
it.each([['not a date string'], ['2025-10-16T00:00:00'], ['2025-16-10T00:00:00Z'], ['2025-02-31T00:00:00Z']])
77+
('rejects invalid timestamps', (timestamp: string) => {
78+
expect(() => validateIso8601Timestamp(timestamp)).toThrow(ValidationError);
79+
});
80+
81+
});

lambdas/api-handler/src/utils/validation.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,27 @@ export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps
6060

6161
return { ok: true, value: { correlationId, supplierId } };
6262
}
63+
64+
export function validateIso8601Timestamp(timestamp: string) {
65+
66+
function normalisePrecision([_, mainPart, fractionalPart='.000']: string[]) : string {
67+
if (fractionalPart.length < 4) {
68+
return mainPart + fractionalPart + '0'.repeat(4 - fractionalPart.length) + 'Z';
69+
} else {
70+
return mainPart + fractionalPart.slice(0, 4) + 'Z';
71+
}
72+
}
73+
74+
const groups = timestamp.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d+)?Z/);
75+
if (!groups) {
76+
throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp);
77+
}
78+
const date = new Date(timestamp);
79+
// An invalid month (e.g. '2025-16-10T00:00:00Z') will result in new Date(timestamp).valueOf() returning NaN.
80+
// An invalid day of month (e.g. '2025-02-31T00:00:00Z') will roll over into the following month, but we can
81+
// detect that by comparing date.toISOString() with the original timestamp string. We need to normalise the
82+
// original string to millisecond precision to make this work.
83+
if (Number.isNaN(new Date(timestamp).valueOf()) || date.toISOString() != normalisePrecision(groups)) {
84+
throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp);
85+
}
86+
}

0 commit comments

Comments
 (0)