diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 1b60ce0a..1392cc33 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -40,6 +40,7 @@ No requirements. | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-ssl.zip | n/a | ## Outputs diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf index c4cd1b16..2e90b260 100644 --- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf @@ -50,9 +50,10 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { resources = [ module.authorizer_lambda.function_arn, module.get_letter.function_arn, + module.get_letter_data.function_arn, module.get_letters.function_arn, module.patch_letter.function_arn, - module.get_letter_data.function_arn + module.post_mi.function_arn ] } } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 5a2b4aa5..513e8da1 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -5,22 +5,25 @@ locals { root_domain_nameservers = local.acct.route53_zone_nameservers["supplier-api"] openapi_spec = templatefile("${path.module}/resources/spec.tmpl.json", { - APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn - AWS_REGION = var.region - AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn - GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn - GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn + APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn + AWS_REGION = var.region + AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn + GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn + GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn - PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn + PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn + POST_MI_LAMBDA_ARN = module.post_mi.function_arn }) destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" common_lambda_env_vars = { - LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, - LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours - SUPPLIER_ID_HEADER = "nhsd-supplier-id", - APIM_CORRELATION_HEADER = "nhsd-correlation-id", + LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, + MI_TABLE_NAME = aws_dynamodb_table.mi.name, + LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours + MI_TTL_HOURS = 2160 # 90 days * 24 hours + SUPPLIER_ID_HEADER = "nhsd-supplier-id", + APIM_CORRELATION_HEADER = "nhsd-correlation-id", DOWNLOAD_URL_TTL_SECONDS = 60 } } diff --git a/infrastructure/terraform/components/api/module_lambda_post_mi.tf b/infrastructure/terraform/components/api/module_lambda_post_mi.tf new file mode 100644 index 00000000..9398a3cf --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_post_mi.tf @@ -0,0 +1,68 @@ +module "post_mi" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + + function_name = "post_mi" + description = "Add management information" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.post_mi_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "api-handler/dist" + function_include_common = true + handler_function_name = "postMI" + runtime = "nodejs22.x" + memory = 128 + timeout = 5 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + send_to_firehose = true + log_destination_arn = local.destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = merge(local.common_lambda_env_vars, {}) +} + +data "aws_iam_policy_document" "post_mi_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, ## Requires shared kms module + ] + } + + statement { + sid = "AllowDynamoDBAccess" + effect = "Allow" + + actions = [ + "dynamodb:PutItem", + ] + + resources = [ + aws_dynamodb_table.mi.arn, + ] + } +} diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 50896857..4514619b 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -202,6 +202,45 @@ } } ] + }, + "/mi": { + "post": { + "description": "Provide management information.", + "operationId": "postMI", + "requestBody": { + "required": true + }, + "responses": { + "201": { + "description": "Resource created" + }, + "400": { + "description": "Bad request, invalid input data" + }, + "500": { + "description": "Server error" + } + }, + "security": [ + { + "LambdaAuthorizer": [] + } + ], + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${POST_MI_LAMBDA_ARN}/invocations" + } + } } } } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index cd59b16f..85f89193 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -30,7 +30,9 @@ export async function setupDynamoDBContainer() { region: 'us-west-2', endpoint, lettersTableName: 'letters', - ttlHours: 1 + miTableName: 'management-info', + lettersTtlHours: 1, + miTtlHours: 1 }; return { @@ -44,10 +46,7 @@ export async function setupDynamoDBContainer() { export type DBContext = Awaited>; -export async function createTables(context: DBContext) { - const { ddbClient } = context; - - await ddbClient.send(new CreateTableCommand({ +const createLetterTableCommand = new CreateTableCommand({ TableName: 'letters', BillingMode: 'PAY_PER_REQUEST', KeySchema: [ @@ -72,15 +71,37 @@ export async function createTables(context: DBContext) { { AttributeName: 'supplierStatus', AttributeType: 'S' }, { AttributeName: 'supplierStatusSk', AttributeType: 'S' }, ] - })); + }); - await ddbClient.send(new UpdateTimeToLiveCommand({ +const updateTimeToLiveCommand = new UpdateTimeToLiveCommand({ TableName: 'letters', TimeToLiveSpecification: { AttributeName: 'ttl', Enabled: true } - })); + }); + +const createMITableCommand = new CreateTableCommand({ + TableName: 'management-info', + BillingMode: 'PAY_PER_REQUEST', + KeySchema: [ + { AttributeName: 'supplierId', KeyType: 'HASH' }, // Partition key + { AttributeName: 'id', KeyType: 'RANGE' } // Sort key + ], + AttributeDefinitions: [ + { AttributeName: 'supplierId', AttributeType: 'S' }, + { AttributeName: 'id', AttributeType: 'S' }, + ] + }); + + +export async function createTables(context: DBContext) { + const { ddbClient } = context; + + await ddbClient.send(createLetterTableCommand); + await ddbClient.send(updateTimeToLiveCommand); + + await ddbClient.send(createMITableCommand); } @@ -90,4 +111,8 @@ export async function deleteTables(context: DBContext) { await ddbClient.send(new DeleteTableCommand({ TableName: 'letters' })); + + await ddbClient.send(new DeleteTableCommand({ + TableName: 'management-info' + })); } diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index cd7da96f..6e336468 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -307,7 +307,7 @@ describe('LetterRepository', () => { const mockSend = jest.fn().mockResolvedValue({ Items: null }); const mockDdbClient = { send: mockSend } as any; - const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', ttlHours: 1 }); + const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', lettersTtlHours: 1 }); const letters = await repo.getLettersBySupplier('supplier1', 'PENDING', 10); expect(letters).toEqual([]); diff --git a/internal/datastore/src/__test__/mi-repository.test.ts b/internal/datastore/src/__test__/mi-repository.test.ts new file mode 100644 index 00000000..b0fa2e39 --- /dev/null +++ b/internal/datastore/src/__test__/mi-repository.test.ts @@ -0,0 +1,67 @@ +import { Logger } from "pino"; +import { setupDynamoDBContainer, createTables, DBContext, deleteTables } from "./db"; +import { createTestLogger, LogStream } from "./logs"; +import { MIRepository } from "../mi-repository"; + +// Database tests can take longer, especially with setup and teardown +jest.setTimeout(30000); + + +describe('MiRepository', () => { + let db: DBContext; + let miRepository: MIRepository; + let logStream: LogStream; + let logger: Logger; + + + beforeAll(async () => { + db = await setupDynamoDBContainer(); + }); + + beforeEach(async () => { + await createTables(db); + ( + { logStream, logger } = createTestLogger() + ); + + miRepository = new MIRepository(db.docClient, logger, db.config); + }); + + afterEach(async () => { + await deleteTables(db); + jest.useRealTimers(); + }); + + afterAll(async () => { + await db.container.stop(); + }); + + describe('putMi', () => { + + it('creates a letter with id and timestamps', async () => { + + jest.useFakeTimers(); + // Month is zero-indexed in JS Date + jest.setSystemTime(new Date(2020, 1, 1)); + const mi = { + specificationId: 'spec1', + supplierId: 'supplier1', + groupId:'group1', + lineItem: 'item1', + quantity: 12, + timestamp: new Date().toISOString(), + stockRemaining: 0 + }; + + const persistedMi = await(miRepository.putMI(mi)); + + expect(persistedMi).toEqual(expect.objectContaining({ + id: expect.any(String), + createdAt: '2020-02-01T00:00:00.000Z', + updatedAt: '2020-02-01T00:00:00.000Z', + ttl: 1580518800, // 2020-02-01T00:01:00.000Z, seconds since epoch + ...mi + })); + }); + }); +}); diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts index ec92d7ad..92081caf 100644 --- a/internal/datastore/src/config.ts +++ b/internal/datastore/src/config.ts @@ -2,5 +2,7 @@ export type DatastoreConfig = { region: string, endpoint?: string, lettersTableName: string, - ttlHours: number + miTableName: string, + lettersTtlHours: number, + miTtlHours: number } diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 3c0ebfaf..20f80e92 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -1,3 +1,4 @@ export * from './types'; +export * from './mi-repository'; export * from './letter-repository'; export * from './types'; diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 2afe0792..05bc0e3d 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -23,7 +23,7 @@ const defaultPagingOptions = { export type LetterRepositoryConfig = { lettersTableName: string, - ttlHours: number + lettersTtlHours: number } export class LetterRepository { @@ -37,7 +37,7 @@ export class LetterRepository { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, supplierStatusSk: new Date().toISOString(), - ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours) + ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) }; try { await this.ddbClient.send(new PutCommand({ @@ -68,7 +68,7 @@ export class LetterRepository { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, supplierStatusSk: Date.now().toString(), - ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours) + ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) }); if (lettersDb.length === 25 || i === letters.length - 1) { @@ -143,7 +143,7 @@ export class LetterRepository { ':status': letterToUpdate.status, ':updatedAt': new Date().toISOString(), ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, - ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours) + ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) }; if (letterToUpdate.reasonCode) diff --git a/internal/datastore/src/mi-repository.ts b/internal/datastore/src/mi-repository.ts new file mode 100644 index 00000000..c3d1fd35 --- /dev/null +++ b/internal/datastore/src/mi-repository.ts @@ -0,0 +1,38 @@ +import { + DynamoDBDocumentClient, + PutCommand +} from '@aws-sdk/lib-dynamodb'; +import { MI, MISchema } from './types'; +import { Logger } from 'pino'; +import { v4 as uuidv4 } from 'uuid'; + +export type MIRepositoryConfig = { + miTableName: string, + miTtlHours: number +}; + +export class MIRepository { + constructor(readonly ddbClient: DynamoDBDocumentClient, + readonly log: Logger, + readonly config: MIRepositoryConfig) { + } + + async putMI(mi: Omit): Promise { + + const now = new Date().toISOString(); + const miDb = { + ...mi, + id: uuidv4(), + createdAt: now, + updatedAt: now, + ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.miTtlHours) + }; + + await this.ddbClient.send(new PutCommand({ + TableName: this.config.miTableName, + Item: miDb + })); + + return MISchema.parse(miDb); + } +}; diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 036d86e0..88c18f36 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -46,16 +46,22 @@ export const LetterSchema = LetterSchemaBase.extend({ export type Letter = z.infer; export type LetterBase = z.infer; -export const MISchema = z.object({ +export const MISchemaBase = z.object({ id: z.string(), - supplierId: idRef(SupplierSchema), - specificationId: z.string(), - groupId: z.string(), lineItem: z.string(), + timestamp: z.string(), quantity: z.number(), - stockRemaining: z.number(), + specificationId: z.string().optional(), + groupId: z.string().optional(), + stockRemaining: z.number().optional() +}); + +export const MISchema = MISchemaBase.extend({ + supplierId: idRef(SupplierSchema), createdAt: z.string(), - updatedAt: z.string() + updatedAt: z.string(), + ttl: z.int(), }).describe('MI'); export type MI = z.infer; +export type MIBase = z.infer; diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index 2028db88..3e2911a9 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -2,6 +2,17 @@ import type { Deps } from '../deps'; describe('createDependenciesContainer', () => { + + const env = { + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: 12960, + MI_TABLE_NAME: 'MITable', + MI_TTL_HOURS: 2160, + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: 60 + }; + beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); @@ -24,25 +35,21 @@ describe('createDependenciesContainer', () => { // Repo client jest.mock('../../../../../internal/datastore', () => ({ LetterRepository: jest.fn(), + MIRepository: jest.fn(), })); // Env - jest.mock('../env', () => ({ - envVars: { - LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: 12960, - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: 60 - }, - })); + jest.mock('../env', () => ({envVars: env})); }); test('constructs deps and wires repository config correctly', async () => { // get current mock instances const { S3Client } = jest.requireMock('@aws-sdk/client-s3') as { S3Client: jest.Mock }; const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; - const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock }; + const { LetterRepository, MIRepository } = jest.requireMock('../../../../../internal/datastore') as { + LetterRepository: jest.Mock, + MIRepository: jest.Mock + }; const { createDependenciesContainer } = require('../deps'); const deps: Deps = createDependenciesContainer(); @@ -51,18 +58,19 @@ describe('createDependenciesContainer', () => { expect(pinoMock.default).toHaveBeenCalledTimes(1); expect(LetterRepository).toHaveBeenCalledTimes(1); - const repoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0]; - expect(repoCtorArgs[2]).toEqual({ + const letterRepoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0]; + expect(letterRepoCtorArgs[2]).toEqual({ lettersTableName: 'LettersTable', - ttlHours: 12960 + lettersTtlHours: 12960 }); - expect(deps.env).toEqual({ - LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: 12960, - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: 60 + expect(MIRepository).toHaveBeenCalledTimes(1); + const miRepoCtorArgs = (MIRepository as jest.Mock).mock.calls[0]; + expect(miRepoCtorArgs[2]).toEqual({ + miTableName: 'MITable', + miTtlHours: 2160 }); + + expect(deps.env).toEqual(env); }); }); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index c24205ab..2399c941 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -16,7 +16,9 @@ describe('lambdaEnv', () => { process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; process.env.LETTERS_TABLE_NAME = 'letters-table'; + process.env.MI_TABLE_NAME = 'mi-table'; process.env.LETTER_TTL_HOURS = '12960'; + process.env.MI_TTL_HOURS = '2160'; process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; process.env.MAX_LIMIT = '2500'; @@ -26,7 +28,9 @@ describe('lambdaEnv', () => { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'letters-table', + MI_TABLE_NAME: 'mi-table', LETTER_TTL_HOURS: 12960, + MI_TTL_HOURS: 2160, DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: 2500, }); @@ -36,7 +40,9 @@ describe('lambdaEnv', () => { process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var + process.env.MI_TABLE_NAME = 'mi-table'; process.env.LETTER_TTL_HOURS = '12960'; + process.env.MI_TTL_HOURS = '2160'; process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; expect(() => require('../env')).toThrow(ZodError); @@ -46,7 +52,9 @@ describe('lambdaEnv', () => { process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; process.env.LETTERS_TABLE_NAME = 'letters-table'; + process.env.MI_TABLE_NAME = 'mi-table'; process.env.LETTER_TTL_HOURS = '12960'; + process.env.MI_TTL_HOURS = '2160'; process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; const { envVars } = require('../env'); @@ -55,7 +63,9 @@ describe('lambdaEnv', () => { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'letters-table', + MI_TABLE_NAME: 'mi-table', LETTER_TTL_HOURS: 12960, + MI_TTL_HOURS: 2160, DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: undefined }); diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index 6f9c3e84..1942bc6c 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -2,34 +2,53 @@ import { S3Client } from "@aws-sdk/client-s3"; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import pino from 'pino'; -import { LetterRepository } from '../../../../internal/datastore'; +import { LetterRepository, MIRepository } from '../../../../internal/datastore'; import { envVars, EnvVars } from "../config/env"; export type Deps = { s3Client: S3Client; letterRepo: LetterRepository; - logger: pino.Logger, + miRepo: MIRepository; + logger: pino.Logger; env: EnvVars }; -function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository { +function createDocumentClient(): DynamoDBDocumentClient { + const ddbClient = new DynamoDBClient({}); + return DynamoDBDocumentClient.from(ddbClient); +} + +function createLetterRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): LetterRepository { const ddbClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(ddbClient); const config = { lettersTableName: envVars.LETTERS_TABLE_NAME, - ttlHours: envVars.LETTER_TTL_HOURS + lettersTtlHours: envVars.LETTER_TTL_HOURS }; return new LetterRepository(docClient, log, config); } +function createMIRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): MIRepository { + const ddbClient = new DynamoDBClient({}); + const docClient = DynamoDBDocumentClient.from(ddbClient); + const config = { + miTableName: envVars.MI_TABLE_NAME, + miTtlHours: envVars.MI_TTL_HOURS + }; + + return new MIRepository(docClient, log, config); +} + export function createDependenciesContainer(): Deps { const log = pino(); + const documentClient = createDocumentClient(); return { s3Client: new S3Client(), - letterRepo: createLetterRepository(log, envVars), + letterRepo: createLetterRepository(documentClient, log, envVars), + miRepo: createMIRepository(documentClient, log, envVars), logger: log, env: envVars }; diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index 5dfe4d27..f77f455e 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -4,7 +4,9 @@ const EnvVarsSchema = z.object({ SUPPLIER_ID_HEADER: z.string(), APIM_CORRELATION_HEADER: z.string(), LETTERS_TABLE_NAME: z.string(), + MI_TABLE_NAME: z.string(), LETTER_TTL_HOURS: z.coerce.number().int(), + MI_TTL_HOURS: z.coerce.number().int(), DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int(), MAX_LIMIT: z.coerce.number().int().optional() }); diff --git a/lambdas/api-handler/src/contracts/errors.ts b/lambdas/api-handler/src/contracts/errors.ts index f3b3114e..a508739b 100644 --- a/lambdas/api-handler/src/contracts/errors.ts +++ b/lambdas/api-handler/src/contracts/errors.ts @@ -34,7 +34,8 @@ export enum ApiErrorDetail { InvalidRequestLimitNotANumber = 'The limit parameter is not a number', InvalidRequestLimitNotInRange = 'The limit parameter must be a positive number not greater than %s', InvalidRequestLimitOnly = "Only 'limit' query parameter is supported", - InvalidRequestNoRequestId = 'The request does not contain a request id' + InvalidRequestNoRequestId = 'The request does not contain a request id', + InvalidRequestTimestamp = 'Timestamps should be UTC date/times in ISO8601 format, with a Z suffix' } export function buildApiError(params: { diff --git a/lambdas/api-handler/src/contracts/mi.ts b/lambdas/api-handler/src/contracts/mi.ts new file mode 100644 index 00000000..9223a636 --- /dev/null +++ b/lambdas/api-handler/src/contracts/mi.ts @@ -0,0 +1,27 @@ +import z from "zod"; +import { makeDocumentSchema } from "./json-api"; + +export const PostMIRequestResourceSchema = z.object({ + type: z.literal('ManagementInformation'), + attributes: z.object({ + lineItem: z.string(), + timestamp: z.string(), + quantity: z.number(), + specificationId: z.string().optional(), + groupId: z.string().optional(), + stockRemaining: z.number().optional(), + }).strict() +}).strict(); + +export const PostMIResponseResourceSchema = z.object({ + id: z.string(), + ...PostMIRequestResourceSchema.shape, +}).strict(); + +export const PostMIRequestSchema = makeDocumentSchema(PostMIRequestResourceSchema); +export const PostMIResponseSchema = makeDocumentSchema(PostMIResponseResourceSchema); + +export type PostMIRequest = z.infer; +export type PostMIResponse = z.infer; + +export type IncomingMI = PostMIRequest['data']['attributes'] & {supplierId: string}; diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index 5b6509a8..3cbde039 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -37,7 +37,7 @@ describe('API Lambda handler', () => { LETTER_TTL_HOURS: 12960, DOWNLOAD_URL_TTL_SECONDS: 60 } as unknown as EnvVars - } + } as Deps; beforeEach(() => { jest.clearAllMocks(); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts index 99b01b74..a21325a5 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts @@ -28,7 +28,7 @@ describe('API Lambda handler', () => { DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: 2500 } as unknown as EnvVars - }; + } as Deps; beforeEach(() => { jest.clearAllMocks(); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index bc000f30..5177f25d 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -38,7 +38,7 @@ describe('API Lambda handler', () => { DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: 2500 } as unknown as EnvVars - } + } as Deps; beforeEach(() => { jest.clearAllMocks(); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 8c96ce79..8e43b252 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -47,17 +47,17 @@ describe('patchLetter API Handler', () => { }); const mockedDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 12960, - DOWNLOAD_URL_TTL_SECONDS: 60 - } as unknown as EnvVars - } + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60 + } as unknown as EnvVars + } as Deps; it('returns 200 OK with updated resource', async () => { const event = makeApiGwEvent({ diff --git a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts new file mode 100644 index 00000000..d7d942e1 --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts @@ -0,0 +1,203 @@ +import { Context } from "aws-lambda"; +import { mockDeep } from "jest-mock-extended"; +import { makeApiGwEvent } from "./utils/test-utils"; +import { PostMIRequest, PostMIResponse } from "../../contracts/mi"; +import * as miService from '../../services/mi-operations'; +import pino from 'pino'; +import { MIRepository } from "../../../../../internal/datastore/src"; +import { Deps } from "../../config/deps"; +import { EnvVars } from "../../config/env"; +import { createPostMIHandler } from "../post-mi"; + +jest.mock('../../services/mi-operations'); + +const postMIRequest : PostMIRequest = { + data: { + type: 'ManagementInformation', + attributes: { + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000 + } + } +}; +const requestBody = JSON.stringify(postMIRequest, null, 2); + + const postMIResponse : PostMIResponse = { + data: { + id: 'id1', + ...postMIRequest.data + } + }; + +const mockedPostMIOperation = jest.mocked(miService.postMI); + +beforeEach(() => { + jest.clearAllMocks(); +}); + + +describe('postMI API Handler', () => { + + const mockedDeps: jest.Mocked = { + miRepo: {} as unknown as MIRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: 1 + } as unknown as EnvVars + } as Deps; + + + it('returns 200 OK with updated resource', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: requestBody, + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + mockedPostMIOperation.mockResolvedValue(postMIResponse); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual({ + statusCode: 201, + body: JSON.stringify(postMIResponse, null, 2) + }); + }); + + + it('rejects invalid timestamps', async() => { + const modifiedRequest = JSON.parse(requestBody); + modifiedRequest['data']['attributes']['timestamp'] = '2025-02-31T00:00:00Z'; + const event = makeApiGwEvent({ + path: '/mi', + body: JSON.stringify(modifiedRequest), + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 400 + })); + }); + + it('returns 400 Bad Request when there is no body', async () => { + const event = makeApiGwEvent({ + path: '/mi', + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 400 + })); + }); + + + it('returns 500 Internal Error when error is thrown by service', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: requestBody, + pathParameters: {id: 'id1'}, + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + mockedPostMIOperation.mockRejectedValue(new Error()); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 500 + })); + }); + + it('returns 500 Bad Request when supplier id is missing', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: requestBody, + headers: {'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 500 + })); + }); + + it('returns 500 Internal Server Error when correlation id is missing', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: requestBody, + headers: {'nhsd-supplier-id': 'supplier1', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 500 + })); + }); + + it('returns 400 Bad Request when request does not have correct shape', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: '{"test": "test"}', + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 400 + })); + }); + + it('returns 400 Bad Request when request body is not json', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: '{#invalidJSON', + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 400 + })); + }); + + it('returns 500 Internal Server Error when parsing fails', async () => { + const event = makeApiGwEvent({ + path: '/mi', + body: requestBody, + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'} + }); + const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => { + throw 'Unexpected error'; + }) + + const postMI = createPostMIHandler(mockedDeps); + const result = await postMI(event, mockDeep(), jest.fn()); + + expect(result).toEqual(expect.objectContaining({ + statusCode: 500 + })); + + spy.mockRestore(); + }); +}); diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts new file mode 100644 index 00000000..526f849b --- /dev/null +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -0,0 +1,47 @@ +import { APIGatewayProxyHandler } from "aws-lambda"; +import { postMI as postMIOperation } from '../services/mi-operations'; +import { ApiErrorDetail } from "../contracts/errors"; +import { ValidationError } from "../errors"; +import { mapErrorToResponse } from "../mappers/error-mapper"; +import { assertNotEmpty, validateCommonHeaders, validateIso8601Timestamp } from "../utils/validation"; +import { PostMIRequest, PostMIRequestSchema } from "../contracts/mi"; +import { mapToMI } from "../mappers/mi-mapper"; +import { Deps } from "../config/deps"; + +export function createPostMIHandler(deps: Deps): APIGatewayProxyHandler { + + return async (event) => { + + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } + + try { + const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); + + let postMIRequest: PostMIRequest; + + try { + postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); + } catch (error) { + if (error instanceof Error) { + throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); + } + else throw error; + } + validateIso8601Timestamp(postMIRequest.data.attributes.timestamp); + + const result = await postMIOperation(mapToMI(postMIRequest, commonHeadersResult.value.supplierId), deps.miRepo); + + return { + statusCode: 201, + body: JSON.stringify(result, null, 2) + }; + + } catch (error) { + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + } + } +}; diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index bca91bc9..38328703 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -3,6 +3,7 @@ import { createGetLetterHandler } from "./handlers/get-letter"; import { createGetLetterDataHandler } from "./handlers/get-letter-data"; import { createGetLettersHandler } from "./handlers/get-letters"; import { createPatchLetterHandler } from "./handlers/patch-letter"; +import { createPostMIHandler } from "./handlers/post-mi"; const container = createDependenciesContainer(); @@ -10,3 +11,4 @@ export const getLetter = createGetLetterHandler(container); export const getLetterData = createGetLetterDataHandler(container); export const getLetters = createGetLettersHandler(container); export const patchLetter = createPatchLetterHandler(container); +export const postMI = createPostMIHandler(container); diff --git a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts new file mode 100644 index 00000000..be1a55c1 --- /dev/null +++ b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts @@ -0,0 +1,62 @@ +import { MIBase } from "../../../../../internal/datastore/src"; +import { IncomingMI, PostMIRequest } from "../../contracts/mi"; +import { mapToMI, mapToPostMIResponse } from "../mi-mapper"; + +describe ('mi-mapper', () => { + it('maps a PostMIRequest to an IncomingMI object', async () => { + const postMIRequest: PostMIRequest = { + data: { + type: 'ManagementInformation', + attributes: { + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000 + } + } + }; + + const result: IncomingMI = mapToMI(postMIRequest, 'supplier1'); + + expect(result).toEqual({ + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000, + supplierId: 'supplier1' + }); + }); + + it('maps an internal MIBase object to a PostMIResponse', async() => { + const mi: MIBase = { + id: 'id1', + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000 + }; + + const result = mapToPostMIResponse(mi); + + expect(result).toEqual({ + data: { + id: 'id1', + type: 'ManagementInformation', + attributes: { + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000 + } + } + }); + }); +}); diff --git a/lambdas/api-handler/src/mappers/mi-mapper.ts b/lambdas/api-handler/src/mappers/mi-mapper.ts new file mode 100644 index 00000000..83848e1a --- /dev/null +++ b/lambdas/api-handler/src/mappers/mi-mapper.ts @@ -0,0 +1,26 @@ +import { MIBase } from "../../../../internal/datastore/src"; +import { IncomingMI, PostMIRequest as PostMIRequest, PostMIResponse, PostMIResponseSchema } from "../contracts/mi"; + +export function mapToMI(request: PostMIRequest, supplierId: string): IncomingMI { + return { + supplierId: supplierId, + ...request.data.attributes + }; +} + +export function mapToPostMIResponse(mi: MIBase): PostMIResponse { + return PostMIResponseSchema.parse({ + data: { + id: mi.id, + type: 'ManagementInformation', + attributes: { + lineItem: mi.lineItem, + timestamp: mi.timestamp, + quantity: mi.quantity, + specificationId: mi.specificationId, + groupId: mi.groupId, + stockRemaining: mi.stockRemaining + } + } + }); +} diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 7c24c958..284931e8 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -175,7 +175,7 @@ describe('getLetterDataUrl function', () => { APIM_CORRELATION_HEADER: 'nhsd-correlation-id', DOWNLOAD_URL_TTL_SECONDS: 60 }; - const deps: Deps = { s3Client, letterRepo, logger, env }; + const deps: Deps = { s3Client, letterRepo, logger, env } as Deps; it('should return pre signed url successfully', async () => { diff --git a/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts new file mode 100644 index 00000000..6dd245de --- /dev/null +++ b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts @@ -0,0 +1,40 @@ +import { IncomingMI } from "../../contracts/mi"; +import { postMI } from "../mi-operations"; + +describe('postMI function', () => { + + const incomingMi: IncomingMI = { + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000, + supplierId: 'supplier1' + }; + + it('creates the MI in the repository', async () => { + const persistedMi = {id: 'id1', ...incomingMi}; + + const mockRepo = { + putMI: jest.fn().mockResolvedValue(persistedMi) + }; + + const result = await postMI(incomingMi, mockRepo as any); + + expect(result).toEqual({ + data: { + id: 'id1', + type: 'ManagementInformation', + attributes: { + lineItem: 'envelope-business-standard', + timestamp: '2023-11-17T14:27:51.413Z', + quantity: 22, + specificationId: 'spec1', + groupId: 'group1', + stockRemaining: 20000 + } + } + }); + }); +}); diff --git a/lambdas/api-handler/src/services/mi-operations.ts b/lambdas/api-handler/src/services/mi-operations.ts new file mode 100644 index 00000000..2c574b67 --- /dev/null +++ b/lambdas/api-handler/src/services/mi-operations.ts @@ -0,0 +1,7 @@ +import { MIRepository } from "../../../../internal/datastore/src/mi-repository"; +import { IncomingMI, PostMIResponse } from "../contracts/mi"; +import { mapToPostMIResponse } from "../mappers/mi-mapper"; + +export const postMI = async (incomingMi: IncomingMI, miRepo: MIRepository): Promise => { + return mapToPostMIResponse(await miRepo.putMI(incomingMi)); +} diff --git a/lambdas/api-handler/src/utils/__tests__/validation.test.ts b/lambdas/api-handler/src/utils/__tests__/validation.test.ts index 8725cabb..1a95330c 100644 --- a/lambdas/api-handler/src/utils/__tests__/validation.test.ts +++ b/lambdas/api-handler/src/utils/__tests__/validation.test.ts @@ -1,4 +1,5 @@ -import { assertNotEmpty, lowerCaseKeys } from "../validation"; +import { ValidationError } from "../../errors"; +import { assertNotEmpty, lowerCaseKeys, validateIso8601Timestamp } from "../validation"; describe("assertNotEmpty", () => { const error = new Error(); @@ -65,3 +66,16 @@ describe("lowerCaseKeys", () => { expect(result).toEqual({}); }); }); + +describe('validateIso8601Timestamp', () => { + 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']]) + ('permits valid timestamps', (timestamp: string) => { + validateIso8601Timestamp(timestamp); + }); + + it.each([['not a date string'], ['2025-10-16T00:00:00'], ['2025-16-10T00:00:00Z'], ['2025-02-31T00:00:00Z']]) + ('rejects invalid timestamps', (timestamp: string) => { + expect(() => validateIso8601Timestamp(timestamp)).toThrow(ValidationError); + }); + +}); diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index 318131c4..f86b4fff 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -60,3 +60,27 @@ export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps return { ok: true, value: { correlationId, supplierId } }; } + +export function validateIso8601Timestamp(timestamp: string) { + + function normalisePrecision([_, mainPart, fractionalPart='.000']: string[]) : string { + if (fractionalPart.length < 4) { + return mainPart + fractionalPart + '0'.repeat(4 - fractionalPart.length) + 'Z'; + } else { + return mainPart + fractionalPart.slice(0, 4) + 'Z'; + } + } + + const groups = timestamp.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d+)?Z/); + if (!groups) { + throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp); + } + const date = new Date(timestamp); + // An invalid month (e.g. '2025-16-10T00:00:00Z') will result in new Date(timestamp).valueOf() returning NaN. + // An invalid day of month (e.g. '2025-02-31T00:00:00Z') will roll over into the following month, but we can + // detect that by comparing date.toISOString() with the original timestamp string. We need to normalise the + // original string to millisecond precision to make this work. + if (Number.isNaN(new Date(timestamp).valueOf()) || date.toISOString() != normalisePrecision(groups)) { + throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp); + } +}