diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index d9bd38dc8..1ef7b2c65 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -38,6 +38,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | +| [count\_routing\_configs\_lambda](#module\_count\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [delete\_template\_lambda](#module\_delete\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [get\_client\_lambda](#module\_get\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index b6e45176d..db31b3e2e 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -50,6 +50,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { resources = [ module.authorizer_lambda.function_arn, module.upload_letter_template_lambda.function_arn, + module.count_routing_configs_lambda.function_arn, module.create_template_lambda.function_arn, module.delete_template_lambda.function_arn, module.get_client_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index 05cb2d62e..368c2fafd 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -11,20 +11,21 @@ locals { client_ssm_path_pattern = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${local.client_ssm_path_prefix}/*" openapi_spec = templatefile("${path.module}/spec.tmpl.json", { - APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn - AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn - AWS_REGION = var.region - CREATE_LAMBDA_ARN = module.create_template_lambda.function_arn - DELETE_LAMBDA_ARN = module.delete_template_lambda.function_arn - GET_CLIENT_LAMBDA_ARN = module.get_client_lambda.function_arn - GET_LAMBDA_ARN = module.get_template_lambda.function_arn - GET_ROUTING_CONFIG_LAMBDA_ARN = module.get_routing_config_lambda.function_arn - LIST_LAMBDA_ARN = module.list_template_lambda.function_arn - LIST_ROUTING_CONFIGS_LAMBDA_ARN = module.list_routing_configs_lambda.function_arn - REQUEST_PROOF_LAMBDA_ARN = module.request_proof_lambda.function_arn - SUBMIT_LAMBDA_ARN = module.submit_template_lambda.function_arn - UPDATE_LAMBDA_ARN = module.update_template_lambda.function_arn - UPLOAD_LETTER_LAMBDA_ARN = module.upload_letter_template_lambda.function_arn + APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn + AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn + AWS_REGION = var.region + COUNT_ROUTING_CONFIGS_LAMBDA_ARN = module.count_routing_configs_lambda.function_arn + CREATE_LAMBDA_ARN = module.create_template_lambda.function_arn + DELETE_LAMBDA_ARN = module.delete_template_lambda.function_arn + GET_CLIENT_LAMBDA_ARN = module.get_client_lambda.function_arn + GET_LAMBDA_ARN = module.get_template_lambda.function_arn + GET_ROUTING_CONFIG_LAMBDA_ARN = module.get_routing_config_lambda.function_arn + LIST_LAMBDA_ARN = module.list_template_lambda.function_arn + LIST_ROUTING_CONFIGS_LAMBDA_ARN = module.list_routing_configs_lambda.function_arn + REQUEST_PROOF_LAMBDA_ARN = module.request_proof_lambda.function_arn + SUBMIT_LAMBDA_ARN = module.submit_template_lambda.function_arn + UPDATE_LAMBDA_ARN = module.update_template_lambda.function_arn + UPLOAD_LETTER_LAMBDA_ARN = module.upload_letter_template_lambda.function_arn }) backend_lambda_environment_variables = { diff --git a/infrastructure/terraform/modules/backend-api/module_count_routing_configs_lambda.tf b/infrastructure/terraform/modules/backend-api/module_count_routing_configs_lambda.tf new file mode 100644 index 000000000..238184c3e --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_count_routing_configs_lambda.tf @@ -0,0 +1,68 @@ +module "count_routing_configs_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "count-routing-configs" + + function_module_name = "count-routing-configs" + handler_function_name = "handler" + description = "Count Routing Configs API endpoint" + + memory = 512 + timeout = 3 + runtime = "nodejs20.x" + + log_retention_in_days = var.log_retention_in_days + + iam_policy_document = { + body = data.aws_iam_policy_document.count_routing_configs_lambda_policy.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/count-routing-configs" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "count_routing_configs_lambda_policy" { + statement { + sid = "AllowDynamoAccess" + effect = "Allow" + + actions = [ + "dynamodb:Query", + ] + + resources = [ + aws_dynamodb_table.routing_configuration.arn, + ] + } + + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 9786c0ea7..1599f8905 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -376,6 +376,29 @@ ], "type": "object" }, + "CountSuccess": { + "properties": { + "data": { + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "statusCode": { + "type": "integer" + } + }, + "required": [ + "data", + "statusCode" + ], + "type": "object" + }, "CreateUpdateTemplate": { "allOf": [ { @@ -1117,6 +1140,70 @@ } } }, + "/v1/routing-configurations/count": { + "get": { + "description": "Get a count of routing configs", + "parameters": [ + { + "description": "Filter by a single active status", + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/RoutingConfigStatusActive" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CountSuccess" + } + } + }, + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Count routing configs", + "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/${COUNT_ROUTING_CONFIGS_LAMBDA_ARN}/invocations" + } + } + }, "/v1/template": { "post": { "description": "Create a template", diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index 2b46f0547..21ec3a323 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -15,12 +15,12 @@ npx esbuild \ --outdir=dist \ --external:pdfjs-dist \ src/templates/copy-scanned-object-to-internal.ts \ - src/templates/upload-letter.ts \ + src/templates/count-routing-configs.ts \ src/templates/create.ts \ - src/templates/delete-failed-scanned-object.ts \ src/templates/delete.ts \ - src/templates/get-client.ts \ + src/templates/delete-failed-scanned-object.ts \ src/templates/get.ts \ + src/templates/get-client.ts \ src/templates/get-routing-config.ts \ src/templates/list.ts \ src/templates/list-routing-configs.ts \ @@ -29,6 +29,7 @@ npx esbuild \ src/templates/set-letter-upload-virus-scan-status.ts \ src/templates/submit.ts \ src/templates/update.ts \ + src/templates/upload-letter.ts \ src/templates/validate-letter-template-files.ts cp -r ../../utils/utils/src/email-templates ./dist/submit diff --git a/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts new file mode 100644 index 000000000..156b28233 --- /dev/null +++ b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts @@ -0,0 +1,117 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import type { Logger } from 'nhs-notify-web-template-management-utils/logger'; +import { createHandler } from '@backend-api/templates/api/count-routing-configs'; +import { RoutingConfigClient } from '@backend-api/templates/app/routing-config-client'; + +jest.mock('nhs-notify-web-template-management-utils/logger', () => ({ + logger: mock({ + child: jest.fn().mockReturnThis(), + }), +})); + +const setup = () => { + const routingConfigClient = mock(); + + const handler = createHandler({ routingConfigClient }); + + return { handler, mocks: { routingConfigClient } }; +}; + +describe('CountRoutingConfigs handler', () => { + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', user: undefined }], + ['missing client', { clientId: undefined, user: 'user-id' }], + ])( + 'should return 400 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect( + mocks.routingConfigClient.countRoutingConfigs + ).not.toHaveBeenCalled(); + } + ); + + test('should return error when counting routing configs fails', async () => { + const { handler, mocks } = setup(); + + mocks.routingConfigClient.countRoutingConfigs.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock(); + event.requestContext.authorizer = { + user: 'sub', + clientId: 'nhs-notify-client-id', + }; + + event.queryStringParameters = { + status: 'DRAFT', + }; + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith( + 'nhs-notify-client-id', + { status: 'DRAFT' } + ); + }); + + test('should return count of routing configs', async () => { + const { handler, mocks } = setup(); + + mocks.routingConfigClient.countRoutingConfigs.mockResolvedValueOnce({ + data: { count: 99 }, + }); + + const event = mock(); + event.requestContext.authorizer = { + user: 'sub', + clientId: 'nhs-notify-client-id', + }; + event.queryStringParameters = { + status: 'COMPLETED', + }; + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: { count: 99 } }), + }); + + expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith( + 'nhs-notify-client-id', + { status: 'COMPLETED' } + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts index e6979e858..7891ea859 100644 --- a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts @@ -159,4 +159,70 @@ describe('RoutingConfigClient', () => { expect(query.status).toHaveBeenCalledWith('DRAFT'); }); }); + + describe('countRoutingConfigs', () => { + it('queries for non-deleted configs with the given owner and returns the count', async () => { + const { mocks, client } = setup(); + + const query = mockQuery(); + + mocks.routingConfigRepository.query.mockReturnValueOnce(query); + + query.count.mockResolvedValueOnce({ data: { count: 3 } }); + + const result = await client.countRoutingConfigs('nhs-notify-client-id'); + + expect(result).toEqual({ data: { count: 3 } }); + + expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith( + 'nhs-notify-client-id' + ); + expect(query.excludeStatus).toHaveBeenCalledWith('DELETED'); + expect(query.status).not.toHaveBeenCalled(); + }); + + it('validates status filter parameter', async () => { + const { client, mocks } = setup(); + + const result = await client.countRoutingConfigs('nhs-notify-client-id', { + status: 'INVALID', + }); + + expect(result).toEqual({ + error: expect.objectContaining({ + errorMeta: { + code: 400, + description: 'Request failed validation', + details: { + status: 'Invalid option: expected one of "COMPLETED"|"DRAFT"', + }, + }, + }), + }); + + expect(mocks.routingConfigRepository.query).not.toHaveBeenCalled(); + }); + + it('uses the given status filter', async () => { + const { client, mocks } = setup(); + + const query = mockQuery(); + + mocks.routingConfigRepository.query.mockReturnValueOnce(query); + + query.count.mockResolvedValueOnce({ data: { count: 18 } }); + + const result = await client.countRoutingConfigs('nhs-notify-client-id', { + status: 'DRAFT', + }); + + expect(result).toEqual({ data: { count: 18 } }); + + expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith( + 'nhs-notify-client-id' + ); + expect(query.excludeStatus).toHaveBeenCalledWith('DELETED'); + expect(query.status).toHaveBeenCalledWith('DRAFT'); + }); + }); }); diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts index 2f745544b..4f394943d 100644 --- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts @@ -32,194 +32,285 @@ function setup() { } describe('RoutingConfigRepo#query', () => { - test('queries by owner, paginates across pages, returns all items', async () => { - const { repo, mocks } = setup(); - - const page1: RoutingConfig[] = [config1, config2]; - const page2: RoutingConfig[] = [config3]; - - mocks.dynamo - .on(QueryCommand) - .resolvesOnce({ - Items: page1, - LastEvaluatedKey: { owner, id: config2.id }, - }) - .resolvesOnce({ - Items: page2, + describe('list', () => { + test('queries by owner, paginates across pages, returns all items', async () => { + const { repo, mocks } = setup(); + + const page1: RoutingConfig[] = [config1, config2]; + const page2: RoutingConfig[] = [config3]; + + mocks.dynamo + .on(QueryCommand) + .resolvesOnce({ + Items: page1, + LastEvaluatedKey: { owner, id: config2.id }, + }) + .resolvesOnce({ + Items: page2, + }); + + const result = await repo.query(owner).list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': owner, + }, + ExclusiveStartKey: { owner, id: config2.id }, + }); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': owner, + }, }); - const result = await repo.query(owner).list(); - - expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); - expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - ExpressionAttributeNames: { - '#owner': 'owner', - }, - ExpressionAttributeValues: { - ':owner': owner, - }, - ExclusiveStartKey: { owner, id: config2.id }, - }); - expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - ExpressionAttributeNames: { - '#owner': 'owner', - }, - ExpressionAttributeValues: { - ':owner': owner, - }, + expect(result.data).toEqual([config1, config2, config3]); }); - expect(result.data).toEqual([config1, config2, config3]); - }); + test('supports filtering by status (chainable)', async () => { + const { repo, mocks } = setup(); - test('supports filtering by status (chainable)', async () => { - const { repo, mocks } = setup(); + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); - mocks.dynamo.on(QueryCommand).resolvesOnce({ - Items: [], + await repo + .query(owner) + .status('COMPLETED', 'DELETED') + .status('DRAFT') + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: '(#status IN (:status0, :status1, :status2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#status': 'status', + }, + ExpressionAttributeValues: { + ':owner': owner, + ':status0': 'COMPLETED', + ':status1': 'DELETED', + ':status2': 'DRAFT', + }, + }); }); - await repo - .query(owner) - .status('COMPLETED', 'DELETED') - .status('DRAFT') - .list(); - - expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); - expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - FilterExpression: '(#status IN (:status0, :status1, :status2))', - ExpressionAttributeNames: { - '#owner': 'owner', - '#status': 'status', - }, - ExpressionAttributeValues: { - ':owner': owner, - ':status0': 'COMPLETED', - ':status1': 'DELETED', - ':status2': 'DRAFT', - }, - }); - }); + test('supports excluding statuses (chainable)', async () => { + const { repo, mocks } = setup(); - test('supports excluding statuses (chainable)', async () => { - const { repo, mocks } = setup(); + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); - mocks.dynamo.on(QueryCommand).resolvesOnce({ - Items: [], + await repo + .query(owner) + .excludeStatus('COMPLETED', 'DELETED') + .excludeStatus('DRAFT') + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#status <> :notStatus0 AND #status <> :notStatus1 AND #status <> :notStatus2)', + ExpressionAttributeNames: { + '#owner': 'owner', + '#status': 'status', + }, + ExpressionAttributeValues: { + ':owner': owner, + ':notStatus0': 'COMPLETED', + ':notStatus1': 'DELETED', + ':notStatus2': 'DRAFT', + }, + }); }); - await repo - .query(owner) - .excludeStatus('COMPLETED', 'DELETED') - .excludeStatus('DRAFT') - .list(); - - expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); - expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - FilterExpression: - '(#status <> :notStatus0 AND #status <> :notStatus1 AND #status <> :notStatus2)', - ExpressionAttributeNames: { - '#owner': 'owner', - '#status': 'status', - }, - ExpressionAttributeValues: { - ':owner': owner, - ':notStatus0': 'COMPLETED', - ':notStatus1': 'DELETED', - ':notStatus2': 'DRAFT', - }, + test('supports mixed filters', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo.query(owner).status('DRAFT').excludeStatus('DELETED').list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#status IN (:status0)) AND (#status <> :notStatus0)', + ExpressionAttributeNames: { + '#owner': 'owner', + '#status': 'status', + }, + ExpressionAttributeValues: { + ':owner': owner, + ':notStatus0': 'DELETED', + ':status0': 'DRAFT', + }, + }); }); - }); - test('supports mixed filters', async () => { - const { repo, mocks } = setup(); + test('dedupes status filters', async () => { + const { repo, mocks } = setup(); - mocks.dynamo.on(QueryCommand).resolvesOnce({ - Items: [], + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(owner) + .status('DRAFT') + .status('DRAFT') + .excludeStatus('DELETED') + .excludeStatus('DELETED') + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#status IN (:status0)) AND (#status <> :notStatus0)', + ExpressionAttributeNames: { + '#owner': 'owner', + '#status': 'status', + }, + ExpressionAttributeValues: { + ':owner': owner, + ':notStatus0': 'DELETED', + ':status0': 'DRAFT', + }, + }); }); - await repo.query(owner).status('DRAFT').excludeStatus('DELETED').list(); - - expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); - expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - FilterExpression: '(#status IN (:status0)) AND (#status <> :notStatus0)', - ExpressionAttributeNames: { - '#owner': 'owner', - '#status': 'status', - }, - ExpressionAttributeValues: { - ':owner': owner, - ':notStatus0': 'DELETED', - ':status0': 'DRAFT', - }, + test('filters out invalid routing config items', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [ + config1, + { owner, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, + config2, + ], + }); + + const result = await repo.query(owner).list(); + + expect(result.data).toEqual([config1, config2]); }); - }); - test('dedupes status filters', async () => { - const { repo, mocks } = setup(); + test('handles no items from dynamo', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({}); + + const result = await repo.query(owner).list(); - mocks.dynamo.on(QueryCommand).resolvesOnce({ - Items: [], + expect(result.data).toEqual([]); }); - await repo - .query(owner) - .status('DRAFT') - .status('DRAFT') - .excludeStatus('DELETED') - .excludeStatus('DELETED') - .list(); - - expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); - expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { - TableName: TABLE_NAME, - KeyConditionExpression: '#owner = :owner', - FilterExpression: '(#status IN (:status0)) AND (#status <> :notStatus0)', - ExpressionAttributeNames: { - '#owner': 'owner', - '#status': 'status', - }, - ExpressionAttributeValues: { - ':owner': owner, - ':notStatus0': 'DELETED', - ':status0': 'DRAFT', - }, + test('handles exceptions from dynamodb', async () => { + const { repo, mocks } = setup(); + + const e = new Error('oh no'); + + mocks.dynamo.on(QueryCommand).rejectsOnce(e); + + const result = await repo.query(owner).list(); + + expect(result.error).toMatchObject({ + actualError: e, + errorMeta: expect.objectContaining({ code: 500 }), + }); + expect(result.data).toBeUndefined(); }); }); - test('filters out invalid routing config items', async () => { - const { repo, mocks } = setup(); + describe('count', () => { + test('queries by owner, paginates across pages, returns count of items', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo + .on(QueryCommand) + .resolvesOnce({ + Count: 2, + LastEvaluatedKey: { owner, id: config2.id }, + }) + .resolvesOnce({ + Count: 1, + }); + + const result = await repo.query(owner).count(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': owner, + }, + Select: 'COUNT', + ExclusiveStartKey: { owner, id: config2.id }, + }); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': owner, + }, + Select: 'COUNT', + }); - mocks.dynamo.on(QueryCommand).resolvesOnce({ - Items: [ - config1, - { owner, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, - config2, - ], + expect(result.data).toEqual({ count: 3 }); }); - const result = await repo.query(owner).list(); + test('handles no items from dynamo', async () => { + const { repo, mocks } = setup(); - expect(result.data).toEqual([config1, config2]); - }); + mocks.dynamo.on(QueryCommand).resolvesOnce({}); - test('handles no items from dynamo', async () => { - const { repo, mocks } = setup(); + const result = await repo.query(owner).count(); + + expect(result.data).toEqual({ count: 0 }); + }); - mocks.dynamo.on(QueryCommand).resolvesOnce({}); + test('handles exceptions from dynamodb', async () => { + const { repo, mocks } = setup(); - const result = await repo.query(owner).list(); + const e = new Error('oh no'); - expect(result.data).toEqual([]); + mocks.dynamo.on(QueryCommand).rejectsOnce(e); + + const result = await repo.query(owner).count(); + + expect(result.error).toMatchObject({ + actualError: e, + errorMeta: expect.objectContaining({ code: 500 }), + }); + expect(result.data).toBeUndefined(); + }); }); }); diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts index a03ef29ae..b1ddfbe16 100644 --- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts @@ -1,6 +1,7 @@ import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import 'aws-sdk-client-mock-jest'; import { mockClient } from 'aws-sdk-client-mock'; +import { ZodError } from 'zod'; import { RoutingConfigRepository } from '@backend-api/templates/infra/routing-config-repository'; import { routingConfig } from '../../fixtures/routing-config'; @@ -83,7 +84,12 @@ describe('RoutingConfigRepository', () => { 'nhs-notify-client-id' ); - expect(result.error).not.toBeUndefined(); + expect(result.error).toMatchObject({ + actualError: expect.any(ZodError), + errorMeta: expect.objectContaining({ + code: 500, + }), + }); expect(result.data).toBeUndefined(); expect(mocks.dynamo).toHaveReceivedCommandWith(GetCommand, { @@ -94,5 +100,27 @@ describe('RoutingConfigRepository', () => { }, }); }); + + test('returns errors if the database call fails', async () => { + const { repo, mocks } = setup(); + + const e = new Error('Oh No'); + + mocks.dynamo.on(GetCommand).rejectsOnce(e); + + const result = await repo.get( + 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', + 'nhs-notify-client-id' + ); + + expect(result.error).toMatchObject({ + actualError: e, + errorMeta: expect.objectContaining({ + code: 500, + }), + }); + + expect(result.data).toBeUndefined(); + }); }); }); diff --git a/lambdas/backend-api/src/templates/api/count-routing-configs.ts b/lambdas/backend-api/src/templates/api/count-routing-configs.ts new file mode 100644 index 000000000..db14d1bf8 --- /dev/null +++ b/lambdas/backend-api/src/templates/api/count-routing-configs.ts @@ -0,0 +1,42 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { apiFailure, apiSuccess } from './responses'; +import type { RoutingConfigClient } from '../app/routing-config-client'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; + +export function createHandler({ + routingConfigClient, +}: { + routingConfigClient: RoutingConfigClient; +}): APIGatewayProxyHandler { + return async function handler(event) { + const { user: userId, clientId } = event.requestContext.authorizer ?? {}; + + if (!clientId || !userId) { + return apiFailure(400, 'Invalid request'); + } + + const log = logger.child({ + clientId, + userId, + }); + + const { data, error } = await routingConfigClient.countRoutingConfigs( + clientId, + event.queryStringParameters + ); + + if (error) { + log + .child(error.errorMeta) + .error('Failed to count routing configs', error.actualError); + + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(200, data); + }; +} diff --git a/lambdas/backend-api/src/templates/api/responses.ts b/lambdas/backend-api/src/templates/api/responses.ts index 04eb84198..31789153c 100644 --- a/lambdas/backend-api/src/templates/api/responses.ts +++ b/lambdas/backend-api/src/templates/api/responses.ts @@ -4,8 +4,15 @@ import type { RoutingConfig, } from 'nhs-notify-backend-client'; +type Count = { count: number }; + export const apiSuccess = < - T extends TemplateDto | TemplateDto[] | RoutingConfig | RoutingConfig[], + T extends + | Count + | RoutingConfig + | RoutingConfig[] + | TemplateDto + | TemplateDto[], >( statusCode: number, result: T diff --git a/lambdas/backend-api/src/templates/app/routing-config-client.ts b/lambdas/backend-api/src/templates/app/routing-config-client.ts index c214f79d2..afad79849 100644 --- a/lambdas/backend-api/src/templates/app/routing-config-client.ts +++ b/lambdas/backend-api/src/templates/app/routing-config-client.ts @@ -53,4 +53,31 @@ export class RoutingConfigClient { return query.list(); } + + async countRoutingConfigs( + owner: string, + filters?: unknown + ): Promise> { + let parsedFilters: ListRoutingConfigFilters = {}; + + if (filters) { + const validation = await validate($ListRoutingConfigFilters, filters); + + if (validation.error) { + return validation; + } + + parsedFilters = validation.data; + } + + const query = this.routingConfigRepository + .query(owner) + .excludeStatus('DELETED'); + + if (parsedFilters.status) { + query.status(parsedFilters.status); + } + + return query.count(); + } } diff --git a/lambdas/backend-api/src/templates/count-routing-configs.ts b/lambdas/backend-api/src/templates/count-routing-configs.ts new file mode 100644 index 000000000..9b443dc6d --- /dev/null +++ b/lambdas/backend-api/src/templates/count-routing-configs.ts @@ -0,0 +1,4 @@ +import { createHandler } from './api/count-routing-configs'; +import { createContainer } from './container'; + +export const handler = createHandler(createContainer()); diff --git a/lambdas/backend-api/src/templates/infra/routing-config-repository/query.ts b/lambdas/backend-api/src/templates/infra/routing-config-repository/query.ts index 25a43b2fe..7f56e430a 100644 --- a/lambdas/backend-api/src/templates/infra/routing-config-repository/query.ts +++ b/lambdas/backend-api/src/templates/infra/routing-config-repository/query.ts @@ -4,9 +4,10 @@ import { type NativeAttributeValue, type QueryCommandInput, } from '@aws-sdk/lib-dynamodb'; -import { ApplicationResult, success } from '@backend-api/utils/result'; +import { ApplicationResult, failure, success } from '@backend-api/utils/result'; import { $RoutingConfig, + ErrorCase, type RoutingConfig, type RoutingConfigStatus, } from 'nhs-notify-backend-client'; @@ -15,6 +16,7 @@ import { logger } from 'nhs-notify-web-template-management-utils/logger'; export class RoutingConfigQuery { private includeStatuses: RoutingConfigStatus[] = []; private excludeStatuses: RoutingConfigStatus[] = []; + private returnCount = false; constructor( private readonly docClient: DynamoDBDocumentClient, @@ -36,28 +38,65 @@ export class RoutingConfigQuery { /** Execute the query and return a list of all matching RoutingConfigs */ async list(): Promise> { - const query = this.build(); - - const collected: RoutingConfig[] = []; - - const paginator = paginateQuery({ client: this.docClient }, query); - - for await (const page of paginator) { - for (const item of page.Items ?? []) { - const parsed = $RoutingConfig.safeParse(item); - if (parsed.success) { - collected.push(parsed.data); - } else { - logger.warn('Filtered out invalid RoutingConfig item', { - owner: this.owner, - id: item.id, - issues: parsed.error.issues, - }); + try { + this.returnCount = false; + + const query = this.build(); + + const collected: RoutingConfig[] = []; + + const paginator = paginateQuery({ client: this.docClient }, query); + + for await (const page of paginator) { + for (const item of page.Items ?? []) { + const parsed = $RoutingConfig.safeParse(item); + if (parsed.success) { + collected.push(parsed.data); + } else { + logger.warn('Filtered out invalid RoutingConfig item', { + owner: this.owner, + id: item.id, + issues: parsed.error.issues, + }); + } } } + + return success(collected); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + 'Error listing Routing Configs', + error + ); } + } + + /** Execute the query and return a count of all matching RoutingConfigs */ + async count(): Promise> { + try { + this.returnCount = true; + + const query = this.build(); + + let count = 0; + + const paginator = paginateQuery({ client: this.docClient }, query); - return success(collected); + for await (const page of paginator) { + if (page.Count) { + count += page.Count; + } + } + + return success({ count }); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + 'Error counting Routing Configs', + error + ); + } } private build() { @@ -110,6 +149,10 @@ export class RoutingConfigQuery { query.FilterExpression = filters.join(' AND '); } + if (this.returnCount) { + query.Select = 'COUNT'; + } + return query; } } diff --git a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts index 38e102582..6d7e16524 100644 --- a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts @@ -17,31 +17,31 @@ export class RoutingConfigRepository { id: string, owner: string ): Promise> { - const result = await this.client.send( - new GetCommand({ - TableName: this.tableName, - Key: { - id, - owner, - }, - }) - ); + try { + const result = await this.client.send( + new GetCommand({ + TableName: this.tableName, + Key: { + id, + owner, + }, + }) + ); - if (!result.Item) { - return failure(ErrorCase.NOT_FOUND, 'Routing Config not found'); - } + if (!result.Item) { + return failure(ErrorCase.NOT_FOUND, 'Routing Config not found'); + } - const parsed = $RoutingConfig.safeParse(result.Item); + const parsed = $RoutingConfig.parse(result.Item); - if (!parsed.success) { + return success(parsed); + } catch (error) { return failure( ErrorCase.INTERNAL, 'Error retrieving Routing Config', - parsed.error + error ); } - - return success(parsed.data); } query(owner: string): RoutingConfigQuery { diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts index 53e2464da..6cbc16005 100644 --- a/lambdas/backend-client/src/types/generated/types.gen.ts +++ b/lambdas/backend-client/src/types/generated/types.gen.ts @@ -95,6 +95,13 @@ export type ConditionalTemplateLanguage = { templateId: string; }; +export type CountSuccess = { + data: { + count: number; + }; + statusCode: number; +}; + export type CreateUpdateTemplate = BaseTemplate & (SmsProperties | EmailProperties | NhsAppProperties | UploadLetterProperties); @@ -365,6 +372,38 @@ export type GetV1RoutingConfigurationsResponses = { export type GetV1RoutingConfigurationsResponse = GetV1RoutingConfigurationsResponses[keyof GetV1RoutingConfigurationsResponses]; +export type GetV1RoutingConfigurationsCountData = { + body?: never; + path?: never; + query?: { + /** + * Filter by a single active status + */ + status?: RoutingConfigStatusActive; + }; + url: '/v1/routing-configurations/count'; +}; + +export type GetV1RoutingConfigurationsCountErrors = { + /** + * Error + */ + default: Failure; +}; + +export type GetV1RoutingConfigurationsCountError = + GetV1RoutingConfigurationsCountErrors[keyof GetV1RoutingConfigurationsCountErrors]; + +export type GetV1RoutingConfigurationsCountResponses = { + /** + * 200 response + */ + 200: CountSuccess; +}; + +export type GetV1RoutingConfigurationsCountResponse = + GetV1RoutingConfigurationsCountResponses[keyof GetV1RoutingConfigurationsCountResponses]; + export type PostV1TemplateData = { /** * Template to create diff --git a/package-lock.json b/package-lock.json index 4b051c588..9a233dd57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17651,12 +17651,12 @@ "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@smithy/abort-controller": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", - "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.0.tgz", + "integrity": "sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -17689,15 +17689,15 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.2.tgz", - "integrity": "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.3.0.tgz", + "integrity": "sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -17705,20 +17705,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.13.0.tgz", - "integrity": "sha512-BI6ALLPOKnPOU1Cjkc+1TPhOlP3JXSR/UH14JmnaLq41t3ma+IjuXrKfhycVjr5IQ0XxRh2NnQo3olp+eCVrGg==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.14.0.tgz", + "integrity": "sha512-XJ4z5FxvY/t0Dibms/+gLJrI5niRoY0BCmE02fwmPcRYFPI4KI876xaE79YGWIKnEslMbuQPsIEsoU/DXa0DoA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", - "@smithy/uuid": "^1.0.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.4.0", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, "engines": { @@ -17726,15 +17726,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.2.tgz", - "integrity": "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.0.tgz", + "integrity": "sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -17812,15 +17812,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", - "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.0.tgz", + "integrity": "sha512-BG3KSmsx9A//KyIfw+sqNmWFr1YBUr+TwpxFT7yPqAk0yyDh7oSNgzfNH7pS6OC099EGx2ltOULvumCFe8bcgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -17843,14 +17843,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.1.1.tgz", - "integrity": "sha512-H9DIU9WBLhYrvPs9v4sYvnZ1PiAI0oc8CgNQUJ1rpN3pP7QADbTOUjchI2FB764Ub0DstH5xbTqcMJu1pnVqxA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.0.tgz", + "integrity": "sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -17872,12 +17872,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.1.1.tgz", - "integrity": "sha512-1AqLyFlfrrDkyES8uhINRlJXmHA2FkG+3DY8X+rmLSqmFwk3DJnvhyGzyByPyewh2jbmV+TYQBEfngQax8IFGg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.0.tgz", + "integrity": "sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -17885,9 +17885,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", - "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -17911,13 +17911,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.1.1.tgz", - "integrity": "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.0.tgz", + "integrity": "sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -17925,18 +17925,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.5.tgz", - "integrity": "sha512-DdOIpssQ5LFev7hV6GX9TMBW5ChTsQBxqgNW1ZGtJNSAi5ksd5klwPwwMY0ejejfEzwXXGqxgVO3cpaod4veiA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.0.tgz", + "integrity": "sha512-jFVjuQeV8TkxaRlcCNg0GFVgg98tscsmIrIwRFeC74TIUyLE3jmY9xgc1WXrPQYRjQNK3aRoaIk6fhFRGOIoGw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.13.0", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-middleware": "^4.1.1", + "@smithy/core": "^3.14.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -17944,19 +17944,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.3.1.tgz", - "integrity": "sha512-aH2bD1bzb6FB04XBhXA5mgedEZPKx3tD/qBuYCAKt5iieWvWO1Y2j++J9uLqOndXb9Pf/83Xka/YjSnMbcPchA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.0.tgz", + "integrity": "sha512-yaVBR0vQnOnzex45zZ8ZrPzUnX73eUC8kVFaAAbn04+6V7lPtxn56vZEBBAhgS/eqD6Zm86o6sJs6FuQVoX5qg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/protocol-http": "^5.2.1", - "@smithy/service-error-classification": "^4.1.2", - "@smithy/smithy-client": "^4.6.5", - "@smithy/types": "^4.5.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.2", - "@smithy/uuid": "^1.0.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/service-error-classification": "^4.2.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, "engines": { @@ -17964,13 +17964,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", - "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.0.tgz", + "integrity": "sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -17978,12 +17978,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", - "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.0.tgz", + "integrity": "sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -17991,14 +17991,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", - "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.0.tgz", + "integrity": "sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18006,15 +18006,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", - "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.3.0.tgz", + "integrity": "sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/abort-controller": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18022,12 +18022,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", - "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.0.tgz", + "integrity": "sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18035,12 +18035,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", - "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.0.tgz", + "integrity": "sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18048,13 +18048,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", - "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.0.tgz", + "integrity": "sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-uri-escape": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18062,12 +18062,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", - "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.0.tgz", + "integrity": "sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18075,24 +18075,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.2.tgz", - "integrity": "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.0.tgz", + "integrity": "sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0" + "@smithy/types": "^4.6.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", - "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.0.tgz", + "integrity": "sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18100,18 +18100,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", - "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.0.tgz", + "integrity": "sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-uri-escape": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18119,17 +18119,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.5.tgz", - "integrity": "sha512-6J2hhuWu7EjnvLBIGltPCqzNswL1cW/AkaZx6i56qLsQ0ix17IAhmDD9aMmL+6CN9nCJODOXpBTCQS6iKAA7/g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.7.0.tgz", + "integrity": "sha512-3BDx/aCCPf+kkinYf5QQhdQ9UAGihgOVqI3QO5xQfSaIWvUE4KYLtiGRWsNe1SR7ijXC0QEPqofVp5Sb0zC8xQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.13.0", - "@smithy/middleware-endpoint": "^4.2.5", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.2", + "@smithy/core": "^3.14.0", + "@smithy/middleware-endpoint": "^4.3.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-stream": "^4.4.0", "tslib": "^2.6.2" }, "engines": { @@ -18137,9 +18137,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", - "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.6.0.tgz", + "integrity": "sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18149,13 +18149,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", - "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.0.tgz", + "integrity": "sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/querystring-parser": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18163,13 +18163,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", - "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.2.0.tgz", + "integrity": "sha512-+erInz8WDv5KPe7xCsJCp+1WCjSbah9gWcmUXc9NqmhyPx59tf7jqFz+za1tRG1Y5KM1Cy1rWCcGypylFp4mvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18177,9 +18177,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", - "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18189,9 +18189,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", - "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.0.tgz", + "integrity": "sha512-U8q1WsSZFjXijlD7a4wsDQOvOwV+72iHSfq1q7VD+V75xP/pdtm0WIGuaFJ3gcADDOKj2MIBn4+zisi140HEnQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18201,12 +18201,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", - "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18214,9 +18214,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", - "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18226,14 +18226,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.5.tgz", - "integrity": "sha512-FGBhlmFZVSRto816l6IwrmDcQ9pUYX6ikdR1mmAhdtSS1m77FgADukbQg7F7gurXfAvloxE/pgsrb7SGja6FQA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.2.0.tgz", + "integrity": "sha512-qzHp7ZDk1Ba4LDwQVCNp90xPGqSu7kmL7y5toBpccuhi3AH7dcVBIT/pUxYcInK4jOy6FikrcTGq5wxcka8UaQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.5", - "@smithy/types": "^4.5.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -18242,17 +18242,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.5.tgz", - "integrity": "sha512-Gwj8KLgJ/+MHYjVubJF0EELEh9/Ir7z7DFqyYlwgmp4J37KE+5vz6b3pWUnSt53tIe5FjDfVjDmHGYKjwIvW0Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.0.tgz", + "integrity": "sha512-FxUHS3WXgx3bTWR6yQHNHHkQHZm/XKIi/CchTnKvBulN6obWpcbzJ6lDToXn+Wp0QlVKd7uYAz2/CTw1j7m+Kg==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.2.2", - "@smithy/credential-provider-imds": "^4.1.2", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.5", - "@smithy/types": "^4.5.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/credential-provider-imds": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18260,13 +18260,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.2.tgz", - "integrity": "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.0.tgz", + "integrity": "sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18274,9 +18274,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", - "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18286,12 +18286,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", - "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.0.tgz", + "integrity": "sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18299,13 +18299,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.2.tgz", - "integrity": "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.0.tgz", + "integrity": "sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.1.2", - "@smithy/types": "^4.5.0", + "@smithy/service-error-classification": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18313,18 +18313,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", - "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.4.0.tgz", + "integrity": "sha512-vtO7ktbixEcrVzMRmpQDnw/Ehr9UWjBvSJ9fyAbadKkC4w5Cm/4lMO8cHz8Ysb8uflvQUNRcuux/oNHKPXkffg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/fetch-http-handler": "^5.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18332,9 +18332,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", - "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18344,12 +18344,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", - "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -18357,13 +18357,13 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.1.1.tgz", - "integrity": "sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.0.tgz", + "integrity": "sha512-0Z+nxUU4/4T+SL8BCNN4ztKdQjToNvUYmkF1kXO5T7Yz3Gafzh0HeIG6mrkN8Fz3gn9hSyxuAT+6h4vM+iQSBQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/abort-controller": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -18371,9 +18371,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.0.0.tgz", - "integrity": "sha512-OlA/yZHh0ekYFnbUkmYBDQPE6fGfdrvgz39ktp8Xf+FA6BfxLejPTMDOG0Nfk5/rDySAz1dRbFf24zaAFYVXlQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" diff --git a/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts new file mode 100644 index 000000000..aecb5efb0 --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts @@ -0,0 +1,214 @@ +import { test, expect } from '@playwright/test'; +import type { RoutingConfig } from 'nhs-notify-backend-client'; +import { + createAuthHelper, + type TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; +import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; + +test.describe('GET /v1/routing-configurations/count', () => { + const authHelper = createAuthHelper(); + const storageHelper = new RoutingConfigStorageHelper(); + let user1: TestUser; + let user2: TestUser; + let userSharedClient: TestUser; + let draftRoutingConfig: RoutingConfig; + let completedRoutingConfig: RoutingConfig; + let deletedRoutingConfig: RoutingConfig; + + test.beforeAll(async () => { + user1 = await authHelper.getTestUser(testUsers.User1.userId); + user2 = await authHelper.getTestUser(testUsers.User2.userId); + userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); + + draftRoutingConfig = RoutingConfigFactory.create({ + owner: user1.clientId, + clientId: user1.clientId, + status: 'DRAFT', + createdBy: user1.userId, + updatedBy: user1.userId, + }); + + completedRoutingConfig = RoutingConfigFactory.create({ + owner: user1.clientId, + clientId: user1.clientId, + status: 'COMPLETED', + createdBy: user1.userId, + updatedBy: user1.userId, + }); + + deletedRoutingConfig = RoutingConfigFactory.create({ + owner: user1.clientId, + clientId: user1.clientId, + status: 'DELETED', + createdBy: user1.userId, + updatedBy: user1.userId, + }); + + await storageHelper.seed([ + draftRoutingConfig, + completedRoutingConfig, + deletedRoutingConfig, + ]); + }); + + test.afterAll(async () => { + await storageHelper.deleteSeeded(); + }); + + test('returns 401 if no auth token', async ({ request }) => { + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count` + ); + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); + }); + + test('counts active routing configs belonging to the authenticated owner', async ({ + request, + }) => { + // exercise - request user 1 routing configs + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + } + ); + + // assert on user 1 response - should filter out deleted + expect(response.status()).toBe(200); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { count: 2 }, + }); + }); + + test('does not return routing configs belonging to other clients besides the authenticated one', async ({ + request, + }) => { + // exercise - request user 2 routing configs (they have no routing configs) + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await user2.getAccessToken(), + }, + } + ); + + // assert that user 2 gets an empty list + expect(response.status()).toBe(200); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { count: 0 }, + }); + }); + + test('different users on the same client can fetch the clients routing configs', async ({ + request, + }) => { + // exercise - request shared user routing configs (same client as user 1) + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await userSharedClient.getAccessToken(), + }, + } + ); + + // assert that the user gets the full list of configs belonging to their client + expect(response.status()).toBe(200); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { count: 2 }, + }); + }); + + test('can filter by DRAFT status', async ({ request }) => { + // exercise - request routing configs with DRAFT status + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + params: { status: 'DRAFT' }, + } + ); + + // assert that response only contains the drafts + expect(response.status()).toBe(200); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { count: 1 }, + }); + }); + + test('can filter by COMPLETED status', async ({ request }) => { + // exercise - request routing configs with COMPLETED status + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + params: { status: 'COMPLETED' }, + } + ); + + // assert that response only contains the completed configs + expect(response.status()).toBe(200); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { count: 1 }, + }); + }); + + test('cannot filter by DELETED status', async ({ request }) => { + // exercise - request routing configs with DELETED status + const response = await request.get( + `${process.env.API_BASE_URL}/v1/routing-configurations/count`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + params: { status: 'DELETED' }, + } + ); + + // assert that response contains an error + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + details: { + status: 'Invalid option: expected one of "COMPLETED"|"DRAFT"', + }, + statusCode: 400, + technicalMessage: 'Request failed validation', + }); + }); +});