Skip to content
1 change: 1 addition & 0 deletions infrastructure/terraform/components/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ No requirements.
| <a name="module_kms"></a> [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a |
| <a name="module_logging_bucket"></a> [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a |
| <a name="module_patch_letter"></a> [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| <a name="module_post_mi"></a> [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| <a name="module_s3bucket_test_letters"></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 |
| <a name="module_supplier_ssl"></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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}
}
23 changes: 13 additions & 10 deletions infrastructure/terraform/components/api/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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,
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
41 changes: 33 additions & 8 deletions internal/datastore/src/__test__/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -44,10 +46,7 @@ export async function setupDynamoDBContainer() {

export type DBContext = Awaited<ReturnType<typeof setupDynamoDBContainer>>;

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: [
Expand All @@ -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);
}


Expand All @@ -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'
}));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
67 changes: 67 additions & 0 deletions internal/datastore/src/__test__/mi-repository.test.ts
Original file line number Diff line number Diff line change
@@ -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
}));
});
});
});
4 changes: 3 additions & 1 deletion internal/datastore/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ export type DatastoreConfig = {
region: string,
endpoint?: string,
lettersTableName: string,
ttlHours: number
miTableName: string,
lettersTtlHours: number,
miTtlHours: number
}
1 change: 1 addition & 0 deletions internal/datastore/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './types';
export * from './mi-repository';
export * from './letter-repository';
export * from './types';
8 changes: 4 additions & 4 deletions internal/datastore/src/letter-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const defaultPagingOptions = {

export type LetterRepositoryConfig = {
lettersTableName: string,
ttlHours: number
lettersTtlHours: number
}

export class LetterRepository {
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading