From 8852492d74fcf25e02c9993bfd526a7bd1b27de5 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 4 Mar 2026 11:30:58 +0000 Subject: [PATCH 01/16] CCM-14201 - Client Subscription Management --- .../module_transform_filter_lambda.tf | 7 +- jest.config.base.ts | 2 +- .../package.json | 1 + .../__tests__/index.cache-ttl-invalid.test.ts | 11 + .../__tests__/index.cache-ttl-valid.test.ts | 11 + .../src/__tests__/index.config-prefix.test.ts | 141 + .../src/__tests__/index.integration.test.ts | 237 ++ .../src/__tests__/index.reset-loader.test.ts | 64 + .../src/__tests__/index.s3-config.test.ts | 32 + .../src/__tests__/index.test.ts | 57 +- .../services/callback-logger.test.ts | 2 +- .../__tests__/services/config-cache.test.ts | 86 + .../__tests__/services/config-loader.test.ts | 178 ++ .../config-update.integration.test.ts | 115 + .../services/filters/event-pattern.test.ts | 100 + .../src/__tests__/services/logger.test.ts | 10 + .../services/subscription-filter.test.ts | 1060 ++++++++ .../services/transform-pipeline.test.ts | 265 ++ .../transformers/event-transformer.test.ts | 106 + .../validators/config-validator.test.ts | 138 + .../validators/event-validator.test.ts | 30 +- .../src/handler.ts | 63 +- .../src/index.ts | 75 +- .../src/services/config-cache.ts | 37 + .../src/services/config-loader.ts | 98 + .../services/filters/channel-status-filter.ts | 108 + .../src/services/filters/event-pattern.ts | 45 + .../services/filters/message-status-filter.ts | 70 + .../src/services/observability.ts | 1 + .../src/services/transform-pipeline.ts | 46 + .../services/validators/config-validator.ts | 176 ++ package-lock.json | 2390 +++++++++++------ package.json | 7 +- scripts/config/sonar-scanner.properties | 2 +- scripts/deploy_client_subscriptions.sh | 136 + src/models/src/channel-types.ts | 4 +- src/models/src/client-config.ts | 21 +- src/models/src/index.ts | 6 + src/models/src/status-types.ts | 87 +- .../client-subscriptions-management/README.md | 59 + .../jest.config.ts | 20 + .../package.json | 29 + .../client-subscription-builder.test.ts | 143 + .../client-subscription-repository.test.ts | 387 +++ .../src/__tests__/constants.test.ts | 28 + .../src/__tests__/container-s3-config.test.ts | 32 + .../src/__tests__/container.test.ts | 41 + .../get-client-subscriptions.test.ts | 176 ++ .../src/__tests__/helper.test.ts | 153 ++ .../src/__tests__/put-channel-status.test.ts | 382 +++ .../src/__tests__/put-message-status.test.ts | 317 +++ .../src/__tests__/s3-repository.test.ts | 128 + .../src/constants.ts | 18 + .../src/container.ts | 32 + .../src/domain/client-subscription-builder.ts | 131 + .../cli/get-client-subscriptions.ts | 70 + .../src/entrypoint/cli/helper.ts | 45 + .../src/entrypoint/cli/put-channel-status.ts | 147 + .../src/entrypoint/cli/put-message-status.ts | 119 + .../src/index.ts | 2 + .../infra/client-subscription-repository.ts | 230 ++ .../src/infra/s3-repository.ts | 73 + .../src/types.ts | 51 + .../tsconfig.json | 17 + tsconfig.json | 14 + 65 files changed, 8045 insertions(+), 824 deletions(-) create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/config-cache.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/config-loader.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts create mode 100644 scripts/deploy_client_subscriptions.sh create mode 100644 tools/client-subscriptions-management/README.md create mode 100644 tools/client-subscriptions-management/jest.config.ts create mode 100644 tools/client-subscriptions-management/package.json create mode 100644 tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/constants.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/container.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/helper.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts create mode 100644 tools/client-subscriptions-management/src/constants.ts create mode 100644 tools/client-subscriptions-management/src/container.ts create mode 100644 tools/client-subscriptions-management/src/domain/client-subscription-builder.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/helper.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts create mode 100644 tools/client-subscriptions-management/src/index.ts create mode 100644 tools/client-subscriptions-management/src/infra/client-subscription-repository.ts create mode 100644 tools/client-subscriptions-management/src/infra/s3-repository.ts create mode 100644 tools/client-subscriptions-management/src/types.ts create mode 100644 tools/client-subscriptions-management/tsconfig.json create mode 100644 tsconfig.json diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf index 2fff974..386e237 100644 --- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf @@ -35,8 +35,11 @@ module "client_transform_filter_lambda" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - ENVIRONMENT = var.environment - METRICS_NAMESPACE = "nhs-notify-client-callbacks" + ENVIRONMENT = var.environment + METRICS_NAMESPACE = "nhs-notify-client-callbacks-metrics" + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.id + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" } } diff --git a/jest.config.base.ts b/jest.config.base.ts index f057e3e..52c1d02 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -5,7 +5,7 @@ export const baseJestConfig: Config = { clearMocks: true, collectCoverage: true, coverageDirectory: "./.reports/unit/coverage", - coverageProvider: "v8", + coverageProvider: "babel", coveragePathIgnorePatterns: ["/__tests__/", "/node_modules/"], transform: { "^.+\\.ts$": "ts-jest" }, testPathIgnorePatterns: [".build"], diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index 5cc0128..4a4be91 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@nhs-notify-client-callbacks/models": "*", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts new file mode 100644 index 0000000..3e36e3e --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from ".."; + +describe("cache ttl configuration", () => { + it("falls back to default TTL when invalid", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(60_000); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts new file mode 100644 index 0000000..13aa374 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from ".."; + +describe("cache ttl configuration", () => { + it("uses the configured TTL when valid", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "120", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(120_000); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts new file mode 100644 index 0000000..40c1637 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable import-x/first */ +// eslint-disable-next-line unicorn/no-useless-undefined +const mockLoadClientConfig = jest.fn().mockResolvedValue(undefined); +const mockConfigLoader = jest.fn().mockImplementation(() => ({ + loadClientConfig: mockLoadClientConfig, +})); + +jest.mock("services/config-loader", () => ({ + ConfigLoader: mockConfigLoader, +})); + +jest.mock("aws-embedded-metrics", () => ({ + createMetricsLogger: jest.fn(() => ({ + setNamespace: jest.fn(), + setDimensions: jest.fn(), + putMetric: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined as unknown), + })), + Unit: { + Count: "Count", + Milliseconds: "Milliseconds", + }, +})); + +import type { SQSRecord } from "aws-lambda"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { handler, resetConfigLoader } from ".."; + +const makeSqsRecord = (body: object): SQSRecord => ({ + messageId: "sqs-id", + receiptHandle: "receipt", + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue", + awsRegion: "eu-west-2", +}); + +const validEvent = { + specversion: "1.0", + id: "event-id", + source: "/nhs/england/notify/development/primary/data-plane/client-callbacks", + subject: "customer/test/message/msg-123", + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00.000Z", + datacontenttype: "application/json", + dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: "DELIVERED", + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }, +}; + +describe("config prefix resolution", () => { + beforeEach(() => { + mockLoadClientConfig.mockClear(); + mockConfigLoader.mockClear(); + resetConfigLoader(); // force lazy re-creation of ConfigLoader on next call + process.env.METRICS_NAMESPACE = "test-namespace"; + process.env.ENVIRONMENT = "test"; + }); + + afterEach(() => { + delete process.env.METRICS_NAMESPACE; + delete process.env.ENVIRONMENT; + }); + + it("uses the default prefix when env is not set", async () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + await handler([makeSqsRecord(validEvent)]); + + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ + keyPrefix: "client_subscriptions/", + }), + ); + + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } + }); + + it("uses the configured prefix when env is set", async () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + + await handler([makeSqsRecord(validEvent)]); + + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ + keyPrefix: "custom_prefix/", + }), + ); + + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts new file mode 100644 index 0000000..d0c4bca --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -0,0 +1,237 @@ +/** + * Integration-style test for the complete handler flow including S3 config loading and + * subscription filtering. Uses the real ConfigLoader + ConfigCache + filter pipeline + * with a mocked S3Client. + */ +/* eslint-disable import-x/first */ +import { Readable } from "node:stream"; + +// Mock S3Client before importing the handler +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + const actual = jest.requireActual("@aws-sdk/client-s3"); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ + send: mockSend, + })), + }; +}); + +jest.mock("aws-embedded-metrics", () => ({ + createMetricsLogger: jest.fn(() => ({ + setNamespace: jest.fn(), + setDimensions: jest.fn(), + putMetric: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined as unknown), + })), + Unit: { + Count: "Count", + Milliseconds: "Milliseconds", + }, +})); + +import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; +import type { SQSRecord } from "aws-lambda"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { createS3Client, handler, resetConfigLoader } from ".."; + +const makeSqsRecord = (body: object): SQSRecord => ({ + messageId: "sqs-id", + receiptHandle: "receipt", + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue", + awsRegion: "eu-west-2", +}); + +const createValidConfig = (clientId: string) => [ + { + Name: `${clientId}-message`, + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify([]), + EventDetail: JSON.stringify({}), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${clientId}-target`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED", "FAILED"], + }, +]; + +const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({ + specversion: "1.0", + id: "event-id", + source: "/nhs/england/notify/development/primary/data-plane/client-callbacks", + subject: `customer/test/message/msg-123`, + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00.000Z", + datacontenttype: "application/json", + dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus, + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId, + }, +}); + +describe("Lambda handler with S3 subscription filtering", () => { + beforeAll(() => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"; + process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"; + process.env.METRICS_NAMESPACE = "test-namespace"; + process.env.ENVIRONMENT = "test"; + }); + + beforeEach(() => { + mockSend.mockClear(); + // Reset loader and clear cache for clean state between tests + resetConfigLoader( + createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }), + ); + }); + + afterAll(() => { + resetConfigLoader(); + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS; + delete process.env.METRICS_NAMESPACE; + delete process.env.ENVIRONMENT; + }); + + it("passes event through when client config matches subscription", async () => { + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + ]); + + expect(result).toHaveLength(1); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("filters out event when status is not in subscription", async () => { + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")), + ]); + + expect(result).toHaveLength(0); + }); + + it("filters out event when client has no configuration in S3", async () => { + mockSend.mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-no-config", "DELIVERED")), + ]); + + expect(result).toHaveLength(0); + }); + + it("passes matching events and filters non-matching in the same batch", async () => { + // First call (client-1 DELIVERED) → match + // Second call (client-1 CREATED) → no match + // Both share the same client config (cached after first call) + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")), + ]); + + // Only the DELIVERED event passes the filter + expect(result).toHaveLength(1); + expect((result[0].data as { messageStatus: string }).messageStatus).toBe( + "DELIVERED", + ); + }); + + it("passes all events through when no config bucket is configured", async () => { + resetConfigLoader(); // clear loader – no bucket → filtering disabled + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + ]); + + expect(result).toHaveLength(1); + expect(mockSend).not.toHaveBeenCalled(); + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = + originalBucket ?? "test-bucket"; + }); + + it("loads configs for multiple distinct clients in parallel and deduplicates S3 fetches", async () => { + mockSend.mockImplementation((cmd: { input: { Key: string } }) => { + const clientId = cmd.input.Key.replace( + "client_subscriptions/", + "", + ).replace(".json", ""); + return Promise.resolve({ + Body: Readable.from([JSON.stringify(createValidConfig(clientId))]), + }); + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-b", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")), // duplicate client + ]); + + // All three events match their respective configs + expect(result).toHaveLength(3); + // S3 fetched once per distinct client (client-a and client-b), not once per event + expect(mockSend).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts new file mode 100644 index 0000000..c47cdf2 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts @@ -0,0 +1,64 @@ +import { createS3Client, resetConfigLoader } from ".."; + +describe("resetConfigLoader", () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + beforeEach(() => { + // Ensure bucket is set for tests that need it + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + }); + + afterEach(() => { + // Clean up after each test + resetConfigLoader(); + + // Restore original env + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + }); + + it("resets the cached loader to undefined when called with no arguments", () => { + resetConfigLoader(); + + // The loader should be reset (we can't directly test this without exposing internal state, + // but we can test that calling it again with a custom client works) + expect(() => resetConfigLoader()).not.toThrow(); + }); + + it("creates a new loader with custom S3Client when provided", () => { + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + + // Should not throw and should create the loader + resetConfigLoader(customClient); + + // Calling resetConfigLoader again with undefined should clear it + expect(() => resetConfigLoader()).not.toThrow(); + }); + + it("creates a new loader with custom keyPrefix when environment variable is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + + // Should not throw and should create the loader + expect(() => resetConfigLoader(customClient)).not.toThrow(); + + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + }); + + it("throws error when S3Client provided but bucket name is missing", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + const customClient = createS3Client(); + + expect(() => resetConfigLoader(customClient)).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts new file mode 100644 index 0000000..2c0d207 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts @@ -0,0 +1,32 @@ +import { createS3Client } from ".."; + +describe("createS3Client", () => { + it("sets forcePathStyle=true when endpoint contains localhost", () => { + const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; + const client = createS3Client(env); + + // Access the config through the client's config property + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + expect(config.forcePathStyle).toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { + const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; + const client = createS3Client(env); + + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint is not set", () => { + const env = {}; + const client = createS3Client(env); + + const { config } = client as any; + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 13de95b..80e673e 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -174,7 +174,7 @@ describe("Lambda handler", () => { }; await expect(handler([sqsMessage])).rejects.toThrow( - 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"', + "Validation failed: type: Invalid option", ); }); @@ -260,6 +260,60 @@ describe("Lambda handler", () => { ); }); + it("should use 'Unknown error' message when a non-Error is thrown during SQS message parsing", async () => { + const faultyMetrics = { + emitEventReceived: jest.fn(), + emitValidationError: jest.fn(), + emitTransformationFailure: jest.fn(), + emitDeliveryInitiated: jest.fn(), + emitTransformationSuccess: jest.fn(), + }; + const faultyLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + addContext: jest.fn(), + clearContext: jest.fn(), + }; + const faultyObservability = { + recordProcessingStarted: jest.fn(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw "non-error-thrown"; + }), + getLogger: jest.fn().mockReturnValue(faultyLogger), + getMetrics: jest.fn().mockReturnValue(faultyMetrics), + // eslint-disable-next-line unicorn/no-useless-undefined + flush: jest.fn().mockResolvedValue(undefined), + } as unknown as ObservabilityService; + + const faultyHandler = createHandler({ + createObservabilityService: () => faultyObservability, + }); + + const sqsMessage: SQSRecord = { + messageId: "sqs-msg-id-non-error", + receiptHandle: "receipt-handle-non-error", + body: JSON.stringify(validMessageStatusEvent), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "mock-md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", + awsRegion: "eu-west-2", + }; + + await expect(faultyHandler([sqsMessage])).rejects.toThrow( + "Failed to parse SQS message body as JSON: Unknown error", + ); + }); + it("should process empty batch successfully", async () => { const result = await handler([]); @@ -405,6 +459,7 @@ describe("createHandler default wiring", () => { expect(state.processEvents).toHaveBeenCalledWith( [], state.mockObservabilityInstance, + undefined, ); expect(result).toEqual(["ok"]); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts index 03206bf..e8b9308 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts @@ -21,7 +21,7 @@ describe("callback-logger", () => { }); describe("logCallbackGenerated", () => { - describe("MESSAGE_STATUS_TRANSITIONED events", () => { + describe("MESSAGE_STATUS_PUBLISHED events", () => { const messageStatusPayload: ClientCallbackPayload = { data: [ { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts new file mode 100644 index 0000000..4341818 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -0,0 +1,86 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "services/config-cache"; + +describe("ConfigCache", () => { + it("stores and retrieves configuration", () => { + const cache = new ConfigCache(60_000); + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + const result = cache.get("client-1"); + + expect(result).toEqual(config); + }); + + it("returns undefined for non-existent key", () => { + const cache = new ConfigCache(60_000); + const result = cache.get("non-existent"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for expired entries", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); + + const cache = new ConfigCache(1000); // 1 second TTL + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + + // Advance time past expiry + jest.advanceTimersByTime(1500); + + const result = cache.get("client-1"); + + expect(result).toBeUndefined(); + + jest.useRealTimers(); + }); + + it("clears all entries", () => { + const cache = new ConfigCache(60_000); + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + cache.set("client-2", config); + + cache.clear(); + + expect(cache.get("client-1")).toBeUndefined(); + expect(cache.get("client-2")).toBeUndefined(); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts new file mode 100644 index 0000000..adccbbc --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -0,0 +1,178 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; +import { ConfigValidationError } from "services/validators/config-validator"; + +const createValidConfig = (clientId: string) => [ + { + Name: `${clientId}-message`, + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${clientId}-target`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, +]; + +const createLoader = (send: jest.Mock) => + new ConfigLoader({ + bucketName: "bucket", + keyPrefix: "client_subscriptions/", + s3Client: { send } as unknown as S3Client, + cache: new ConfigCache(60_000), + }); + +describe("ConfigLoader", () => { + it("loads and validates client configuration from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + expect(send.mock.calls[0][0].input).toEqual({ + Bucket: "bucket", + Key: "client_subscriptions/client-1.json", + }); + }); + + it("returns cached configuration on subsequent calls", async () => { + const send = jest.fn().mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + const loader = createLoader(send); + + await loader.loadClientConfig("client-1"); + await loader.loadClientConfig("client-1"); + + expect(send).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when the configuration file is missing", async () => { + const send = jest + .fn() + .mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).resolves.toBeUndefined(); + }); + + it("throws when configuration fails validation", async () => { + const send = jest.fn().mockResolvedValue({ + Body: Readable.from([ + JSON.stringify([{ SubscriptionType: "MessageStatus" }]), + ]), + }); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + ConfigValidationError, + ); + }); + + it("throws when S3 response body is empty", async () => { + const send = jest.fn().mockResolvedValue({}); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + "S3 response body was empty", + ); + }); + + it("handles string response body from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: JSON.stringify(createValidConfig("client-1")), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("handles Uint8Array response body from S3", async () => { + const configString = JSON.stringify(createValidConfig("client-1")); + const uint8Array = new TextEncoder().encode(configString); + const send = jest.fn().mockResolvedValue({ + Body: uint8Array, + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("handles readable stream with Buffer chunks", async () => { + const configString = JSON.stringify(createValidConfig("client-1")); + const send = jest.fn().mockResolvedValue({ + Body: Readable.from([Buffer.from(configString)]), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("throws when response body is not readable", async () => { + const send = jest.fn().mockResolvedValue({ + Body: 12_345, + }); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + "Response body is not readable", + ); + }); + + it("rethrows non-NoSuchKey errors", async () => { + const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + "S3 access denied", + ); + }); + + it("wraps non-Error values thrown by S3 in an Error", async () => { + const send = jest.fn().mockRejectedValue("unexpected string error"); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toBe( + "unexpected string error", + ); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts new file mode 100644 index 0000000..7934b60 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts @@ -0,0 +1,115 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; + +describe("config update integration", () => { + it("reloads configuration after cache expiry", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); + + const send = jest + .fn() + .mockResolvedValueOnce({ + Body: Readable.from([ + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]), + ]), + }) + .mockResolvedValueOnce({ + Body: Readable.from([ + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["FAILED"], + }, + ]), + ]), + }); + + const loader = new ConfigLoader({ + bucketName: "bucket", + keyPrefix: "client_subscriptions/", + s3Client: { send } as unknown as S3Client, + cache: new ConfigCache(1000), + }); + + const first = await loader.loadClientConfig("client-1"); + const firstMessage = first?.find( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + expect(firstMessage?.Statuses).toEqual(["DELIVERED"]); + + jest.advanceTimersByTime(1500); + + const second = await loader.loadClientConfig("client-1"); + const secondMessage = second?.find( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + expect(secondMessage?.Statuses).toEqual(["FAILED"]); + + jest.useRealTimers(); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts new file mode 100644 index 0000000..be8d9cd --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts @@ -0,0 +1,100 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +const createSubscription = ( + eventSource: string[], + eventDetail: Record, +): ClientSubscriptionConfiguration[number] => ({ + Name: "test", + ClientId: "client-1", + Description: "Test subscription", + EventSource: JSON.stringify(eventSource), + EventDetail: JSON.stringify(eventDetail), + Targets: [], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], +}); + +describe("matchesEventPattern", () => { + it("matches when source and detail match", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "MessageStatus", + }); + + expect(result).toBe(true); + }); + + it("matches when sources list is empty", () => { + const subscription = createSubscription([], { + clientId: ["client-1"], + }); + + const result = matchesEventPattern(subscription, "any-source", { + clientId: "client-1", + }); + + expect(result).toBe(true); + }); + + it("does not match when source is different", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + }); + + const result = matchesEventPattern(subscription, "source-b", { + clientId: "client-1", + }); + + expect(result).toBe(false); + }); + + it("does not match when detail value is different", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "ChannelStatus", + }); + + expect(result).toBe(false); + }); + + it("does not match when detail key is missing in event", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + channel: ["EMAIL"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "MessageStatus", + // channel is missing + }); + + expect(result).toBe(false); + }); + + it("does not match when detail value is undefined", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: undefined, + }); + + expect(result).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts index 5e6b146..32c8a24 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts @@ -299,6 +299,16 @@ describe("extractCorrelationId", () => { expect(correlationId).toBeUndefined(); }); + + it("should return undefined when id is present but not a string", () => { + const event = { + id: 42, + }; + + const correlationId = extractCorrelationId(event); + + expect(correlationId).toBeUndefined(); + }); }); describe("logLifecycleEvent", () => { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts new file mode 100644 index 0000000..d5a4a10 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -0,0 +1,1060 @@ +import type { + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: notifyData, +}); + +describe("subscription filters", () => { + it("matches message status subscriptions by client, status, and event pattern", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects message status subscriptions when event source mismatches", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches channel status subscriptions by channel and supplier status", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects channel status subscriptions when channel does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "SMS", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when event source mismatches", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when clientId does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-2", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when status does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "FAILED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when clientId does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-2", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when channelStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "FAILED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when supplierStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "rejected", + previousSupplierStatus: "notified", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when neither status changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", // No change + supplierStatus: "read", + previousSupplierStatus: "read", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches when only channelStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "notified", + previousSupplierStatus: "notified", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], // Not subscribed to NOTIFIED + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "SENDING", + previousChannelStatus: "SENDING", // No change + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty supplierStatuses array when channelStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty channelStatuses array when supplierStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty array = not subscribed to any channel status changes + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects with both arrays empty", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty + SupplierStatuses: [], // Empty + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts new file mode 100644 index 0000000..c71818a --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts @@ -0,0 +1,265 @@ +import type { + Channel, + ChannelStatus, + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatus, + MessageStatusData, + StatusPublishEvent, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { evaluateSubscriptionFilters } from "services/transform-pipeline"; + +const createMessageStatusEvent = ( + clientId: string, + status: MessageStatus, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: status, + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId, + }, +}); + +const createChannelStatusEvent = ( + clientId: string, + channel: Channel, + channelStatus: ChannelStatus, + supplierStatus: SupplierStatus, + previousChannelStatus?: ChannelStatus, + previousSupplierStatus?: SupplierStatus, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.CHANNEL_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: { + messageId: "msg-123", + messageReference: "ref-123", + channel, + channelStatus, + previousChannelStatus, + supplierStatus, + previousSupplierStatus, + cascadeType: "primary" as const, + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId, + }, +}); + +const createMessageStatusConfig = ( + clientId: string, + statuses: MessageStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: "client-message", + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: statuses, + }, +]; + +const createChannelStatusConfig = ( + clientId: string, + channelType: Channel, + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: `client-${channelType}`, + ClientId: clientId, + Description: `${channelType} channel status subscription`, + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: [channelType], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: channelType, + ChannelStatuses: channelStatuses, + SupplierStatuses: supplierStatuses, + }, +]; + +describe("evaluateSubscriptionFilters", () => { + describe("when config is undefined", () => { + it("returns not matched with Unknown subscription type", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config + const result = evaluateSubscriptionFilters(event, undefined); + + expect(result).toEqual({ + matched: false, + subscriptionType: "Unknown", + }); + }); + }); + + describe("when event is MessageStatus", () => { + it("returns matched true when status matches subscription", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "MessageStatus", + }); + }); + + it("returns matched false when status does not match subscription", () => { + const event = createMessageStatusEvent("client-1", "FAILED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "MessageStatus", + }); + }); + }); + + describe("when event is ChannelStatus", () => { + it("returns matched true when channel and statuses match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "DELIVERED", + "delivered", + "SENDING", // previousChannelStatus (changed) + "notified", // previousSupplierStatus (changed) + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "ChannelStatus", + }); + }); + + it("returns matched false when channel status does not match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "FAILED", + "delivered", + "FAILED", // previousChannelStatus (no change) + "delivered", // previousSupplierStatus (no change) + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "ChannelStatus", + }); + }); + }); + + describe("when event type is unknown", () => { + it("returns not matched with Unknown subscription type", () => { + const event = { + ...createMessageStatusEvent("client-1", "DELIVERED"), + type: "unknown-event-type", + } as StatusPublishEvent; + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "Unknown", + }); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts new file mode 100644 index 0000000..49e3158 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts @@ -0,0 +1,106 @@ +import { + type ChannelStatusData, + type MessageStatusData, + type StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { TransformationError } from "services/error-handler"; +import { transformEvent } from "services/transformers/event-transformer"; + +const baseEvent = { + specversion: "1.0", + source: "/nhs/england/notify/development/primary/data-plane/messaging", + subject: "customer/client-abc-123/message/msg-789-xyz", + time: "2026-02-05T14:30:00.000Z", + datacontenttype: "application/json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", +}; + +const messageStatusEvent: StatusPublishEvent = { + ...baseEvent, + id: "msg-event-id-001", + dataschema: "https://notify.nhs.uk/schemas/message-status-published-v1.json", + type: "uk.nhs.notify.message.status.PUBLISHED.v1", + data: { + clientId: "client-abc-123", + messageId: "msg-789-xyz", + messageReference: "client-ref-12345", + messageStatus: "DELIVERED", + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2026-02-05T14:29:55Z", + routingPlan: { + id: "routing-plan-123", + name: "NHS App", + version: "v1", + createdDate: "2023-11-17T14:27:51.413Z", + }, + }, +}; + +const channelStatusEvent: StatusPublishEvent = { + ...baseEvent, + id: "ch-event-id-001", + dataschema: "https://notify.nhs.uk/schemas/channel-status-published-v1.json", + type: "uk.nhs.notify.channel.status.PUBLISHED.v1", + data: { + clientId: "client-abc-123", + messageId: "msg-789-xyz", + messageReference: "client-ref-12345", + channel: "NHSAPP", + channelStatus: "DELIVERED", + supplierStatus: "delivered", + cascadeType: "primary", + cascadeOrder: 1, + retryCount: 0, + timestamp: "2026-02-05T14:29:55Z", + }, +}; + +describe("event-transformer", () => { + describe("transformEvent", () => { + it("transforms a message status event", () => { + const result = transformEvent(messageStatusEvent, "corr-id-001"); + + expect(result.data[0].type).toBe("MessageStatus"); + }); + + it("transforms a channel status event", () => { + const result = transformEvent(channelStatusEvent, "corr-id-002"); + + expect(result.data[0].type).toBe("ChannelStatus"); + }); + + it("throws TransformationError for unsupported event type", () => { + const unsupportedEvent = { + ...messageStatusEvent, + type: "uk.nhs.notify.unsupported.event.v1", + } as unknown as StatusPublishEvent; + + expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow( + TransformationError, + ); + + expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow( + "Unsupported event type: uk.nhs.notify.unsupported.event.v1", + ); + }); + + it("includes correlationId in TransformationError when provided", () => { + const unsupportedEvent = { + ...messageStatusEvent, + type: "uk.nhs.notify.unknown.v1", + } as unknown as StatusPublishEvent; + + let caughtError: unknown; + try { + transformEvent(unsupportedEvent, "test-correlation-id"); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(TransformationError); + expect((caughtError as TransformationError).message).toBe( + "Unsupported event type: uk.nhs.notify.unknown.v1", + ); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts new file mode 100644 index 0000000..8fb189d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -0,0 +1,138 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + ConfigValidationError, + validateClientConfig, +} from "services/validators/config-validator"; + +const createValidConfig = (): ClientSubscriptionConfiguration => [ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + { + Name: "client-channel", + ClientId: "client-1", + Description: "Channel status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, +]; + +describe("validateClientConfig", () => { + it("returns the config when valid", () => { + const config = createValidConfig(); + + expect(validateClientConfig(config)).toEqual(config); + }); + + it("throws when config is not an array", () => { + expect(() => validateClientConfig({})).toThrow(ConfigValidationError); + }); + + it("throws when invocation endpoint is not https", () => { + const config = createValidConfig(); + config[0].Targets[0].InvocationEndpoint = "http://example.com"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when subscription names are not unique", () => { + const config = createValidConfig(); + config[1].Name = config[0].Name; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventSource is invalid JSON", () => { + const config = createValidConfig(); + config[0].EventSource = "not-json"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventSource is valid JSON but not an array", () => { + const config = createValidConfig(); + config[0].EventSource = JSON.stringify({ not: "array" }); + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventDetail is invalid JSON", () => { + const config = createValidConfig(); + config[0].EventDetail = "not-json"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventDetail is valid JSON but not a record of string arrays", () => { + const config = createValidConfig(); + config[0].EventDetail = JSON.stringify({ key: "not-array" }); + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when InvocationEndpoint is not a valid URL", () => { + const config = createValidConfig(); + config[0].Targets[0].InvocationEndpoint = "not-a-url"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts index ec9d424..81413b4 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts @@ -70,7 +70,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"', + "Validation failed: type: Invalid option", ); }); }); @@ -318,6 +318,34 @@ describe("event-validator", () => { jest.unmock("cloudevents"); }); + + it("should format generic Error exceptions during validation", () => { + jest.resetModules(); + + jest.isolateModules(() => { + const { ValidationError: RealCloudEventsValidationError } = + jest.requireActual("cloudevents"); + + jest.doMock("cloudevents", () => ({ + CloudEvent: jest.fn(() => { + throw new Error("generic processing error"); + }), + ValidationError: RealCloudEventsValidationError, + })); + + const moduleUnderTest = jest.requireActual( + "services/validators/event-validator", + ); + + expect(() => + moduleUnderTest.validateStatusPublishEvent({ + specversion: "1.0", + }), + ).toThrow("generic processing error"); + }); + + jest.unmock("cloudevents"); + }); }); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 3f2dc6a..5e5b919 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -9,6 +9,8 @@ import { transformEvent } from "services/transformers/event-transformer"; import { extractCorrelationId } from "services/logger"; import { ValidationError, getEventError } from "services/error-handler"; import type { ObservabilityService } from "services/observability"; +import type { ConfigLoader } from "services/config-loader"; +import { evaluateSubscriptionFilters } from "services/transform-pipeline"; const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10; @@ -21,6 +23,8 @@ class BatchStats { failed = 0; + filtered = 0; + processed = 0; recordSuccess(): void { @@ -33,10 +37,15 @@ class BatchStats { this.processed += 1; } + recordFiltered(): void { + this.filtered += 1; + } + toObject() { return { successful: this.successful, failed: this.failed, + filtered: this.filtered, processed: this.processed, }; } @@ -117,6 +126,51 @@ function recordDeliveryInitiated( } } +async function filterBatch( + transformedEvents: TransformedEvent[], + configLoader: ConfigLoader, + observability: ObservabilityService, + stats: BatchStats, +): Promise { + const uniqueClientIds = [ + ...new Set(transformedEvents.map((e) => e.data.clientId)), + ]; + + const configEntries = await pMap( + uniqueClientIds, + async (clientId) => { + const config = await configLoader.loadClientConfig(clientId); + return [clientId, config] as const; + }, + { concurrency: BATCH_CONCURRENCY }, + ); + + const configByClientId = new Map(configEntries); + + const filtered: TransformedEvent[] = []; + + for (const event of transformedEvents) { + const { clientId } = event.data; + const config = configByClientId.get(clientId); + const filterResult = evaluateSubscriptionFilters(event, config); + + if (filterResult.matched) { + filtered.push(event); + } else { + stats.recordFiltered(); + observability + .getLogger() + .info("Event filtered out - no matching subscription", { + clientId, + eventType: event.type, + subscriptionType: filterResult.subscriptionType, + }); + } + } + + return filtered; +} + async function transformBatch( sqsRecords: SQSRecord[], observability: ObservabilityService, @@ -146,6 +200,7 @@ async function transformBatch( export async function processEvents( event: SQSRecord[], observability: ObservabilityService, + configLoader?: ConfigLoader, ): Promise { const startTime = Date.now(); const stats = new BatchStats(); @@ -153,6 +208,10 @@ export async function processEvents( try { const transformedEvents = await transformBatch(event, observability, stats); + const filteredEvents = configLoader + ? await filterBatch(transformedEvents, configLoader, observability, stats) + : transformedEvents; + const processingTime = Date.now() - startTime; observability.logBatchProcessingCompleted({ ...stats.toObject(), @@ -160,10 +219,10 @@ export async function processEvents( processingTimeMs: processingTime, }); - recordDeliveryInitiated(transformedEvents, observability); + recordDeliveryInitiated(filteredEvents, observability); await observability.flush(); - return transformedEvents; + return filteredEvents; } catch (error) { stats.recordFailure(); diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts index 757b83c..60bd978 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -1,9 +1,82 @@ +import { S3Client } from "@aws-sdk/client-s3"; import type { SQSRecord } from "aws-lambda"; import { Logger } from "services/logger"; import { CallbackMetrics, createMetricLogger } from "services/metrics"; import { ObservabilityService } from "services/observability"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; import { type TransformedEvent, processEvents } from "handler"; +const DEFAULT_CACHE_TTL_SECONDS = 60; + +export const resolveCacheTtlMs = ( + env: NodeJS.ProcessEnv = process.env, +): number => { + const configuredTtlSeconds = Number.parseInt( + env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`, + 10, + ); + const cacheTtlSeconds = Number.isFinite(configuredTtlSeconds) + ? configuredTtlSeconds + : DEFAULT_CACHE_TTL_SECONDS; + return cacheTtlSeconds * 1000; +}; + +const configCache = new ConfigCache(resolveCacheTtlMs()); + +let cachedLoader: ConfigLoader | undefined; + +export const createS3Client = ( + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + return new S3Client({ endpoint, forcePathStyle }); +}; + +const getConfigLoader = (): ConfigLoader | undefined => { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + // Config bucket not configured - subscription filtering disabled + return undefined; + } + + if (cachedLoader) { + return cachedLoader; + } + + cachedLoader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? "client_subscriptions/", + s3Client: createS3Client(), + cache: configCache, + }); + + return cachedLoader; +}; + +// Exported for testing - resets the cached loader (and clears the config cache) to allow +// clean state between tests, with optional custom S3Client injection +export const resetConfigLoader = (s3Client?: S3Client): void => { + cachedLoader = undefined; + configCache.clear(); + if (s3Client) { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + cachedLoader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client, + cache: configCache, + }); + } +}; + export interface HandlerDependencies { createObservabilityService: () => ObservabilityService; } @@ -25,7 +98,7 @@ export function createHandler( return async (event: SQSRecord[]): Promise => { const observability = createObservabilityService(); - return processEvents(event, observability); + return processEvents(event, observability, getConfigLoader()); }; } diff --git a/lambdas/client-transform-filter-lambda/src/services/config-cache.ts b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts new file mode 100644 index 0000000..e371fdd --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts @@ -0,0 +1,37 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +type CacheEntry = { + value: ClientSubscriptionConfiguration; + expiresAt: number; +}; + +export class ConfigCache { + private readonly cache = new Map(); + + constructor(private readonly ttlMs: number) {} + + get(clientId: string): ClientSubscriptionConfiguration | undefined { + const entry = this.cache.get(clientId); + if (!entry) { + return undefined; + } + + if (entry.expiresAt <= Date.now()) { + this.cache.delete(clientId); + return undefined; + } + + return entry.value; + } + + set(clientId: string, value: ClientSubscriptionConfiguration): void { + this.cache.set(clientId, { + value, + expiresAt: Date.now() + this.ttlMs, + }); + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts new file mode 100644 index 0000000..452f8db --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -0,0 +1,98 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "services/config-cache"; +import { logger } from "services/logger"; +import { + ConfigValidationError, + validateClientConfig, +} from "services/validators/config-validator"; + +type ConfigLoaderOptions = { + bucketName: string; + keyPrefix: string; + s3Client: S3Client; + cache: ConfigCache; +}; + +const isReadableStream = (value: unknown): value is Readable => + typeof value === "object" && value !== null && "on" in value; + +const streamToString = async (value: unknown): Promise => { + if (typeof value === "string") { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("utf8"); + } + + if (isReadableStream(value)) { + const chunks: Buffer[] = []; + for await (const chunk of value) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); + } + + throw new Error("Response body is not readable"); +}; + +export class ConfigLoader { + constructor(private readonly options: ConfigLoaderOptions) {} + + async loadClientConfig( + clientId: string, + ): Promise { + const cached = this.options.cache.get(clientId); + if (cached) { + logger.debug("Config loaded from cache", { clientId, cacheHit: true }); + return cached; + } + + logger.debug("Config not in cache, fetching from S3", { + clientId, + cacheHit: false, + }); + + try { + const response = await this.options.s3Client.send( + new GetObjectCommand({ + Bucket: this.options.bucketName, + Key: `${this.options.keyPrefix}${clientId}.json`, + }), + ); + + if (!response.Body) { + throw new Error("S3 response body was empty"); + } + + const rawConfig = await streamToString(response.Body); + const parsedConfig = JSON.parse(rawConfig) as unknown; + const validated = validateClientConfig(parsedConfig); + this.options.cache.set(clientId, validated); + logger.info("Config loaded successfully from S3", { + clientId, + subscriptionCount: validated.length, + }); + return validated; + } catch (error) { + if (error instanceof NoSuchKey) { + logger.info("No config found in S3 for client", { clientId }); + return undefined; + } + if (error instanceof ConfigValidationError) { + logger.error("Config validation failed with schema violations", { + clientId, + validationErrors: error.issues, + }); + throw error; + } + logger.error("Failed to load config from S3", { + clientId, + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts new file mode 100644 index 0000000..8bd40de --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -0,0 +1,108 @@ +import type { + ChannelStatusData, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusPublishEvent; + notifyData: ChannelStatusData; +}; + +const isChannelStatusSubscription = ( + subscription: ClientSubscriptionConfiguration[number], +): subscription is ChannelStatusSubscriptionConfiguration => + subscription.SubscriptionType === "ChannelStatus"; + +export const matchesChannelStatusSubscription = ( + config: ClientSubscriptionConfiguration, + context: FilterContext, +): boolean => { + const { event, notifyData } = context; + + const matched = config + .filter((sub) => isChannelStatusSubscription(sub)) + .some((subscription) => { + if (subscription.ClientId !== notifyData.clientId) { + return false; + } + + if (subscription.ChannelType !== notifyData.channel) { + logger.debug("Channel status filter rejected: channel type mismatch", { + clientId: notifyData.clientId, + channel: notifyData.channel, + expectedChannel: subscription.ChannelType, + }); + return false; + } + + // Check if supplier status changed AND client is subscribed to it + const supplierStatusChanged = + notifyData.previousSupplierStatus !== notifyData.supplierStatus; + const clientSubscribedSupplierStatus = + subscription.SupplierStatuses.includes(notifyData.supplierStatus); + + // Check if channel status changed AND client is subscribed to it + const channelStatusChanged = + notifyData.previousChannelStatus !== notifyData.channelStatus; + const clientSubscribedChannelStatus = + subscription.ChannelStatuses.includes(notifyData.channelStatus); + + const statusMatch = + (supplierStatusChanged && clientSubscribedSupplierStatus) || + (channelStatusChanged && clientSubscribedChannelStatus); + + if (!statusMatch) { + logger.debug( + "Channel status filter rejected: no matching status change for subscription", + { + clientId: notifyData.clientId, + channelStatus: notifyData.channelStatus, + previousChannelStatus: notifyData.previousChannelStatus, + channelStatusChanged, + clientSubscribedChannelStatus, + supplierStatus: notifyData.supplierStatus, + previousSupplierStatus: notifyData.previousSupplierStatus, + supplierStatusChanged, + clientSubscribedSupplierStatus, + subscribedChannelStatuses: subscription.ChannelStatuses, + subscribedSupplierStatuses: subscription.SupplierStatuses, + }, + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + channel: notifyData.channel, + clientId: notifyData.clientId, + type: "ChannelStatus", + }); + + if (!patternMatch) { + logger.debug("Channel status filter rejected: event pattern mismatch", { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }); + } + + return patternMatch; + }); + + if (matched) { + logger.info("Channel status filter matched", { + clientId: notifyData.clientId, + channel: notifyData.channel, + channelStatus: notifyData.channelStatus, + previousChannelStatus: notifyData.previousChannelStatus, + supplierStatus: notifyData.supplierStatus, + previousSupplierStatus: notifyData.previousSupplierStatus, + eventSource: event.source, + }); + } + + return matched; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts new file mode 100644 index 0000000..1ad9f30 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts @@ -0,0 +1,45 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +type EventPattern = { + sources: string[]; + detail: Record; +}; + +const parseEventPattern = ( + subscription: ClientSubscriptionConfiguration[number], +): EventPattern => { + const sources = JSON.parse(subscription.EventSource) as string[]; + const detail = JSON.parse(subscription.EventDetail) as Record< + string, + string[] + >; + return { sources, detail }; +}; + +const matchesEventSource = (sources: string[], source: string): boolean => + sources.length === 0 || sources.includes(source); + +const matchesEventDetail = ( + detail: Record, + eventDetail: Record, +): boolean => + Object.entries(detail).every(([key, values]) => { + // eslint-disable-next-line security/detect-object-injection + const value = eventDetail[key]; + if (!value) { + return false; + } + return values.includes(value); + }); + +export const matchesEventPattern = ( + subscription: ClientSubscriptionConfiguration[number], + eventSource: string, + eventDetail: Record, +): boolean => { + const pattern = parseEventPattern(subscription); + return ( + matchesEventSource(pattern.sources, eventSource) && + matchesEventDetail(pattern.detail, eventDetail) + ); +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts new file mode 100644 index 0000000..1da0f96 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -0,0 +1,70 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatusData, + MessageStatusSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusPublishEvent; + notifyData: MessageStatusData; +}; + +const isMessageStatusSubscription = ( + subscription: ClientSubscriptionConfiguration[number], +): subscription is MessageStatusSubscriptionConfiguration => + subscription.SubscriptionType === "MessageStatus"; + +export const matchesMessageStatusSubscription = ( + config: ClientSubscriptionConfiguration, + context: FilterContext, +): boolean => { + const { event, notifyData } = context; + + const matched = config + .filter((sub) => isMessageStatusSubscription(sub)) + .some((subscription) => { + if (subscription.ClientId !== notifyData.clientId) { + return false; + } + + if (!subscription.Statuses.includes(notifyData.messageStatus)) { + logger.debug( + "Message status filter rejected: status not in subscription", + { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + expectedStatuses: subscription.Statuses, + }, + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + clientId: notifyData.clientId, + type: "MessageStatus", + }); + + if (!patternMatch) { + logger.debug("Message status filter rejected: event pattern mismatch", { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }); + } + + return patternMatch; + }); + + if (matched) { + logger.info("Message status filter matched", { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + eventSource: event.source, + }); + } + + return matched; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index bb6126d..bfbe279 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -42,6 +42,7 @@ export class ObservabilityService { logBatchProcessingCompleted(context: { successful: number; failed: number; + filtered: number; processed: number; batchSize: number; processingTimeMs: number; diff --git a/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts new file mode 100644 index 0000000..11b6615 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts @@ -0,0 +1,46 @@ +import type { + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; +import { logger } from "services/logger"; + +type FilterResult = { + matched: boolean; + subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown"; +}; + +export const evaluateSubscriptionFilters = ( + event: StatusPublishEvent, + config: ClientSubscriptionConfiguration | undefined, +): FilterResult => { + if (!config) { + logger.debug("No config available for filtering", { + eventType: event.type, + }); + return { matched: false, subscriptionType: "Unknown" }; + } + + if (event.type === EventTypes.MESSAGE_STATUS_PUBLISHED) { + const notifyData = event.data as MessageStatusData; + return { + matched: matchesMessageStatusSubscription(config, { event, notifyData }), + subscriptionType: "MessageStatus", + }; + } + + if (event.type === EventTypes.CHANNEL_STATUS_PUBLISHED) { + const notifyData = event.data as ChannelStatusData; + return { + matched: matchesChannelStatusSubscription(config, { event, notifyData }), + subscriptionType: "ChannelStatus", + }; + } + + logger.warn("Unknown event type for filtering", { eventType: event.type }); + return { matched: false, subscriptionType: "Unknown" }; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts new file mode 100644 index 0000000..01ebef6 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -0,0 +1,176 @@ +import { z } from "zod"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; + +type ValidationIssue = { + path: string; + message: string; +}; + +export class ConfigValidationError extends Error { + constructor(public readonly issues: ValidationIssue[]) { + super("Client subscription configuration validation failed"); + } +} + +const jsonStringArraySchema = z.array(z.string()); +const jsonRecordSchema = z.record(z.string(), z.array(z.string())); + +const eventSourceSchema = z.string().superRefine((value, ctx) => { + try { + const parsed = JSON.parse(value) as unknown; + const result = jsonStringArraySchema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: "custom", + message: "Expected JSON array of strings", + }); + } + } catch { + ctx.addIssue({ + code: "custom", + message: "Expected valid JSON array", + }); + } +}); + +const eventDetailSchema = z.string().superRefine((value, ctx) => { + try { + const parsed = JSON.parse(value) as unknown; + const result = jsonRecordSchema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: "custom", + message: "Expected JSON object of string arrays", + }); + } + } catch { + ctx.addIssue({ + code: "custom", + message: "Expected valid JSON object", + }); + } +}); + +const httpsUrlSchema = z.string().refine( + (value) => { + try { + const parsed = new URL(value); + return parsed.protocol === "https:"; + } catch { + return false; + } + }, + { + message: "Expected HTTPS URL", + }, +); + +const targetSchema = z.object({ + Type: z.literal("API"), + TargetId: z.string(), + Name: z.string(), + InputTransformer: z.object({ + InputPaths: z.string(), + InputHeaders: z.object({ + "x-hmac-sha256-signature": z.string(), + }), + }), + InvocationEndpoint: httpsUrlSchema, + InvocationMethod: z.literal("POST"), + InvocationRateLimit: z.number(), + APIKey: z.object({ + HeaderName: z.string(), + HeaderValue: z.string(), + }), +}); + +const baseSubscriptionSchema = z.object({ + Name: z.string(), + ClientId: z.string(), + Description: z.string(), + EventSource: eventSourceSchema, + EventDetail: eventDetailSchema, + Targets: z.array(targetSchema).min(1), +}); + +const messageStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("MessageStatus"), + Statuses: z.array(z.enum(MESSAGE_STATUSES)), +}); + +const channelStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("ChannelStatus"), + ChannelType: z.enum(CHANNEL_TYPES), + ChannelStatuses: z.array(z.enum(CHANNEL_STATUSES)), + SupplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)), +}); + +const subscriptionSchema = z.discriminatedUnion("SubscriptionType", [ + messageStatusSchema, + channelStatusSchema, +]); + +const configSchema = z.array(subscriptionSchema).superRefine((config, ctx) => { + const seenNames = new Set(); + + for (const [index, subscription] of config.entries()) { + if (seenNames.has(subscription.Name)) { + ctx.addIssue({ + code: "custom", + message: "Expected Name to be unique", + path: [index, "Name"], + }); + } else { + seenNames.add(subscription.Name); + } + } +}); + +const formatIssuePath = (path: (string | number)[]): string => { + let formatted = "config"; + + for (const segment of path) { + formatted = + typeof segment === "number" + ? `${formatted}[${segment}]` + : `${formatted}.${segment}`; + } + + return formatted; +}; + +export const validateClientConfig = ( + rawConfig: unknown, +): ClientSubscriptionConfiguration => { + const result = configSchema.safeParse(rawConfig); + + if (!result.success) { + const issues = result.error.issues.map((issue) => { + const pathSegments = issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ); + + return { + path: formatIssuePath(pathSegments), + message: issue.message, + }; + }); + throw new ConfigValidationError(issues); + } + + return result.data; +}; + +export type { ValidationIssue }; + +export { + type ChannelStatusSubscriptionConfiguration, + type MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; diff --git a/package-lock.json b/package-lock.json index 6ba1c04..180d025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "lambdas/client-transform-filter-lambda", "src/models", "lambdas/mock-webhook-lambda", - "tests/integration" + "tests/integration", + "tools/client-subscriptions-management" ], "devDependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@stylistic/eslint-plugin": "^3.1.0", "@tsconfig/node22": "^22.0.2", "@types/jest": "^29.5.14", @@ -47,6 +49,7 @@ "name": "nhs-notify-client-transform-filter-lambda", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@nhs-notify-client-callbacks/models": "*", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", @@ -64,6 +67,26 @@ "typescript": "^5.8.2" } }, + "lambdas/client-transform-filter-lambda/node_modules/p-map": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "lambdas/client-transform-filter-lambda/node_modules/zod": { + "version": "4.3.6", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "lambdas/mock-webhook-lambda": { "name": "nhs-notify-mock-webhook-lambda", "version": "0.0.1", @@ -84,9 +107,7 @@ } }, "lambdas/mock-webhook-lambda/node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "version": "22.19.11", "dev": true, "license": "MIT", "dependencies": { @@ -95,15 +116,23 @@ }, "lambdas/mock-webhook-lambda/node_modules/undici-types": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -114,10 +143,61 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -131,8 +211,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -143,8 +221,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -156,8 +232,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -169,8 +243,6 @@ }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -183,8 +255,6 @@ }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -192,8 +262,6 @@ }, "node_modules/@aws-crypto/util": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -203,8 +271,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -215,8 +281,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -228,8 +292,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -240,52 +302,157 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1000.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1000.0.tgz", - "integrity": "sha512-8/YP++CiBIh5jADEmPfBCHYWErHNYlG5Ome5h82F/yB+x6i9ARF/Y/u95Z9IHwO25CDvxTPKH0U66h7HFL8tcg==", + "version": "3.991.0", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-node": "^3.972.14", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.15", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/eventstream-serde-browser": "^4.2.10", - "@smithy/eventstream-serde-config-resolver": "^4.3.10", - "@smithy/eventstream-serde-node": "^4.2.10", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.991.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.991.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/credential-provider-node": "^3.972.11", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.10", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-location-constraint": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-s3": "^3.972.12", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.12", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.996.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.996.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.11", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.2", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-retry": "^4.4.33", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.32", + "@smithy/util-defaults-mode-node": "^4.2.35", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.12", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" }, "engines": { @@ -293,52 +460,50 @@ } }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.1000.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1000.0.tgz", - "integrity": "sha512-fGp197WE/wy05DNAKLokN21RwhH17go631U6GT/t3BwHv7DBd5oI4OLT5TLy0dc4freAd3ib3XET1OEc1TG/3Q==", + "version": "3.990.0", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-node": "^3.972.14", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-sdk-sqs": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.15", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/md5-js": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-sqs": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -347,8 +512,6 @@ }, "node_modules/@aws-sdk/core": { "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.15.tgz", - "integrity": "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -369,10 +532,19 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.13.tgz", - "integrity": "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -387,8 +559,6 @@ }, "node_modules/@aws-sdk/credential-provider-http": { "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.15.tgz", - "integrity": "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -408,8 +578,6 @@ }, "node_modules/@aws-sdk/credential-provider-ini": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.13.tgz", - "integrity": "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -433,8 +601,6 @@ }, "node_modules/@aws-sdk/credential-provider-login": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.13.tgz", - "integrity": "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -452,8 +618,6 @@ }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.14.tgz", - "integrity": "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.13", @@ -475,8 +639,6 @@ }, "node_modules/@aws-sdk/credential-provider-process": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.13.tgz", - "integrity": "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -492,8 +654,6 @@ }, "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.13.tgz", - "integrity": "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -511,8 +671,6 @@ }, "node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz", - "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -527,10 +685,60 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.10", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", - "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -542,10 +750,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", - "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -558,8 +776,6 @@ }, "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", - "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -572,18 +788,51 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.12", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.23.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.11.tgz", - "integrity": "sha512-Y4dryR0y7wN3hBayLOVSRuP3FeTs8KbNEL4orW/hKpf4jsrneDpI2RifUQVhiyb3QkC83bpeKaOSa0waHiPvcg==", + "version": "3.972.7", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.1", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -592,8 +841,6 @@ }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.15.tgz", - "integrity": "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -608,10 +855,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", - "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -657,10 +916,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/region-config-resolver": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", - "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -675,8 +946,6 @@ }, "node_modules/@aws-sdk/token-providers": { "version": "3.999.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.999.0.tgz", - "integrity": "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.15", @@ -693,8 +962,6 @@ }, "node_modules/@aws-sdk/types": { "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -704,16 +971,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.990.0", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" }, "engines": { @@ -722,8 +998,6 @@ }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", - "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -734,8 +1008,6 @@ }, "node_modules/@aws-sdk/util-user-agent-browser": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", - "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -746,8 +1018,6 @@ }, "node_modules/@aws-sdk/util-user-agent-node": { "version": "3.973.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.0.tgz", - "integrity": "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.15", @@ -770,8 +1040,6 @@ }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.8.tgz", - "integrity": "sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -784,19 +1052,17 @@ }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -805,7 +1071,7 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", + "version": "7.27.5", "dev": true, "license": "MIT", "engines": { @@ -813,20 +1079,20 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", + "version": "7.27.4", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -850,14 +1116,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", + "version": "7.27.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" }, "engines": { @@ -865,11 +1131,11 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -887,34 +1153,26 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", + "version": "7.27.3", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -924,7 +1182,7 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -940,7 +1198,7 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -956,23 +1214,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.27.6", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", + "version": "7.27.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -1029,11 +1287,11 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1065,11 +1323,11 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1173,11 +1431,11 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1187,42 +1445,50 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", + "version": "7.27.4", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { - "version": "7.29.0", + "version": "7.27.6", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1255,19 +1521,17 @@ }, "node_modules/@datastructures-js/heap": { "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@datastructures-js/heap/-/heap-4.3.7.tgz", - "integrity": "sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==", "license": "MIT" }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" @@ -1290,17 +1554,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "dev": true, @@ -1322,6 +1575,26 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "dev": true, @@ -1345,18 +1618,18 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", + "version": "3.3.1", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", + "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1366,6 +1639,34 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "9.39.3", "dev": true, @@ -1406,17 +1707,29 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.7", + "version": "0.16.6", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@humanwhocodes/retry": "^0.3.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "dev": true, @@ -1477,7 +1790,7 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", + "version": "3.14.1", "dev": true, "license": "MIT", "dependencies": { @@ -1819,25 +2132,28 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", + "version": "0.3.8", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", "dev": true, "license": "MIT", "engines": { @@ -1845,12 +2161,12 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", + "version": "1.5.0", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", + "version": "0.3.25", "dev": true, "license": "MIT", "dependencies": { @@ -1858,6 +2174,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.3.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@nhs-notify-client-callbacks/models": { "resolved": "src/models", "link": true @@ -1896,8 +2252,6 @@ }, "node_modules/@pinojs/redact": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, "node_modules/@pkgr/core": { @@ -1911,8 +2265,15 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", + "version": "0.27.8", "dev": true, "license": "MIT" }, @@ -1934,8 +2295,6 @@ }, "node_modules/@smithy/abort-controller": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", - "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -1945,10 +2304,29 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.2", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz", - "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -1964,8 +2342,6 @@ }, "node_modules/@smithy/core": { "version": "3.23.6", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz", - "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.11", @@ -1985,8 +2361,6 @@ }, "node_modules/@smithy/credential-provider-imds": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz", - "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -2000,14 +2374,12 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz", - "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -2015,13 +2387,11 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.10.tgz", - "integrity": "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2029,12 +2399,10 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.10.tgz", - "integrity": "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==", + "version": "4.3.8", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2042,13 +2410,11 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.10.tgz", - "integrity": "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2056,13 +2422,11 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.10.tgz", - "integrity": "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2071,8 +2435,6 @@ }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", - "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2085,10 +2447,21 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.10", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.1", + "@smithy/chunked-blob-reader-native": "^4.2.2", + "@smithy/types": "^4.12.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/hash-node": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2100,10 +2473,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.9", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.1", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", - "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2115,8 +2498,6 @@ }, "node_modules/@smithy/is-array-buffer": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", - "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2126,14 +2507,11 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.10.tgz", - "integrity": "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==", - "dev": true, + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -2142,8 +2520,6 @@ }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", - "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2156,8 +2532,6 @@ }, "node_modules/@smithy/middleware-endpoint": { "version": "4.4.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz", - "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.6", @@ -2175,8 +2549,6 @@ }, "node_modules/@smithy/middleware-retry": { "version": "4.4.37", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz", - "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -2195,8 +2567,6 @@ }, "node_modules/@smithy/middleware-serde": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz", - "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2209,8 +2579,6 @@ }, "node_modules/@smithy/middleware-stack": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz", - "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2222,8 +2590,6 @@ }, "node_modules/@smithy/node-config-provider": { "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz", - "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.10", @@ -2237,8 +2603,6 @@ }, "node_modules/@smithy/node-http-handler": { "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz", - "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==", "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.10", @@ -2253,8 +2617,6 @@ }, "node_modules/@smithy/property-provider": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz", - "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2266,8 +2628,6 @@ }, "node_modules/@smithy/protocol-http": { "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", - "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2279,8 +2639,6 @@ }, "node_modules/@smithy/querystring-builder": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz", - "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2293,8 +2651,6 @@ }, "node_modules/@smithy/querystring-parser": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz", - "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2306,8 +2662,6 @@ }, "node_modules/@smithy/service-error-classification": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz", - "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0" @@ -2318,8 +2672,6 @@ }, "node_modules/@smithy/shared-ini-file-loader": { "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz", - "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2331,8 +2683,6 @@ }, "node_modules/@smithy/signature-v4": { "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", - "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.1", @@ -2350,8 +2700,6 @@ }, "node_modules/@smithy/smithy-client": { "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz", - "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.6", @@ -2368,8 +2716,6 @@ }, "node_modules/@smithy/types": { "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2380,8 +2726,6 @@ }, "node_modules/@smithy/url-parser": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz", - "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==", "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.2.10", @@ -2394,8 +2738,6 @@ }, "node_modules/@smithy/util-base64": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", - "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.1", @@ -2408,8 +2750,6 @@ }, "node_modules/@smithy/util-body-length-browser": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", - "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2420,8 +2760,6 @@ }, "node_modules/@smithy/util-body-length-node": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", - "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2432,8 +2770,6 @@ }, "node_modules/@smithy/util-buffer-from": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", - "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.1", @@ -2445,8 +2781,6 @@ }, "node_modules/@smithy/util-config-provider": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", - "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2457,8 +2791,6 @@ }, "node_modules/@smithy/util-defaults-mode-browser": { "version": "4.3.36", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz", - "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.10", @@ -2472,8 +2804,6 @@ }, "node_modules/@smithy/util-defaults-mode-node": { "version": "4.2.39", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz", - "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==", "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.9", @@ -2490,8 +2820,6 @@ }, "node_modules/@smithy/util-endpoints": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz", - "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -2504,8 +2832,6 @@ }, "node_modules/@smithy/util-hex-encoding": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2516,8 +2842,6 @@ }, "node_modules/@smithy/util-middleware": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", - "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2529,8 +2853,6 @@ }, "node_modules/@smithy/util-retry": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz", - "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.10", @@ -2543,8 +2865,6 @@ }, "node_modules/@smithy/util-stream": { "version": "4.5.15", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz", - "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==", "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.11", @@ -2562,8 +2882,6 @@ }, "node_modules/@smithy/util-uri-escape": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", - "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2572,13 +2890,23 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", - "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", + "node_modules/@smithy/util-utf8": { + "version": "4.2.1", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.9", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/abort-controller": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -2587,8 +2915,6 @@ }, "node_modules/@smithy/uuid": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", - "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2615,6 +2941,28 @@ "eslint": ">=8.40.0" } }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "dev": true, @@ -2624,7 +2972,7 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", + "version": "1.0.11", "dev": true, "license": "MIT" }, @@ -2644,12 +2992,12 @@ "license": "MIT" }, "node_modules/@tsconfig/node22": { - "version": "22.0.5", + "version": "22.0.2", "dev": true, "license": "MIT" }, "node_modules/@types/aws-lambda": { - "version": "8.10.160", + "version": "8.10.150", "dev": true, "license": "MIT" }, @@ -2683,11 +3031,11 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.28.0", + "version": "7.20.7", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.20.7" } }, "node_modules/@types/estree": { @@ -2748,12 +3096,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/node": { - "version": "25.3.0", + "version": "24.0.3", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/stack-utils": { @@ -2767,7 +3122,7 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.35", + "version": "17.0.33", "dev": true, "license": "MIT", "dependencies": { @@ -2806,14 +3161,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/@typescript-eslint/parser": { "version": "8.56.1", "dev": true, @@ -2998,28 +3345,16 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.9.2", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, "node_modules/abab": { @@ -3028,7 +3363,7 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.16.0", + "version": "8.15.0", "dev": true, "license": "MIT", "bin": { @@ -3056,7 +3391,7 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.5", + "version": "8.3.4", "dev": true, "license": "MIT", "dependencies": { @@ -3079,8 +3414,6 @@ }, "node_modules/aggregate-error": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -3092,15 +3425,13 @@ }, "node_modules/aggregate-error/node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ajv": { - "version": "6.14.0", + "version": "6.12.6", "dev": true, "license": "MIT", "dependencies": { @@ -3116,8 +3447,6 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -3133,8 +3462,6 @@ }, "node_modules/ajv-formats/node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3149,8 +3476,6 @@ }, "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/ansi-escapes": { @@ -3167,9 +3492,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3177,7 +3512,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3201,17 +3535,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -3266,6 +3589,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.3", "dev": true, @@ -3300,6 +3666,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "dev": true, @@ -3335,8 +3718,6 @@ }, "node_modules/async-wait-until": { "version": "2.0.31", - "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.31.tgz", - "integrity": "sha512-9VCfHvc4f36oT6sG5p16aKc9zojf3wF4FrjNDxU3Db51SJ1bQ5lWAWtQDDZPysTwSLKBDzNZ083qPkTIj6XnrA==", "dev": true, "license": "MIT", "engines": { @@ -3355,8 +3736,6 @@ }, "node_modules/atomic-sleep": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", "engines": { "node": ">=8.0.0" @@ -3377,8 +3756,6 @@ }, "node_modules/aws-embedded-metrics": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/aws-embedded-metrics/-/aws-embedded-metrics-4.2.1.tgz", - "integrity": "sha512-uzydBXlGQVTB2sZ9ACCQZM3y0u4wdvxxRKFL9LP6RdfI2GcOrCcAsz65UKQvX9iagxFhah322VvvatgP8E7MIg==", "license": "Apache-2.0", "dependencies": { "@datastructures-js/heap": "^4.0.2" @@ -3388,7 +3765,7 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", + "version": "4.10.3", "dev": true, "license": "MPL-2.0", "engines": { @@ -3476,7 +3853,7 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", + "version": "1.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -3497,7 +3874,7 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "@babel/core": "^7.0.0" } }, "node_modules/babel-preset-jest": { @@ -3516,28 +3893,12 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", + "version": "1.0.2", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } + "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -3545,12 +3906,10 @@ }, "node_modules/bowser": { "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.3", + "version": "5.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -3560,6 +3919,14 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "dev": true, @@ -3572,7 +3939,7 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.25.0", "dev": true, "funding": [ { @@ -3590,11 +3957,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3704,7 +4070,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", + "version": "1.0.30001724", "dev": true, "funding": [ { @@ -3746,7 +4112,7 @@ } }, "node_modules/ci-info": { - "version": "4.4.0", + "version": "4.2.0", "dev": true, "funding": [ { @@ -3785,16 +4151,17 @@ }, "node_modules/clean-stack": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/client-subscriptions-management": { + "resolved": "tools/client-subscriptions-management", + "link": true + }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3807,8 +4174,6 @@ }, "node_modules/cloudevents": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.3.tgz", - "integrity": "sha512-wTixKNjfLeyj9HQpESvLVVO4xgdqdvX4dTeg1IZ2SCunu/fxVzCamcIZneEyj31V82YolFCKwVeSkr8zResB0Q==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0", @@ -3824,8 +4189,6 @@ }, "node_modules/cloudevents/node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3840,8 +4203,6 @@ }, "node_modules/cloudevents/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/co": { @@ -3854,13 +4215,12 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.3", + "version": "1.0.2", "dev": true, "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3871,7 +4231,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3886,13 +4245,18 @@ } }, "node_modules/comment-parser": { - "version": "1.4.5", + "version": "1.4.1", "dev": true, "license": "MIT", "engines": { "node": ">= 12.0.0" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "dev": true, @@ -3904,11 +4268,11 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.48.0", + "version": "3.43.0", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.25.0" }, "funding": { "type": "opencollective", @@ -4065,12 +4429,12 @@ } }, "node_modules/decimal.js": { - "version": "10.6.0", + "version": "10.5.0", "dev": true, "license": "MIT" }, "node_modules/dedent": { - "version": "1.7.1", + "version": "1.6.0", "dev": true, "license": "MIT", "peerDependencies": { @@ -4143,7 +4507,7 @@ } }, "node_modules/diff": { - "version": "4.0.4", + "version": "4.0.2", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4158,6 +4522,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "dev": true, @@ -4244,7 +4621,7 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.302", + "version": "1.5.173", "dev": true, "license": "ISC" }, @@ -4260,12 +4637,11 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, + "version": "8.0.0", "license": "MIT" }, "node_modules/entities": { - "version": "7.0.1", + "version": "6.0.1", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4276,7 +4652,7 @@ } }, "node_modules/error-ex": { - "version": "1.3.4", + "version": "1.3.2", "dev": true, "license": "MIT", "dependencies": { @@ -4284,7 +4660,7 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "version": "1.24.0", "dev": true, "license": "MIT", "dependencies": { @@ -4364,6 +4740,34 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -4416,7 +4820,7 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", + "version": "0.25.0", "hasInstallScript": true, "license": "MIT", "bin": { @@ -4426,37 +4830,35 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4606,7 +5008,7 @@ } }, "node_modules/eslint-config-airbnb-extended/node_modules/globals": { - "version": "16.5.0", + "version": "16.2.0", "dev": true, "license": "MIT", "engines": { @@ -4653,6 +5055,28 @@ } } }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/eslint-import-resolver-typescript": { "version": "4.4.4", "dev": true, @@ -4686,6 +5110,34 @@ } } }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/eslint-plugin-html": { "version": "8.1.4", "dev": true, @@ -4694,7 +5146,41 @@ "htmlparser2": "^10.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=16.0.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import-x": { @@ -4732,6 +5218,50 @@ } } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jest": { "version": "28.14.0", "dev": true, @@ -4796,6 +5326,31 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-no-relative-import-paths": { "version": "v1.6.1", "dev": true, @@ -4830,6 +5385,104 @@ } } }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-security": { "version": "3.0.1", "dev": true, @@ -4948,7 +5601,7 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "16.5.0", + "version": "16.2.0", "dev": true, "license": "MIT", "engines": { @@ -4974,6 +5627,26 @@ } }, "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "dev": true, "license": "Apache-2.0", @@ -4984,6 +5657,25 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "10.4.0", "dev": true, @@ -5000,6 +5692,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "dev": true, @@ -5013,7 +5716,7 @@ } }, "node_modules/esquery": { - "version": "1.7.0", + "version": "1.6.0", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5141,8 +5844,6 @@ }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -5157,8 +5858,6 @@ }, "node_modules/fast-xml-parser": { "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "funding": [ { "type": "github", @@ -5174,7 +5873,7 @@ } }, "node_modules/fastq": { - "version": "1.20.1", + "version": "1.19.1", "dev": true, "license": "ISC", "dependencies": { @@ -5189,22 +5888,6 @@ "bser": "2.1.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -5284,7 +5967,7 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -5303,6 +5986,18 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -5342,13 +6037,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -5359,7 +6047,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5434,7 +6121,7 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", + "version": "4.10.1", "dev": true, "license": "MIT", "dependencies": { @@ -5474,6 +6161,26 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "14.0.0", "dev": true, @@ -5628,7 +6335,7 @@ "license": "MIT" }, "node_modules/htmlparser2": { - "version": "10.1.0", + "version": "10.0.0", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -5641,8 +6348,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" + "domutils": "^3.2.1", + "entities": "^6.0.0" } }, "node_modules/http-proxy-agent": { @@ -5690,7 +6397,7 @@ } }, "node_modules/ignore": { - "version": "5.3.2", + "version": "7.0.5", "dev": true, "license": "MIT", "engines": { @@ -5777,8 +6484,6 @@ }, "node_modules/is-arguments": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -5971,7 +6676,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5986,12 +6690,11 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.2", + "version": "1.1.0", "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -6259,7 +6962,7 @@ } }, "node_modules/istanbul-reports": { - "version": "3.2.0", + "version": "3.1.7", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6270,6 +6973,24 @@ "node": ">=8" } }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jest": { "version": "29.7.0", "dev": true, @@ -6810,17 +7531,6 @@ "node": ">=8" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "dev": true, @@ -6900,7 +7610,7 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -6967,8 +7677,6 @@ }, "node_modules/json-bigint": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -7154,7 +7862,7 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.17.21", "dev": true, "license": "MIT" }, @@ -7168,6 +7876,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -7235,17 +7956,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "dev": true, @@ -7273,8 +7983,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { - "version": "10.2.3", + "version": "10.2.4", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7312,7 +8030,7 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.4", + "version": "0.2.4", "dev": true, "license": "MIT", "bin": { @@ -7358,7 +8076,7 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.19", "dev": true, "license": "MIT" }, @@ -7382,10 +8100,20 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", + "version": "2.2.20", "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "dev": true, @@ -7424,6 +8152,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.fromentries": { "version": "2.0.8", "dev": true, @@ -7441,6 +8185,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.groupby": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.values": { "version": "1.2.1", "dev": true, @@ -7460,8 +8219,6 @@ }, "node_modules/on-exit-leak-free": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -7549,21 +8306,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "dev": true, @@ -7611,17 +8353,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -7657,11 +8388,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "2.3.1", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7669,8 +8400,6 @@ }, "node_modules/pino": { "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", @@ -7691,8 +8420,6 @@ }, "node_modules/pino-abstract-transport": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", "license": "MIT", "dependencies": { "split2": "^4.0.0" @@ -7700,8 +8427,6 @@ }, "node_modules/pino-std-serializers": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, "node_modules/pirates": { @@ -7795,7 +8520,7 @@ } }, "node_modules/prettier": { - "version": "3.8.1", + "version": "3.6.0", "dev": true, "license": "MIT", "peer": true, @@ -7846,8 +8571,6 @@ }, "node_modules/process": { "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -7855,8 +8578,6 @@ }, "node_modules/process-warning": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", @@ -7881,6 +8602,25 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/psl": { "version": "1.15.0", "dev": true, @@ -7941,8 +8681,6 @@ }, "node_modules/quick-format-unescaped": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, "node_modules/react-is": { @@ -7952,8 +8690,6 @@ }, "node_modules/real-require": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", "engines": { "node": ">= 12.13.0" @@ -8054,7 +8790,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8062,8 +8797,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8075,11 +8808,11 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", + "version": "1.22.10", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -8225,8 +8958,6 @@ }, "node_modules/safe-stable-stringify": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { "node": ">=10" @@ -8421,8 +9152,6 @@ }, "node_modules/sonic-boom": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -8447,8 +9176,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" @@ -8512,7 +9239,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8523,11 +9249,6 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "dev": true, @@ -8541,6 +9262,45 @@ "node": ">= 0.4" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "dev": true, @@ -8596,7 +9356,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8622,9 +9381,12 @@ } }, "node_modules/strip-indent": { - "version": "4.1.1", + "version": "4.0.0", "dev": true, "license": "MIT", + "dependencies": { + "min-indent": "^1.0.1" + }, "engines": { "node": ">=12" }, @@ -8645,8 +9407,6 @@ }, "node_modules/strnum": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -8709,10 +9469,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/thread-stream": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" @@ -8733,6 +9511,33 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, @@ -8902,18 +9707,52 @@ } } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", + "version": "4.20.3", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -8926,23 +9765,23 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.3", + "version": "0.25.5", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8953,32 +9792,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/type-check": { @@ -9000,17 +9838,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "dev": true, @@ -9082,7 +9909,7 @@ } }, "node_modules/typescript": { - "version": "5.9.3", + "version": "5.8.3", "dev": true, "license": "Apache-2.0", "bin": { @@ -9145,7 +9972,7 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", + "version": "7.8.0", "dev": true, "license": "MIT" }, @@ -9158,40 +9985,40 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", + "version": "1.9.2", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.2.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.9.2", + "@unrs/resolver-binding-android-arm64": "1.9.2", + "@unrs/resolver-binding-darwin-arm64": "1.9.2", + "@unrs/resolver-binding-darwin-x64": "1.9.2", + "@unrs/resolver-binding-freebsd-x64": "1.9.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-x64-musl": "1.9.2", + "@unrs/resolver-binding-wasm32-wasi": "1.9.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", + "version": "1.1.3", "dev": true, "funding": [ { @@ -9238,8 +10065,6 @@ }, "node_modules/util": { "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9251,8 +10076,6 @@ }, "node_modules/uuid": { "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -9442,7 +10265,7 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", + "version": "1.1.19", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -9475,7 +10298,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9507,7 +10329,7 @@ } }, "node_modules/ws": { - "version": "8.19.0", + "version": "8.18.2", "dev": true, "license": "MIT", "engines": { @@ -9549,7 +10371,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9562,7 +10383,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9579,7 +10399,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9604,15 +10423,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "src/models": { "name": "@nhs-notify-client-callbacks/models", "version": "0.0.1", @@ -9636,6 +10446,54 @@ "jest": "^29.7.0", "typescript": "^5.8.2" } + }, + "tools/client-subscriptions-management": { + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "@nhs-notify-client-callbacks/models": "*", + "ajv": "^8.12.0", + "fast-uri": "^3.1.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^22.10.10", + "@types/yargs": "^17.0.24", + "eslint": "^9.27.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "tools/client-subscriptions-management/node_modules/@types/node": { + "version": "22.19.11", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "tools/client-subscriptions-management/node_modules/ajv": { + "version": "8.18.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "tools/client-subscriptions-management/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "tools/client-subscriptions-management/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index c031567..37c9c5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "devDependencies": { "@stylistic/eslint-plugin": "^3.1.0", + "@aws-sdk/client-s3": "^3.821.0", "@tsconfig/node22": "^22.0.2", "@types/jest": "^29.5.14", "@typescript-eslint/parser": "^8.56.1", @@ -35,7 +36,8 @@ "pretty-format": { "react-is": "19.0.0" }, - "minimatch": "^10.2.2" + "minimatch@^3.0.0": "3.1.5", + "minimatch": "10.2.4" }, "scripts": { "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", @@ -51,6 +53,7 @@ "lambdas/client-transform-filter-lambda", "src/models", "lambdas/mock-webhook-lambda", - "tests/integration" + "tests/integration", + "tools/client-subscriptions-management" ] } diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index be946be..6a2f270 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -5,5 +5,5 @@ sonar.qualitygate.wait=true sonar.sourceEncoding=UTF-8 sonar.terraform.provider.aws.version=5.54.1 sonar.cpd.exclusions=**.test.*, src/models/** -sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, **/jest.config.ts +sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, scripts/**/src/__tests__/**, tools/**/src/__tests__/**, **/jest.config.* sonar.javascript.lcov.reportPaths=lcov.info diff --git a/scripts/deploy_client_subscriptions.sh b/scripts/deploy_client_subscriptions.sh new file mode 100644 index 0000000..be8683d --- /dev/null +++ b/scripts/deploy_client_subscriptions.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat < \ + [--terraform-apply] \ + [--environment --group --project --tf-region ] \ + -- + +Examples: + # Message status subscription + ./scripts/deploy_client_subscriptions.sh \ + --subscription-type message \ + --terraform-apply \ + --environment dev \ + --group dev \ + -- \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.com \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 + + # Channel status subscription + ./scripts/deploy_client_subscriptions.sh \ + --subscription-type channel \ + --terraform-apply \ + --environment dev \ + --group dev \ + -- \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --client-id client-123 \ + --channel-type NHSAPP \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses delivered failed \ + --api-endpoint https://webhook.example.com \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +EOF +} + +subscription_type="" +terraform_apply="false" +environment="" +group="" +project="nhs" +tf_region="" +forward_args=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --subscription-type) + subscription_type="$2" + shift 2 + ;; + --terraform-apply) + terraform_apply="true" + shift + ;; + --environment) + environment="$2" + shift 2 + ;; + --group) + group="$2" + shift 2 + ;; + --project) + project="$2" + shift 2 + ;; + --tf-region) + tf_region="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + --) + shift + forward_args+=("$@") + break + ;; + *) + forward_args+=("$1") + shift + ;; + esac +done + +if [ -z "$subscription_type" ]; then + echo "Error: --subscription-type is required" + usage + exit 1 +fi + +if [ "$subscription_type" != "message" ] && [ "$subscription_type" != "channel" ]; then + echo "Error: --subscription-type must be 'message' or 'channel'" + usage + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +echo "[deploy-client-subscriptions] Uploading subscription config ($subscription_type)..." + +if [ "$subscription_type" = "message" ]; then + npm --workspace tools/client-subscriptions-management run put-message-status -- "${forward_args[@]}" +else + npm --workspace tools/client-subscriptions-management run put-channel-status -- "${forward_args[@]}" +fi + +if [ "$terraform_apply" = "true" ]; then + if [ -z "$environment" ] || [ -z "$group" ]; then + echo "Error: --environment and --group are required for terraform apply" + exit 1 + fi + + echo "[deploy-client-subscriptions] Running terraform apply for callbacks component..." + if [ -n "$tf_region" ]; then + make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" region="$tf_region" + else + make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" + fi +fi diff --git a/src/models/src/channel-types.ts b/src/models/src/channel-types.ts index d4526fb..d50c7c7 100644 --- a/src/models/src/channel-types.ts +++ b/src/models/src/channel-types.ts @@ -1 +1,3 @@ -export type Channel = "NHSAPP" | "EMAIL" | "SMS" | "LETTER"; +export const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const; + +export type Channel = (typeof CHANNEL_TYPES)[number]; diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts index e3f5c56..6b10f90 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -1,3 +1,10 @@ +import type { Channel } from "./channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "./status-types"; + export type ClientSubscriptionConfiguration = ( | MessageStatusSubscriptionConfiguration | ChannelStatusSubscriptionConfiguration @@ -29,14 +36,16 @@ interface SubscriptionConfigurationBase { }[]; } -export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { +export interface MessageStatusSubscriptionConfiguration + extends SubscriptionConfigurationBase { SubscriptionType: "MessageStatus"; - Statuses: string[]; + Statuses: MessageStatus[]; } -export interface ChannelStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { +export interface ChannelStatusSubscriptionConfiguration + extends SubscriptionConfigurationBase { SubscriptionType: "ChannelStatus"; - ChannelType: string; - ChannelStatuses: string[]; - SupplierStatuses: string[]; + ChannelType: Channel; + ChannelStatuses: ChannelStatus[]; + SupplierStatuses: SupplierStatus[]; } diff --git a/src/models/src/index.ts b/src/models/src/index.ts index d26c42d..c01a868 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -1,4 +1,5 @@ export type { ChannelStatusData } from "./channel-status-data"; +export { CHANNEL_TYPES } from "./channel-types"; export type { Channel } from "./channel-types"; export type { CallbackItem, @@ -19,6 +20,11 @@ export type { MessageStatusData } from "./message-status-data"; export type { RoutingPlan } from "./routing-plan"; export { EventTypes } from "./status-publish-event"; export type { StatusPublishEvent } from "./status-publish-event"; +export { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "./status-types"; export type { ChannelStatus, MessageStatus, diff --git a/src/models/src/status-types.ts b/src/models/src/status-types.ts index 2d2bfef..b2e9e5c 100644 --- a/src/models/src/status-types.ts +++ b/src/models/src/status-types.ts @@ -1,41 +1,50 @@ -export type MessageStatus = - | "FAILED" - | "PENDING_ENRICHMENT" - | "DELIVERED" - | "ENRICHED" - | "SENDING"; +export const MESSAGE_STATUSES = [ + "FAILED", + "PENDING_ENRICHMENT", + "DELIVERED", + "ENRICHED", + "SENDING", +] as const; -export type ChannelStatus = - | "ASSIGNING_BATCH" - | "CREATED" - | "SENDING" - | "DELIVERED" - | "FAILED" - | "RETRY" - | "SKIPPED" - | "STALE_PDS"; +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; -export type SupplierStatus = - | "accepted" - | "cancelled" - | "created" - | "delivered" - | "dispatched" - | "enclosed" - | "failed" - | "forwarded" - | "pending" - | "printed" - | "read" - | "notification_attempted" - | "notified" - | "rejected" - | "returned" - | "sending" - | "sent" - | "received" - | "permanent_failure" - | "temporary_failure" - | "technical_failure" - | "unnotified" - | "unknown"; +export const CHANNEL_STATUSES = [ + "ASSIGNING_BATCH", + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "RETRY", + "SKIPPED", + "STALE_PDS", +] as const; + +export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; + +export const SUPPLIER_STATUSES = [ + "accepted", + "cancelled", + "created", + "delivered", + "dispatched", + "enclosed", + "failed", + "forwarded", + "pending", + "printed", + "read", + "notification_attempted", + "notified", + "rejected", + "returned", + "sending", + "sent", + "received", + "permanent_failure", + "temporary_failure", + "technical_failure", + "unnotified", + "unknown", +] as const; + +export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md new file mode 100644 index 0000000..ee93476 --- /dev/null +++ b/tools/client-subscriptions-management/README.md @@ -0,0 +1,59 @@ +# client-subscriptions-management + +TypeScript CLI utility for managing NHS Notify client subscription configuration in S3. + +## Usage + +From the repository root run: + +```bash +npm --workspace tools/client-subscriptions-management [options] +``` + +Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable. + +## Commands + +### Get Client Subscriptions By Client ID + +```bash +npm --workspace tools/client-subscriptions-management get-by-client-id \ + --bucket-name my-bucket \ + --client-id client-123 +``` + +### Put Message Status Subscription + +```bash +npm --workspace tools/client-subscriptions-management put-message-status \ + --bucket-name my-bucket \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.com \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +``` + +Optional: `--client-name "Test Client"` (defaults to client-id if not provided) + +### Put Channel Status Subscription + +```bash +npm --workspace tools/client-subscriptions-management put-channel-status \ + --bucket-name my-bucket \ + --client-id client-123 \ + --channel-type EMAIL \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses READ REJECTED \ + --api-endpoint https://webhook.example.com \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +``` + +Optional: `--client-name "Test Client"` (defaults to client-id if not provided) + +**Note**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided. diff --git a/tools/client-subscriptions-management/jest.config.ts b/tools/client-subscriptions-management/jest.config.ts new file mode 100644 index 0000000..679cd1c --- /dev/null +++ b/tools/client-subscriptions-management/jest.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "jest"; + +const jestConfig: Config = { + preset: "ts-jest", + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [String.raw`\.build`], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + testEnvironment: "node", + modulePaths: ["/src"], + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, +}; + +export default jestConfig; diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json new file mode 100644 index 0000000..f255b54 --- /dev/null +++ b/tools/client-subscriptions-management/package.json @@ -0,0 +1,29 @@ +{ + "name": "client-subscriptions-management", + "version": "0.0.1", + "private": true, + "main": "src/index.ts", + "scripts": { + "get-by-client-id": "tsx ./src/entrypoint/cli/get-client-subscriptions.ts", + "put-channel-status": "tsx ./src/entrypoint/cli/put-channel-status.ts", + "put-message-status": "tsx ./src/entrypoint/cli/put-message-status.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@nhs-notify-client-callbacks/models": "*", + "@aws-sdk/client-s3": "^3.821.0", + "ajv": "^8.12.0", + "fast-uri": "^3.1.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^22.10.10", + "@types/yargs": "^17.0.24", + "eslint": "^9.27.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts new file mode 100644 index 0000000..52c3c8e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -0,0 +1,143 @@ +const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; +process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source"; + +// eslint-disable-next-line import-x/first -- Ensure env is set before module load +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +afterAll(() => { + if (originalEventSource === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + } else { + process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = originalEventSource; + } +}); + +describe("ClientSubscriptionConfigurationBuilder", () => { + it("builds message status subscription with default event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder(); + + const result = builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + apiKeyHeaderName: "x-api-key", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["DELIVERED"], + dryRun: false, + }); + + expect(result).toMatchObject({ + Name: "client-one", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + Statuses: ["DELIVERED"], + EventSource: JSON.stringify(["env-source"]), + }); + }); + + it("builds message status subscription with explicit event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["FAILED"], + dryRun: false, + eventSource: "explicit-source", + }); + + expect(result.EventSource).toBe(JSON.stringify(["explicit-source"])); + }); + + it("builds channel status subscription with explicit event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 20, + dryRun: false, + eventSource: "explicit-source", + }); + + expect(result).toMatchObject({ + Name: "client-one-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + EventSource: JSON.stringify(["explicit-source"]), + }); + }); + + it("defaults channelStatuses and supplierStatuses to [] when not provided", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(result.ChannelStatuses).toEqual([]); + expect(result.SupplierStatuses).toEqual([]); + }); + + it("throws if no event source is available for messageStatus", () => { + const builder = new ClientSubscriptionConfigurationBuilder(""); + + expect(() => + builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["DELIVERED"], + dryRun: false, + }), + ).toThrow( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + }); + + it("throws if no event source is available for channelStatus", () => { + const builder = new ClientSubscriptionConfigurationBuilder(""); + + expect(() => + builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 20, + dryRun: false, + }), + ).toThrow( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts new file mode 100644 index 0000000..12661b3 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts @@ -0,0 +1,387 @@ +import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; +import type { S3Repository } from "src/infra/s3-repository"; +import type { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +const createRepository = ( + overrides?: Partial<{ + getObject: jest.Mock; + putRawData: jest.Mock; + messageStatus: jest.Mock; + channelStatus: jest.Mock; + }>, +) => { + const s3Repository = { + getObject: overrides?.getObject ?? jest.fn(), + putRawData: overrides?.putRawData ?? jest.fn(), + } as unknown as S3Repository; + + const configurationBuilder = { + messageStatus: overrides?.messageStatus ?? jest.fn(), + channelStatus: overrides?.channelStatus ?? jest.fn(), + } as unknown as ClientSubscriptionConfigurationBuilder; + + const repository = new ClientSubscriptionRepository( + s3Repository, + configurationBuilder, + ); + + return { repository, s3Repository, configurationBuilder }; +}; + +describe("ClientSubscriptionRepository", () => { + const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] = + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-1", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }; + + const messageSubscription: MessageStatusSubscriptionConfiguration = { + Name: "client-1", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + Statuses: ["DELIVERED"], + Description: "Message subscription", + EventSource: "[]", + EventDetail: "{}", + Targets: [baseTarget], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + Name: "client-1-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + Description: "Channel subscription", + EventSource: "[]", + EventDetail: "{}", + Targets: [baseTarget], + }; + + it("returns parsed subscriptions when file exists", async () => { + const storedConfig: ClientSubscriptionConfiguration = [messageSubscription]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const { repository } = createRepository({ getObject }); + + const result = await repository.getClientSubscriptions("client-1"); + + expect(result).toEqual(storedConfig); + }); + + it("returns undefined when config file is missing", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); + + await expect( + repository.getClientSubscriptions("client-1"), + ).resolves.toBeUndefined(); + }); + + it("replaces existing message subscription", async () => { + const storedConfig: ClientSubscriptionConfiguration = [ + channelSubscription, + messageSubscription, + ]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const putRawData = jest.fn(); + const newMessage: MessageStatusSubscriptionConfiguration = { + ...messageSubscription, + Statuses: ["FAILED"], + }; + const messageStatus = jest.fn().mockReturnValue(newMessage); + + const { repository } = createRepository({ + getObject, + putRawData, + messageStatus, + }); + + const result = await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["FAILED"], + rateLimit: 10, + dryRun: false, + }); + + expect(result).toEqual([channelSubscription, newMessage]); + expect(putRawData).toHaveBeenCalledWith( + JSON.stringify([channelSubscription, newMessage]), + "client_subscriptions/client-1.json", + ); + }); + + it("skips S3 write when dry run is enabled", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const messageStatus = jest.fn().mockReturnValue(messageSubscription); + + const { repository } = createRepository({ + getObject, + putRawData, + messageStatus, + }); + + await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: true, + }); + + expect(putRawData).not.toHaveBeenCalled(); + }); + + it("replaces existing channel subscription for the channel type", async () => { + const storedConfig: ClientSubscriptionConfiguration = [ + channelSubscription, + messageSubscription, + ]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const putRawData = jest.fn(); + const newChannel: ChannelStatusSubscriptionConfiguration = { + ...channelSubscription, + ChannelStatuses: ["FAILED"], + }; + const channelStatus = jest.fn().mockReturnValue(newChannel); + + const { repository } = createRepository({ + getObject, + putRawData, + channelStatus, + }); + + const result = await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["FAILED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(result).toEqual([messageSubscription, newChannel]); + expect(putRawData).toHaveBeenCalledWith( + JSON.stringify([messageSubscription, newChannel]), + "client_subscriptions/client-1.json", + ); + }); + + it("skips S3 write for channel status dry run", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const channelStatus = jest.fn().mockReturnValue(channelSubscription); + + const { repository } = createRepository({ + getObject, + putRawData, + channelStatus, + }); + + await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: true, + }); + + expect(putRawData).not.toHaveBeenCalled(); + }); + + describe("AJV validation", () => { + it("throws validation error for invalid message status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["INVALID_STATUS" as never], + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(/Validation failed/); + }); + + it("throws validation error for missing required fields in message subscription", async () => { + const { repository } = createRepository(); + + await expect( + repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + // @ts-expect-error Testing missing field + statuses: undefined, + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(/Validation failed/); + }); + + it("throws validation error for invalid channel type", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "INVALID_CHANNEL" as never, + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(/Validation failed/); + }); + + it("throws validation error for invalid channel status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["INVALID_STATUS" as never], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(/Validation failed/); + }); + + it("throws validation error for invalid supplier status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["INVALID_STATUS" as never], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(/Validation failed/); + }); + + it("throws validation error when neither channelStatuses nor supplierStatuses are provided", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow( + /at least one of channelStatuses or supplierStatuses must be provided/, + ); + }); + + it("applies default value for apiKeyHeaderName on message subscription", async () => { + const getObject = jest.fn().mockResolvedValue(undefined as never); + const messageStatus = jest.fn().mockReturnValue(messageSubscription); + + const { configurationBuilder, repository } = createRepository({ + getObject, + messageStatus, + }); + + await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + }); + + expect(configurationBuilder.messageStatus).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyHeaderName: "x-api-key", + }), + ); + }); + + it("applies default value for apiKeyHeaderName on channel subscription", async () => { + const getObject = jest.fn().mockResolvedValue(undefined as never); + const channelStatus = jest.fn().mockReturnValue(channelSubscription); + + const { configurationBuilder, repository } = createRepository({ + getObject, + channelStatus, + }); + + await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(configurationBuilder.channelStatus).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyHeaderName: "x-api-key", + }), + ); + }); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/constants.test.ts b/tools/client-subscriptions-management/src/__tests__/constants.test.ts new file mode 100644 index 0000000..19192f0 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/constants.test.ts @@ -0,0 +1,28 @@ +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "src/constants"; + +describe("constants", () => { + it("exposes message statuses", () => { + expect(MESSAGE_STATUSES).toContain("DELIVERED"); + expect(MESSAGE_STATUSES).toContain("FAILED"); + }); + + it("exposes channel statuses", () => { + expect(CHANNEL_STATUSES).toContain("SENDING"); + expect(CHANNEL_STATUSES).toContain("SKIPPED"); + }); + + it("exposes supplier statuses", () => { + expect(SUPPLIER_STATUSES).toContain("delivered"); + expect(SUPPLIER_STATUSES).toContain("unknown"); + }); + + it("exposes channel types", () => { + expect(CHANNEL_TYPES).toContain("SMS"); + expect(CHANNEL_TYPES).toContain("EMAIL"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts new file mode 100644 index 0000000..ec19865 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -0,0 +1,32 @@ +import { createS3Client } from "src/container"; + +describe("createS3Client", () => { + it("sets forcePathStyle=true when endpoint contains localhost", () => { + const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; + const client = createS3Client("eu-west-2", env); + + // Access the config through the client's config property + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + expect(config.forcePathStyle).toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { + const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; + const client = createS3Client("eu-west-2", env); + + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint is not set", () => { + const env = {}; + const client = createS3Client("eu-west-2", env); + + const { config } = client as any; + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts new file mode 100644 index 0000000..fcb5514 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable import-x/first */ +import { S3Client } from "@aws-sdk/client-s3"; + +const mockS3Repository = jest.fn(); +const mockBuilder = jest.fn(); +const mockRepository = jest.fn(); + +jest.mock("src/infra/s3-repository", () => ({ + S3Repository: mockS3Repository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + ClientSubscriptionConfigurationBuilder: mockBuilder, +})); + +jest.mock("src/infra/client-subscription-repository", () => ({ + ClientSubscriptionRepository: mockRepository, +})); + +import { createClientSubscriptionRepository } from "src/container"; + +describe("createClientSubscriptionRepository", () => { + it("creates repository with provided options", () => { + const repoInstance = { repo: true }; + mockRepository.mockReturnValue(repoInstance); + + const result = createClientSubscriptionRepository({ + bucketName: "bucket-1", + region: "eu-west-2", + eventSource: "event-source", + }); + + expect(mockS3Repository).toHaveBeenCalledWith( + "bucket-1", + expect.any(S3Client), + ); + expect(mockBuilder).toHaveBeenCalledWith("event-source"); + expect(mockRepository).toHaveBeenCalledTimes(1); + expect(result).toBe(repoInstance); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts new file mode 100644 index 0000000..1cad9ff --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable import-x/first, no-console */ +const mockGetClientSubscriptions = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + getClientSubscriptions: mockGetClientSubscriptions, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/get-client-subscriptions"; + +describe("get-client-subscriptions CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockGetClientSubscriptions.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("prints formatted config when subscription exists", async () => { + mockGetClientSubscriptions.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(mockCreateRepository).toHaveBeenCalled(); + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1"); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("prints message when no configuration exists", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.log).toHaveBeenCalledWith( + "No configuration exists for client: client-1", + ); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]; + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.runCli(); + + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1"); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-id", + "client-2", + "--bucket-name", + "bucket-2", + ]; + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.main(); + + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-2"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts new file mode 100644 index 0000000..441bc43 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts @@ -0,0 +1,153 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; +import { + formatSubscriptionFileResponse, + normalizeClientName, + resolveBucketName, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +describe("cli helper", () => { + const messageSubscription: MessageStatusSubscriptionConfiguration = { + Name: "client-a", + SubscriptionType: "MessageStatus", + ClientId: "client-a", + Statuses: ["DELIVERED"], + Description: "Message subscription", + EventSource: '["source-a"]', + EventDetail: "{}", + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-a", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + Name: "client-a-sms", + SubscriptionType: "ChannelStatus", + ClientId: "client-a", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + Description: "Channel subscription", + EventSource: '["source-a"]', + EventDetail: "{}", + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-a-sms", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 20, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + it("formats subscription output", () => { + const config: ClientSubscriptionConfiguration = [ + messageSubscription, + channelSubscription, + ]; + + const result = formatSubscriptionFileResponse(config); + + expect(result).toEqual([ + { + clientId: "client-a", + subscriptionType: "MessageStatus", + statuses: ["DELIVERED"], + clientApiEndpoint: "https://example.com/webhook", + clientApiKey: "secret", + rateLimit: 10, + }, + { + clientId: "client-a", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + clientApiEndpoint: "https://example.com/webhook", + clientApiKey: "secret", + rateLimit: 20, + }, + ]); + }); + + it("normalizes client name", () => { + expect(normalizeClientName("My Client Name")).toBe("my-client-name"); + }); + + it("resolves bucket name from argument", () => { + expect(resolveBucketName("bucket-1")).toBe("bucket-1"); + }); + + it("resolves bucket name from env", () => { + expect( + resolveBucketName(undefined, { + CLIENT_SUBSCRIPTION_BUCKET_NAME: "bucket-env", + } as NodeJS.ProcessEnv), + ).toBe("bucket-env"); + }); + + it("throws when bucket name is missing", () => { + expect(() => resolveBucketName(undefined, {} as NodeJS.ProcessEnv)).toThrow( + "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", + ); + }); + + it("resolves region from argument", () => { + expect(resolveRegion("eu-west-2")).toBe("eu-west-2"); + }); + + it("resolves region from AWS_REGION", () => { + expect( + resolveRegion(undefined, { + AWS_REGION: "eu-west-1", + } as NodeJS.ProcessEnv), + ).toBe("eu-west-1"); + }); + + it("resolves region from AWS_DEFAULT_REGION", () => { + expect( + resolveRegion(undefined, { + AWS_DEFAULT_REGION: "eu-west-3", + } as NodeJS.ProcessEnv), + ).toBe("eu-west-3"); + }); + + it("returns undefined when region is not set", () => { + expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts new file mode 100644 index 0000000..afe146f --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts @@ -0,0 +1,382 @@ +/* eslint-disable import-x/first, no-console */ +const mockPutChannelStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putChannelStatusSubscription: mockPutChannelStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/put-channel-status"; + +describe("put-channel-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutChannelStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled(); + }); + + it("rejects when neither channel-statuses nor supplier-statuses are provided", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + expect(process.exitCode).toBe(1); + expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled(); + }); + + it("writes channel subscription and logs response", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + "--event-source", + "source-a", + "--api-key-header-name", + "x-api-key", + ]); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({ + clientName: "Client One", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.runCli(); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalled(); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client Two", + "--client-id", + "client-2", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main(); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalled(); + }); + + it("defaults client-name to client-id when not provided", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({ + clientName: "client-1", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + eventSource: undefined, + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts new file mode 100644 index 0000000..5d2cbe5 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts @@ -0,0 +1,317 @@ +/* eslint-disable import-x/first, no-console */ +const mockPutMessageStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putMessageStatusSubscription: mockPutMessageStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/put-message-status"; + +describe("put-message-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutMessageStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockPutMessageStatusSubscription).not.toHaveBeenCalled(); + }); + + it("writes subscription and logs response", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + "--event-source", + "source-a", + "--api-key-header-name", + "x-api-key", + ]); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({ + clientName: "Client One", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.runCli(); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalled(); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client Two", + "--client-id", + "client-2", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main(); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalled(); + }); + + it("defaults client-name to client-id when not provided", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({ + clientName: "client-1", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + eventSource: undefined, + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts b/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts new file mode 100644 index 0000000..89dff3e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts @@ -0,0 +1,128 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { S3Repository } from "src/infra/s3-repository"; + +describe("S3Repository", () => { + it("returns string content from S3", async () => { + const send = jest.fn().mockResolvedValue({ Body: "content" }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("returns string content from Uint8Array", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: new TextEncoder().encode("content") }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("returns string content from readable stream", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: Readable.from([Buffer.from("content")]) }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("returns string content from string chunks", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: Readable.from(["content"]) }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("throws when body is not readable", async () => { + const send = jest.fn().mockResolvedValue({ Body: 123 }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is not readable", + ); + }); + + it("throws when body is object without stream interface", async () => { + const send = jest.fn().mockResolvedValue({ Body: {} }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is not readable", + ); + }); + + it("throws when body is missing", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response is not a readable stream", + ); + }); + + it("returns undefined when object is missing", async () => { + const send = jest + .fn() + .mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).resolves.toBeUndefined(); + }); + + it("rethrows non-NoSuchKey errors", async () => { + const send = jest.fn().mockRejectedValue(new Error("Denied")); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow("Denied"); + }); + + it("writes object to S3", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await repository.putRawData("payload", "key.json"); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); + }); +}); diff --git a/tools/client-subscriptions-management/src/constants.ts b/tools/client-subscriptions-management/src/constants.ts new file mode 100644 index 0000000..ab8af71 --- /dev/null +++ b/tools/client-subscriptions-management/src/constants.ts @@ -0,0 +1,18 @@ +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; + +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; +export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; +export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; +export type ChannelType = (typeof CHANNEL_TYPES)[number]; + +export { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + CHANNEL_TYPES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts new file mode 100644 index 0000000..1b78f14 --- /dev/null +++ b/tools/client-subscriptions-management/src/container.ts @@ -0,0 +1,32 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; +import { S3Repository } from "src/infra/s3-repository"; +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +type RepositoryOptions = { + bucketName: string; + region?: string; + eventSource?: string; +}; + +export const createS3Client = ( + region?: string, + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + return new S3Client({ region, endpoint, forcePathStyle }); +}; + +export const createClientSubscriptionRepository = ( + options: RepositoryOptions, +): ClientSubscriptionRepository => { + const s3Repository = new S3Repository( + options.bucketName, + createS3Client(options.region), + ); + const configurationBuilder = new ClientSubscriptionConfigurationBuilder( + options.eventSource, + ); + return new ClientSubscriptionRepository(s3Repository, configurationBuilder); +}; diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts new file mode 100644 index 0000000..dcc82a8 --- /dev/null +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -0,0 +1,131 @@ +import { normalizeClientName } from "src/entrypoint/cli/helper"; +import type { + ChannelStatusSubscriptionArgs, + MessageStatusSubscriptionArgs, +} from "src/infra/client-subscription-repository"; +import type { + ChannelStatusSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; + +const DEFAULT_EVENT_SOURCE = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + +// eslint-disable-next-line import-x/prefer-default-export +export class ClientSubscriptionConfigurationBuilder { + constructor( + private readonly eventSource: string | undefined = DEFAULT_EVENT_SOURCE, + ) {} + + private resolveEventSource(override?: string): string { + const source = override ?? this.eventSource; + if (!source) { + throw new Error( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + } + return source; + } + + messageStatus( + args: MessageStatusSubscriptionArgs, + ): MessageStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + clientId, + clientName, + eventSource, + rateLimit, + statuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + return { + Name: normalizedClientName, + SubscriptionType: "MessageStatus", + ClientId: clientId, + Statuses: statuses, + Description: `Message Status Subscription for ${clientName}`, + EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: normalizedClientName, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; + } + + channelStatus( + args: ChannelStatusSubscriptionArgs, + ): ChannelStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + channelStatuses, + channelType, + clientId, + clientName, + eventSource, + rateLimit, + supplierStatuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + return { + Name: `${normalizedClientName}-${channelType}`, + SubscriptionType: "ChannelStatus", + ClientId: clientId, + ChannelType: channelType, + ChannelStatuses: channelStatuses ?? [], + SupplierStatuses: supplierStatuses ?? [], + Description: `Channel Status Subscription for ${clientName} - ${channelType}`, + EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: [channelType], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${normalizedClientName}-${channelType}`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; + } +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts new file mode 100644 index 0000000..207c66f --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -0,0 +1,70 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + }); + + const result = await clientSubscriptionRepository.getClientSubscriptions( + argv["client-id"], + ); + + if (result) { + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); + } else { + // eslint-disable-next-line no-console + console.log(`No configuration exists for client: ${argv["client-id"]}`); + } +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts new file mode 100644 index 0000000..f468410 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -0,0 +1,45 @@ +import type { ClientSubscriptionConfiguration } from "src/types"; + +export const formatSubscriptionFileResponse = ( + subscriptions: ClientSubscriptionConfiguration, +) => + subscriptions.map((subscription) => ({ + clientId: subscription.ClientId, + subscriptionType: subscription.SubscriptionType, + ...(subscription.SubscriptionType === "ChannelStatus" + ? { + channelType: subscription.ChannelType, + channelStatuses: subscription.ChannelStatuses, + supplierStatuses: subscription.SupplierStatuses, + } + : {}), + ...(subscription.SubscriptionType === "MessageStatus" + ? { + statuses: subscription.Statuses, + } + : {}), + clientApiEndpoint: subscription.Targets[0].InvocationEndpoint, + clientApiKey: subscription.Targets[0].APIKey.HeaderValue, + rateLimit: subscription.Targets[0].InvocationRateLimit, + })); + +export const normalizeClientName = (name: string): string => + name.replaceAll(/\s+/g, "-").toLowerCase(); + +export const resolveBucketName = ( + bucketArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string => { + const bucketName = bucketArg ?? env.CLIENT_SUBSCRIPTION_BUCKET_NAME; + if (!bucketName) { + throw new Error( + "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", + ); + } + return bucketName; +}; + +export const resolveRegion = ( + regionArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts new file mode 100644 index 0000000..f9f4e32 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -0,0 +1,147 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + SUPPLIER_STATUSES, +} from "src/constants"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + "api-endpoint": { + type: "string", + demandOption: true, + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + }, + "api-key": { + type: "string", + demandOption: true, + }, + "channel-statuses": { + string: true, + type: "array", + demandOption: false, + choices: CHANNEL_STATUSES, + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: false, + choices: SUPPLIER_STATUSES, + }, + "channel-type": { + type: "string", + demandOption: true, + choices: CHANNEL_TYPES, + }, + "rate-limit": { + type: "number", + demandOption: true, + }, + "dry-run": { + type: "boolean", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + "event-source": { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + // eslint-disable-next-line no-console + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const channelStatuses = argv["channel-statuses"]; + const supplierStatuses = argv["supplier-statuses"]; + if (!channelStatuses?.length && !supplierStatuses?.length) { + // eslint-disable-next-line no-console + console.error( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource: argv["event-source"], + }); + + const result = + await clientSubscriptionRepository.putChannelStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + channelType: argv["channel-type"], + channelStatuses, + supplierStatuses, + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource: argv["event-source"], + }); + + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts new file mode 100644 index 0000000..3eed0c3 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -0,0 +1,119 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { MESSAGE_STATUSES } from "src/constants"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + "api-endpoint": { + type: "string", + demandOption: true, + }, + "api-key": { + type: "string", + demandOption: true, + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + }, + "message-statuses": { + string: true, + type: "array", + demandOption: true, + choices: MESSAGE_STATUSES, + }, + "rate-limit": { + type: "number", + demandOption: true, + }, + "dry-run": { + type: "boolean", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + "event-source": { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + // eslint-disable-next-line no-console + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource: argv["event-source"], + }); + + const result = + await clientSubscriptionRepository.putMessageStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + statuses: argv["message-statuses"], + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource: argv["event-source"], + }); + + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/index.ts b/tools/client-subscriptions-management/src/index.ts new file mode 100644 index 0000000..bec05b8 --- /dev/null +++ b/tools/client-subscriptions-management/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import-x/prefer-default-export +export { createClientSubscriptionRepository } from "src/container"; diff --git a/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts b/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts new file mode 100644 index 0000000..5611fb9 --- /dev/null +++ b/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts @@ -0,0 +1,230 @@ +import Ajv from "ajv"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + type ChannelStatus, + type ChannelType, + MESSAGE_STATUSES, + type MessageStatus, + SUPPLIER_STATUSES, + type SupplierStatus, +} from "src/constants"; +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import type { ClientSubscriptionConfiguration } from "src/types"; +import { S3Repository } from "src/infra/s3-repository"; + +export type MessageStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + statuses: MessageStatus[]; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const messageStatusSubscriptionArgsSchema = { + type: "object", + properties: { + clientName: { type: "string" }, + clientId: { type: "string" }, + apiKey: { type: "string" }, + apiEndpoint: { type: "string" }, + statuses: { + type: "array", + items: { type: "string", enum: MESSAGE_STATUSES }, + }, + rateLimit: { type: "number" }, + dryRun: { type: "boolean" }, + apiKeyHeaderName: { type: "string" }, + eventSource: { type: "string" }, + }, + required: [ + "clientName", + "clientId", + "apiKey", + "apiEndpoint", + "statuses", + "rateLimit", + "dryRun", + ], + additionalProperties: false, +} as const; + +export type ChannelStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; + channelType: ChannelType; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const channelStatusSubscriptionArgsSchema = { + type: "object", + properties: { + clientName: { type: "string" }, + clientId: { type: "string" }, + apiKey: { type: "string" }, + apiEndpoint: { type: "string" }, + channelStatuses: { + type: "array", + items: { type: "string", enum: CHANNEL_STATUSES }, + minItems: 1, + }, + supplierStatuses: { + type: "array", + items: { type: "string", enum: SUPPLIER_STATUSES }, + minItems: 1, + }, + channelType: { type: "string", enum: CHANNEL_TYPES }, + rateLimit: { type: "number" }, + dryRun: { type: "boolean" }, + apiKeyHeaderName: { type: "string" }, + eventSource: { type: "string" }, + }, + required: [ + "clientName", + "clientId", + "apiKey", + "apiEndpoint", + "channelType", + "rateLimit", + "dryRun", + ], + additionalProperties: false, +} as const; + +const ajv = new Ajv({ useDefaults: true }); +const validateMessageStatusArgs = ajv.compile( + messageStatusSubscriptionArgsSchema, +); +const validateChannelStatusArgs = ajv.compile( + channelStatusSubscriptionArgsSchema, +); + +export class ClientSubscriptionRepository { + constructor( + private readonly s3Repository: S3Repository, + private readonly configurationBuilder: ClientSubscriptionConfigurationBuilder, + ) {} + + async getClientSubscriptions( + clientId: string, + ): Promise { + const rawFile = await this.s3Repository.getObject( + `client_subscriptions/${clientId}.json`, + ); + + if (rawFile !== undefined) { + return JSON.parse(rawFile) as unknown as ClientSubscriptionConfiguration; + } + return undefined; + } + + async putMessageStatusSubscription( + subscriptionArgs: MessageStatusSubscriptionArgs, + ) { + const parsedSubscriptionArgs = { + apiKeyHeaderName: "x-api-key", + ...subscriptionArgs, + }; + + if (!validateMessageStatusArgs(parsedSubscriptionArgs)) { + throw new Error( + `Validation failed: ${ajv.errorsText(validateMessageStatusArgs.errors)}`, + ); + } + + const { clientId } = parsedSubscriptionArgs; + const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; + + const indexOfMessageStatusSubscription = subscriptions.findIndex( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + + if (indexOfMessageStatusSubscription !== -1) { + subscriptions.splice(indexOfMessageStatusSubscription, 1); + } + + const messageStatusConfig = this.configurationBuilder.messageStatus( + parsedSubscriptionArgs, + ); + + const newConfigFile: ClientSubscriptionConfiguration = [ + ...subscriptions, + messageStatusConfig, + ]; + + if (!parsedSubscriptionArgs.dryRun) { + await this.s3Repository.putRawData( + JSON.stringify(newConfigFile), + `client_subscriptions/${clientId}.json`, + ); + } + + return newConfigFile; + } + + async putChannelStatusSubscription( + subscriptionArgs: ChannelStatusSubscriptionArgs, + ): Promise { + const parsedSubscriptionArgs = { + apiKeyHeaderName: "x-api-key", + ...subscriptionArgs, + }; + + if (!validateChannelStatusArgs(parsedSubscriptionArgs)) { + throw new Error( + `Validation failed: ${ajv.errorsText(validateChannelStatusArgs.errors)}`, + ); + } + + if ( + !parsedSubscriptionArgs.channelStatuses?.length && + !parsedSubscriptionArgs.supplierStatuses?.length + ) { + throw new Error( + "Validation failed: at least one of channelStatuses or supplierStatuses must be provided", + ); + } + + const { clientId } = parsedSubscriptionArgs; + const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; + + const indexOfChannelStatusSubscription = subscriptions.findIndex( + (subscription) => + subscription.SubscriptionType === "ChannelStatus" && + subscription.ChannelType === parsedSubscriptionArgs.channelType, + ); + + if (indexOfChannelStatusSubscription !== -1) { + subscriptions.splice(indexOfChannelStatusSubscription, 1); + } + + const channelStatusConfig = this.configurationBuilder.channelStatus( + parsedSubscriptionArgs, + ); + + const newConfigFile: ClientSubscriptionConfiguration = [ + ...subscriptions, + channelStatusConfig, + ]; + + if (!parsedSubscriptionArgs.dryRun) { + await this.s3Repository.putRawData( + JSON.stringify(newConfigFile), + `client_subscriptions/${clientId}.json`, + ); + } + + return newConfigFile; + } +} diff --git a/tools/client-subscriptions-management/src/infra/s3-repository.ts b/tools/client-subscriptions-management/src/infra/s3-repository.ts new file mode 100644 index 0000000..59a78bf --- /dev/null +++ b/tools/client-subscriptions-management/src/infra/s3-repository.ts @@ -0,0 +1,73 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + PutObjectCommandInput, + S3Client, +} from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; + +const isReadableStream = (value: unknown): value is Readable => + typeof value === "object" && value !== null && "on" in value; + +const streamToString = async (value: unknown): Promise => { + if (typeof value === "string") { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("utf8"); + } + + if (isReadableStream(value)) { + const chunks: Buffer[] = []; + for await (const chunk of value) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); + } + + throw new Error("Response body is not readable"); +}; + +// eslint-disable-next-line import-x/prefer-default-export +export class S3Repository { + constructor( + private readonly bucketName: string, + private readonly s3Client: S3Client, + ) {} + + async getObject(key: string): Promise { + const params = { + Bucket: this.bucketName, + Key: key, + }; + try { + const { Body } = await this.s3Client.send(new GetObjectCommand(params)); + + if (!Body) { + throw new Error("Response is not a readable stream"); + } + + return await streamToString(Body); + } catch (error) { + if (error instanceof NoSuchKey) { + return undefined; + } + throw error; + } + } + + async putRawData( + fileData: PutObjectCommandInput["Body"], + key: string, + ): Promise { + const params = { + Bucket: this.bucketName, + Key: key, + Body: fileData, + }; + + await this.s3Client.send(new PutObjectCommand(params)); + } +} diff --git a/tools/client-subscriptions-management/src/types.ts b/tools/client-subscriptions-management/src/types.ts new file mode 100644 index 0000000..d24a9f6 --- /dev/null +++ b/tools/client-subscriptions-management/src/types.ts @@ -0,0 +1,51 @@ +import type { + ChannelStatus, + ChannelType, + MessageStatus, + SupplierStatus, +} from "src/constants"; + +type SubscriptionConfigurationBase = { + Name: string; + ClientId: string; + Description: string; + EventSource: string; + EventDetail: string; + Targets: { + Type: "API"; + TargetId: string; + Name: string; + InputTransformer: { + InputPaths: string; + InputHeaders: { + "x-hmac-sha256-signature": string; + }; + }; + InvocationEndpoint: string; + InvocationMethod: "POST"; + InvocationRateLimit: number; + APIKey: { + HeaderName: string; + HeaderValue: string; + }; + }[]; +}; + +export type ChannelStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + SubscriptionType: "ChannelStatus"; + ChannelType: ChannelType; + ChannelStatuses: ChannelStatus[]; + SupplierStatuses: SupplierStatus[]; + }; + +export type MessageStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + SubscriptionType: "MessageStatus"; + Statuses: MessageStatus[]; + }; + +export type ClientSubscriptionConfiguration = ( + | MessageStatusSubscriptionConfiguration + | ChannelStatusSubscriptionConfiguration +)[]; diff --git a/tools/client-subscriptions-management/tsconfig.json b/tools/client-subscriptions-management/tsconfig.json new file mode 100644 index 0000000..5b308bc --- /dev/null +++ b/tools/client-subscriptions-management/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "paths": { + "src/*": [ + "src/*" + ] + }, + "rootDir": "." + }, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42cdff9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "noEmit": true + }, + "extends": "./tsconfig.base.json", + "include": [ + "lambdas/*/src/**/*", + "scripts/*/src/**/*", + "tools/*/src/**/*", + "src/**/*", + "tests/**/*" + ] +} From fc5991e7f82673bd5cb457eb5c635ab1b7d5e24d Mon Sep 17 00:00:00 2001 From: rhyscoxnhs Date: Wed, 4 Mar 2026 15:06:49 +0000 Subject: [PATCH 02/16] CCM-14637 - Set up integration tests (#43) * CCM-14637 - Set up integration tests * CCM-14637 - Attempt to use AWS_ACCOUNT_ID env var instead of fetching it via STS --- .github/actions/acceptance-tests/action.yaml | 53 +++ .github/actions/node-install/action.yaml | 24 + .github/actions/test-types.json | 3 + .github/workflows/cicd-1-pull-request.yaml | 6 +- .github/workflows/stage-4-acceptance.yaml | 164 ++----- .../terraform/components/callbacks/README.md | 6 +- .../terraform/components/callbacks/outputs.tf | 30 +- package-lock.json | 418 ++++++++++++++---- tests/integration/helpers/aws-helpers.ts | 58 +++ tests/integration/helpers/index.ts | 1 + .../integration/infrastructure-exists.test.ts | 30 ++ tests/integration/jest.config.ts | 3 + tests/integration/package.json | 7 +- tests/integration/tsconfig.json | 19 +- 14 files changed, 585 insertions(+), 237 deletions(-) create mode 100644 .github/actions/acceptance-tests/action.yaml create mode 100644 .github/actions/node-install/action.yaml create mode 100644 .github/actions/test-types.json create mode 100644 tests/integration/helpers/aws-helpers.ts create mode 100644 tests/integration/infrastructure-exists.test.ts diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml new file mode 100644 index 0000000..9f0d603 --- /dev/null +++ b/.github/actions/acceptance-tests/action.yaml @@ -0,0 +1,53 @@ +name: Acceptance tests +description: "Run acceptance tests for this repo" + +inputs: + testType: + description: Type of test to run + required: true + + targetEnvironment: + description: Name of the environment under test + required: true + + targetAccountGroup: + description: Name of the account group under test + default: nhs-notify-client-callbacks-dev + required: true + + targetComponent: + description: Name of the component under test + required: true + +runs: + using: "composite" + + steps: + - name: Fetch terraform output + uses: actions/download-artifact@v4 + with: + name: terraform-output-${{ inputs.targetComponent }} + + - name: Get Node version + id: nodejs_version + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Repo setup" + uses: ./.github/actions/node-install + with: + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + + - name: "Set PR NUMBER environment variable" + shell: bash + run: | + echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + + - name: Run test - ${{ inputs.testType }} + shell: bash + env: + PROJECT: nhs + COMPONENT: ${{ inputs.targetComponent }} + run: | + make test-${{ inputs.testType }} diff --git a/.github/actions/node-install/action.yaml b/.github/actions/node-install/action.yaml new file mode 100644 index 0000000..b1ed2d0 --- /dev/null +++ b/.github/actions/node-install/action.yaml @@ -0,0 +1,24 @@ +name: 'npm install and setup' +description: 'Setup node, authenticate github package repository and perform clean npm install' + +inputs: + GITHUB_TOKEN: + description: "Token for access to github package registry" + required: true + +runs: + using: 'composite' + steps: + - name: 'Use Node.js' + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + registry-url: 'https://npm.pkg.github.com' + scope: '@nhsdigital' + + - name: 'Install dependencies' + shell: bash + env: + NODE_AUTH_TOKEN: ${{ inputs.GITHUB_TOKEN }} + run: | + npm ci diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json new file mode 100644 index 0000000..4fe0a8a --- /dev/null +++ b/.github/actions/test-types.json @@ -0,0 +1,3 @@ +[ + "integration" +] diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index bb88afb..cd4d89e 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -174,9 +174,11 @@ jobs: --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" acceptance-stage: # Recommended maximum execution time is 10 minutes name: "Acceptance stage" - needs: [metadata, build-stage] + needs: [metadata, build-stage, pr-create-dynamic-environment] uses: ./.github/workflows/stage-4-acceptance.yaml - if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main') + if: >- + contains(fromJSON('["success", "skipped"]'), needs.pr-create-dynamic-environment.result) && + (needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main')) with: build_datetime: "${{ needs.metadata.outputs.build_datetime }}" build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}" diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index c6dc58e..4ae997e 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -36,139 +36,37 @@ on: required: true type: string +permissions: + id-token: write + contents: read + jobs: - environment-set-up: - name: "Environment set up" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Create infractructure" - run: | - echo "Creating infractructure..." - - name: "Update database" - run: | - echo "Updating database..." - - name: "Deploy application" - run: | - echo "Deploying application..." - test-contract: - name: "Contract test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run contract test" - run: | - make test-contract - - name: "Save result" - run: | - echo "Nothing to save" - test-security: - name: "Security test" + run-acceptance-tests: + name: Run Acceptance Tests runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run security test" - run: | - make test-security - - name: "Save result" - run: | - echo "Nothing to save" - test-ui: - name: "UI test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run UI test" - run: | - make test-ui - - name: "Save result" - run: | - echo "Nothing to save" - test-ui-performance: - name: "UI performance test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run UI performance test" - run: | - make test-ui-performance - - name: "Save result" - run: | - echo "Nothing to save" - test-integration: - name: "Integration test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run integration test" - run: | - make test-integration - - name: "Save result" - run: | - echo "Nothing to save" - test-accessibility: - name: "Accessibility test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run accessibility test" - run: | - make test-accessibility - - name: "Save result" - run: | - echo "Nothing to save" - test-load: - name: "Load test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run load tests" - run: | - make test-load - - name: "Save result" - run: | - echo "Nothing to save" - environment-tear-down: - name: "Environment tear down" - runs-on: ubuntu-latest - needs: - [ - test-accessibility, - test-contract, - test-integration, - test-load, - test-security, - test-ui-performance, - test-ui, - ] - if: always() - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Tear down environment" - run: | - echo "Tearing down environment..." + - uses: actions/checkout@v4 + + - name: "Use Node.js" + uses: actions/setup-node@v4 + with: + node-version: "${{ inputs.nodejs_version }}" + registry-url: "https://npm.pkg.github.com" + scope: "@nhsdigital" + + - name: Trigger Acceptance Tests + shell: bash + env: + APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }} + APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + run: | + .github/scripts/dispatch_internal_repo_workflow.sh \ + --targetWorkflow "dispatch-contextual-tests-dynamic-env.yaml" \ + --infraRepoName "nhs-notify-client-callbacks" \ + --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ + --overrideProjectName "nhs" \ + --targetEnvironment "$TARGET_ENVIRONMENT" \ + --targetAccountGroup "nhs-notify-client-callbacks-dev" \ + --targetComponent "callbacks" diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 7524818..3eba40b 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -43,7 +43,11 @@ | [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-sqs.zip | n/a | ## Outputs -No outputs. +| Name | Description | +|------|-------------| +| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | +| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | +| [mock\_webhook\_url](#output\_mock\_webhook\_url) | URL endpoint for mock webhook (for TEST\_WEBHOOK\_URL environment variable) | diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf index 9dcc2f3..b042e36 100644 --- a/infrastructure/terraform/components/callbacks/outputs.tf +++ b/infrastructure/terraform/components/callbacks/outputs.tf @@ -1 +1,29 @@ -# Define the outputs for the component. The outputs may well be referenced by other component in the same or different environments using terraform_remote_state data sources... +## +# Deployment details +## + +output "deployment" { + description = "Deployment details used for post-deployment scripts" + value = { + aws_region = var.region + aws_account_id = var.aws_account_id + project = var.project + environment = var.environment + group = var.group + component = var.component + } +} + +## +# Mock Webhook Lambda Outputs (test/dev environments only) +## + +output "mock_webhook_lambda_log_group_name" { + description = "CloudWatch log group name for mock webhook lambda (for integration test queries)" + value = var.deploy_mock_webhook ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null +} + +output "mock_webhook_url" { + description = "URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable)" + value = var.deploy_mock_webhook ? aws_lambda_function_url.mock_webhook[0].function_url : null +} diff --git a/package-lock.json b/package-lock.json index 180d025..302f4dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -366,6 +366,73 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-eventbridge": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.1001.0.tgz", + "integrity": "sha512-asySfaKnDTxhMtxCX1dvjDPfJwrQ5xy/tzdmFHmRyURNhIhXG3dwishJ6ROXzOrY7hFCiz+OTWWjZ+IJbrfzkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-node": "^3.972.15", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.16", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.4", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.1", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-eventbridge/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.996.0", "license": "Apache-2.0", @@ -510,18 +577,86 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1001.0.tgz", + "integrity": "sha512-1HVxJcad+BTMVQ4lN2jw4SzyVqnIRZ7mb8YjwqMQ6p1MjuklSriVUXKtYFyxLVJnqaw61nFv9F8oHMOK69p6BQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-node": "^3.972.15", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.16", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.1", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.973.15", + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.16.tgz", + "integrity": "sha512-Nasoyb5K4jfvncTKQyA13q55xHoz9as01NVYP05B0Kzux/X5UhMn3qXsZDyWOSXkfSCAIrMBKmVVWbI0vUapdQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.8", - "@smithy/core": "^3.23.6", + "@aws-sdk/xml-builder": "^3.972.9", + "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", @@ -544,10 +679,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.13", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.14.tgz", + "integrity": "sha512-PvnBY9rwBuLh9MEsAng28DG+WKl+txerKgf4BU9IPAqYI7FBIo1x6q/utLf4KLyQYgSy1TLQnbQuXx5xfBGASg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", @@ -558,18 +695,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.15", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.16.tgz", + "integrity": "sha512-m/QAcvw5OahqGPjeAnKtgfWgjLxeWOYj7JSmxKK6PLyKp2S/t2TAHI6EELEzXnIz28RMgbQLukJkVAqPASVAGQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { @@ -577,17 +716,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.13", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-login": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", - "@aws-sdk/nested-clients": "^3.996.3", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.14.tgz", + "integrity": "sha512-EGA7ufqNpZKZcD0RwM6gRDEQgwAf19wQ99R1ptdWYDJAnpcMcWiFyT0RIrgiZFLD28CwJmYjnra75hChnEveWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-env": "^3.972.14", + "@aws-sdk/credential-provider-http": "^3.972.16", + "@aws-sdk/credential-provider-login": "^3.972.14", + "@aws-sdk/credential-provider-process": "^3.972.14", + "@aws-sdk/credential-provider-sso": "^3.972.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.14", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", @@ -600,11 +741,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.13", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.14.tgz", + "integrity": "sha512-P2kujQHAoV7irCTv6EGyReKFofkHCjIK+F0ZYf5UxeLeecrCwtrDkHoO2Vjsv/eRUumaKblD8czuk3CLlzwGDw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", @@ -617,15 +760,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.14", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.15.tgz", + "integrity": "sha512-59NBJgTcQ2FC94T+SWkN5UQgViFtrLnkswSKhG5xbjPAotOXnkEF2Bf0bfUV1F3VaXzqAPZJoZ3bpg4rr8XD5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-ini": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", + "@aws-sdk/credential-provider-env": "^3.972.14", + "@aws-sdk/credential-provider-http": "^3.972.16", + "@aws-sdk/credential-provider-ini": "^3.972.14", + "@aws-sdk/credential-provider-process": "^3.972.14", + "@aws-sdk/credential-provider-sso": "^3.972.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.14", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", @@ -638,10 +783,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.13", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.14.tgz", + "integrity": "sha512-KAF5LBkJInUPaR9dJDw8LqmbPDRTLyXyRoWVGcJQ+DcN9rxVKBRzAK+O4dTIvQtQ7xaIDZ2kY7zUmDlz6CCXdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -653,12 +800,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.13", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.14.tgz", + "integrity": "sha512-LQzIYrNABnZzkyuIguFa3VVOox9UxPpRW6PL+QYtRHaGl1Ux/+Zi54tAVK31VdeBKPKU3cxqeu8dbOgNqy+naw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/token-providers": "3.999.0", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", + "@aws-sdk/token-providers": "3.1001.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -670,11 +819,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.13", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.14.tgz", + "integrity": "sha512-rOwB3vXHHHnGvAOjTgQETxVAsWjgF61XlbGd/ulvYo7EpdXs8cbIHE3PGih9tTj/65ZOegSqZGFqLaKntaI9Kw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -789,22 +940,24 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.12", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.16.tgz", + "integrity": "sha512-U4K1rqyJYvT/zgTI3+rN+MToa51dFnnq1VSsVJuJWPNEKcEnuZVqf7yTpkJJMkYixVW5TTi1dgupd+nmJ0JyWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.12", - "@aws-sdk/types": "^3.973.1", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.2", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.23.7", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-stream": "^4.5.16", + "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { @@ -840,13 +993,15 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.15", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.16.tgz", + "integrity": "sha512-AmVxtxn8ZkNJbuPu3KKfW9IkJgTgcEtgSwbo0NVcAb31iGvLgHXj2nbbyrUDfh2fx8otXmqL+qw1lRaTi+V3vA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.6", + "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" @@ -870,42 +1025,44 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.3", + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.4.tgz", + "integrity": "sha512-NowB1HfOnWC4kwZOnTg8E8rSL0U+RSjSa++UtEV4ipoH6JOjMLnHyGilqwl+Pe1f0Al6v9yMkSJ/8Ot0f578CQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", + "@aws-sdk/util-user-agent-node": "^3.973.1", "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -918,6 +1075,8 @@ }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -944,12 +1103,31 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.4.tgz", + "integrity": "sha512-MGa8ro0onekYIiesHX60LwKdkxK3Kd61p7TTbLwZemBqlnD9OLrk9sXZdFOIxXanJ+3AaJnV/jiX866eD/4PDg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.16", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { - "version": "3.999.0", + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1001.0.tgz", + "integrity": "sha512-09XAq/uIYgeZhohuGRrR/R+ek3+ljFNdzWCXdqb9rlIERDjSfNiLjTtpHgSK1xTPmC5G4yWoEAyMfTXiggS6wA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -1017,10 +1195,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.0", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.1.tgz", + "integrity": "sha512-kmgbDqT7aCBEVrqESM2JUjbf0zhDUQ7wnt3q1RuVS+3mglrcfVb2bwkbmf38npOyyPGtQPV5dWN3m+sSFAVAgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", @@ -1039,11 +1219,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.8", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.3.6", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { @@ -2341,7 +2523,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.6", + "version": "3.23.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.7.tgz", + "integrity": "sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.11", @@ -2350,7 +2534,7 @@ "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" @@ -2434,7 +2618,9 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.11", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.12.tgz", + "integrity": "sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2531,10 +2717,12 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.20", + "version": "4.4.21", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.21.tgz", + "integrity": "sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", + "@smithy/core": "^3.23.7", "@smithy/middleware-serde": "^4.2.11", "@smithy/node-config-provider": "^4.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -2548,13 +2736,15 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.37", + "version": "4.4.38", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.38.tgz", + "integrity": "sha512-WdHvdhjE6Fj78vxFwDKFDwlqGOGRUWrwGeuENUbTVE46Su9mnQM+dXHtbnCaQvwuSYrRsjpe8zUsFpwUp/azlA==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/service-error-classification": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -2602,7 +2792,9 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.12", + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.13.tgz", + "integrity": "sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==", "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.10", @@ -2699,15 +2891,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.0", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.1.tgz", + "integrity": "sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-endpoint": "^4.4.20", + "@smithy/core": "^3.23.7", + "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-stack": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { @@ -2790,11 +2984,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.36", + "version": "4.3.37", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.37.tgz", + "integrity": "sha512-JlPZhV1kQCGNJgofRTU6E8kHrjCKsb6cps8gco8QDVaFl7biFYzHg0p1x89ytIWyVyCkY3nOpO8tJPM47Vqlww==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2803,14 +2999,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.39", + "version": "4.2.40", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.40.tgz", + "integrity": "sha512-BM5cPEsyxHdYYO4Da77E94lenhaVPNUzBTyCGDkcw/n/mE8Q1cfHwr+n/w2bNPuUsPC30WaW5/hGKWOTKqw8kw==", "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.9", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2864,11 +3062,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.15", + "version": "4.5.16", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.16.tgz", + "integrity": "sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-buffer-from": "^4.2.1", @@ -5856,8 +6056,22 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", @@ -5866,6 +6080,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { @@ -9407,6 +9622,8 @@ }, "node_modules/strnum": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -10435,7 +10652,12 @@ "name": "nhs-notify-client-callbacks-integration-tests", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.991.0" + "@aws-sdk/client-cloudwatch-logs": "^3.991.0", + "@aws-sdk/client-eventbridge": "^3.990.0", + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sqs": "^3.990.0", + "@aws-sdk/client-sts": "^3.821.0", + "async-wait-until": "^2.0.12" }, "devDependencies": { "@aws-sdk/client-sqs": "^3.990.0", diff --git a/tests/integration/helpers/aws-helpers.ts b/tests/integration/helpers/aws-helpers.ts new file mode 100644 index 0000000..75d8f9c --- /dev/null +++ b/tests/integration/helpers/aws-helpers.ts @@ -0,0 +1,58 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +export type DeploymentDetails = { + region: string; + environment: string; + project: string; + component: string; + accountId: string; +}; + +/** + * Reads deployment context from environment variables + * + * Requires: AWS_REGION, PR_NUMBER, PROJECT, COMPONENT, AWS_ACCOUNT_ID + */ +export function getDeploymentDetails(): DeploymentDetails { + const region = process.env.AWS_REGION ?? "eu-west-2"; + const environment = process.env.PR_NUMBER; + const project = process.env.PROJECT; + const component = process.env.COMPONENT; + const accountId = process.env.AWS_ACCOUNT_ID; + + if (!environment) { + throw new Error("PR_NUMBER environment variable must be set"); + } + if (!project) { + throw new Error("PROJECT environment variable must be set"); + } + if (!component) { + throw new Error("COMPONENT environment variable must be set"); + } + if (!accountId) { + throw new Error("AWS_ACCOUNT_ID environment variable must be set"); + } + + return { region, environment, project, component, accountId }; +} + +/** + * Builds the subscription config S3 bucket name from deployment details. + */ +export function buildSubscriptionConfigBucketName({ + accountId, + component, + environment, + project, + region, +}: DeploymentDetails): string { + return `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; +} + +/** + * Creates an S3 client configured for the given region. + */ +export function createS3Client(): S3Client { + const region = process.env.AWS_REGION ?? "eu-west-2"; + return new S3Client({ region }); +} diff --git a/tests/integration/helpers/index.ts b/tests/integration/helpers/index.ts index b0718c3..e2b912e 100644 --- a/tests/integration/helpers/index.ts +++ b/tests/integration/helpers/index.ts @@ -1 +1,2 @@ +export * from "./aws-helpers"; export * from "./cloudwatch-helpers"; diff --git a/tests/integration/infrastructure-exists.test.ts b/tests/integration/infrastructure-exists.test.ts new file mode 100644 index 0000000..62a8186 --- /dev/null +++ b/tests/integration/infrastructure-exists.test.ts @@ -0,0 +1,30 @@ +import { HeadBucketCommand } from "@aws-sdk/client-s3"; +import type { S3Client } from "@aws-sdk/client-s3"; +import { + buildSubscriptionConfigBucketName, + createS3Client, + getDeploymentDetails, +} from "helpers"; + +describe("Infrastructure exists", () => { + let s3Client: S3Client; + let bucketName: string; + + beforeAll(async () => { + const deploymentDetails = getDeploymentDetails(); + bucketName = buildSubscriptionConfigBucketName(deploymentDetails); + s3Client = createS3Client(); + }); + + afterAll(() => { + s3Client?.destroy(); + }); + + it("should confirm the subscription config S3 bucket exists", async () => { + const response = await s3Client.send( + new HeadBucketCommand({ Bucket: bucketName }), + ); + + expect(response.$metadata.httpStatusCode).toBe(200); + }); +}); diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts index 065243c..e52fedf 100644 --- a/tests/integration/jest.config.ts +++ b/tests/integration/jest.config.ts @@ -7,4 +7,7 @@ export default { ...(nodeJestConfig.coveragePathIgnorePatterns ?? []), "/helpers/", ], + moduleNameMapper: { + "^helpers$": "/helpers/index", + }, }; diff --git a/tests/integration/package.json b/tests/integration/package.json index c9e2212..def62d6 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -10,7 +10,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.991.0" + "@aws-sdk/client-cloudwatch-logs": "^3.991.0", + "@aws-sdk/client-eventbridge": "^3.990.0", + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.821.0", + "@aws-sdk/client-sqs": "^3.990.0", + "async-wait-until": "^2.0.12" }, "devDependencies": { "@aws-sdk/client-sqs": "^3.990.0", diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json index c0ab68d..00cb81a 100644 --- a/tests/integration/tsconfig.json +++ b/tests/integration/tsconfig.json @@ -1,7 +1,24 @@ { "compilerOptions": { "baseUrl": ".", - "isolatedModules": true + "isolatedModules": true, + "paths": { + "helpers": [ + "./helpers/index" + ], + "models/*": [ + "../../lambdas/client-transform-filter-lambda/src/models/*" + ], + "services/*": [ + "../../lambdas/client-transform-filter-lambda/src/services/*" + ], + "transformers/*": [ + "../../lambdas/client-transform-filter-lambda/src/transformers/*" + ], + "validators/*": [ + "../../lambdas/client-transform-filter-lambda/src/validators/*" + ] + } }, "extends": "../../tsconfig.base.json", "include": [ From b936966a9cb0b2834283f1f9556fe10abc961b68 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 5 Mar 2026 09:30:36 +0000 Subject: [PATCH 03/16] CCM-14201 - PR feedback --- .../__tests__/index.cache-ttl-invalid.test.ts | 2 +- .../__tests__/index.cache-ttl-valid.test.ts | 2 +- .../src/__tests__/index.config-prefix.test.ts | 4 +- .../src/__tests__/index.integration.test.ts | 22 ++--- .../src/__tests__/index.reset-loader.test.ts | 64 -------------- .../src/__tests__/index.s3-config.test.ts | 2 +- .../src/__tests__/index.test.ts | 74 +++++++++++++++- .../services/config-loader-service.test.ts | 76 ++++++++++++++++ .../src/__tests__/services/metrics.test.ts | 12 +++ .../src/handler.ts | 19 ++-- .../src/index.ts | 86 +++---------------- .../src/services/config-loader-service.ts | 77 +++++++++++++++++ .../src/services/logger.ts | 1 + .../src/services/metrics.ts | 4 + .../src/services/observability.ts | 5 ++ .../infra/client-subscription-repository.ts | 4 +- 16 files changed, 290 insertions(+), 164 deletions(-) delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts index 3e36e3e..0c495c8 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts @@ -1,4 +1,4 @@ -import { resolveCacheTtlMs } from ".."; +import { resolveCacheTtlMs } from "services/config-loader-service"; describe("cache ttl configuration", () => { it("falls back to default TTL when invalid", () => { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts index 13aa374..a60973f 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts @@ -1,4 +1,4 @@ -import { resolveCacheTtlMs } from ".."; +import { resolveCacheTtlMs } from "services/config-loader-service"; describe("cache ttl configuration", () => { it("uses the configured TTL when valid", () => { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts index 40c1637..06a9ea4 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -24,7 +24,7 @@ jest.mock("aws-embedded-metrics", () => ({ import type { SQSRecord } from "aws-lambda"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { handler, resetConfigLoader } from ".."; +import { configLoaderService, handler } from ".."; const makeSqsRecord = (body: object): SQSRecord => ({ messageId: "sqs-id", @@ -73,7 +73,7 @@ describe("config prefix resolution", () => { beforeEach(() => { mockLoadClientConfig.mockClear(); mockConfigLoader.mockClear(); - resetConfigLoader(); // force lazy re-creation of ConfigLoader on next call + configLoaderService.reset(); // force lazy re-creation of ConfigLoader on next call process.env.METRICS_NAMESPACE = "test-namespace"; process.env.ENVIRONMENT = "test"; }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts index d0c4bca..c9ebf23 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -34,7 +34,8 @@ jest.mock("aws-embedded-metrics", () => ({ import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; import type { SQSRecord } from "aws-lambda"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { createS3Client, handler, resetConfigLoader } from ".."; +import { createS3Client } from "services/config-loader-service"; +import { configLoaderService, handler } from ".."; const makeSqsRecord = (body: object): SQSRecord => ({ messageId: "sqs-id", @@ -124,13 +125,13 @@ describe("Lambda handler with S3 subscription filtering", () => { beforeEach(() => { mockSend.mockClear(); // Reset loader and clear cache for clean state between tests - resetConfigLoader( + configLoaderService.reset( createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }), ); }); afterAll(() => { - resetConfigLoader(); + configLoaderService.reset(); delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS; @@ -196,17 +197,16 @@ describe("Lambda handler with S3 subscription filtering", () => { ); }); - it("passes all events through when no config bucket is configured", async () => { - resetConfigLoader(); // clear loader – no bucket → filtering disabled + it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", async () => { + configLoaderService.reset(); const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - const result = await handler([ - makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), - ]); - - expect(result).toHaveLength(1); - expect(mockSend).not.toHaveBeenCalled(); + await expect( + handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + ]), + ).rejects.toThrow("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket ?? "test-bucket"; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts deleted file mode 100644 index c47cdf2..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { createS3Client, resetConfigLoader } from ".."; - -describe("resetConfigLoader", () => { - const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - - beforeEach(() => { - // Ensure bucket is set for tests that need it - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; - }); - - afterEach(() => { - // Clean up after each test - resetConfigLoader(); - - // Restore original env - if (originalBucket === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - } else { - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; - } - }); - - it("resets the cached loader to undefined when called with no arguments", () => { - resetConfigLoader(); - - // The loader should be reset (we can't directly test this without exposing internal state, - // but we can test that calling it again with a custom client works) - expect(() => resetConfigLoader()).not.toThrow(); - }); - - it("creates a new loader with custom S3Client when provided", () => { - const customClient = createS3Client({ - AWS_ENDPOINT_URL: "http://localhost:4566", - }); - - // Should not throw and should create the loader - resetConfigLoader(customClient); - - // Calling resetConfigLoader again with undefined should clear it - expect(() => resetConfigLoader()).not.toThrow(); - }); - - it("creates a new loader with custom keyPrefix when environment variable is set", () => { - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; - const customClient = createS3Client({ - AWS_ENDPOINT_URL: "http://localhost:4566", - }); - - // Should not throw and should create the loader - expect(() => resetConfigLoader(customClient)).not.toThrow(); - - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - }); - - it("throws error when S3Client provided but bucket name is missing", () => { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - - const customClient = createS3Client(); - - expect(() => resetConfigLoader(customClient)).toThrow( - "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", - ); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts index 2c0d207..ae483b7 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts @@ -1,4 +1,4 @@ -import { createS3Client } from ".."; +import { createS3Client } from "services/config-loader-service"; describe("createS3Client", () => { it("sets forcePathStyle=true when endpoint contains localhost", () => { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 80e673e..0a76b20 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -9,9 +9,71 @@ import type { } from "@nhs-notify-client-callbacks/models"; import type { Logger } from "services/logger"; import type { CallbackMetrics } from "services/metrics"; +import type { ConfigLoader } from "services/config-loader"; import { ObservabilityService } from "services/observability"; +import { ConfigLoaderService } from "services/config-loader-service"; import { createHandler } from ".."; +const createPassthroughConfigLoader = (): ConfigLoader => + ({ + loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ + { + SubscriptionType: "MessageStatus", + Name: "unit-test-message", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + Statuses: [ + "DELIVERED", + "FAILED", + "PENDING", + "SENDING", + "TECHNICAL_FAILURE", + "PERMANENT_FAILURE", + ], + }, + { + SubscriptionType: "ChannelStatus", + Name: "unit-test-nhsapp", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + ChannelType: "NHSAPP", + ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + SupplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + { + SubscriptionType: "ChannelStatus", + Name: "unit-test-sms", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + SupplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + ]), + }) as unknown as ConfigLoader; + +const makeStubConfigLoaderService = (): ConfigLoaderService => { + const loader = createPassthroughConfigLoader(); + return { getLoader: () => loader } as unknown as ConfigLoaderService; +}; + describe("Lambda handler", () => { const mockLogger = { info: jest.fn(), @@ -29,6 +91,7 @@ describe("Lambda handler", () => { emitTransformationFailure: jest.fn(), emitDeliveryInitiated: jest.fn(), emitValidationError: jest.fn(), + emitFilteringStarted: jest.fn(), } as unknown as CallbackMetrics; const mockMetricsLogger = { @@ -38,6 +101,7 @@ describe("Lambda handler", () => { const handler = createHandler({ createObservabilityService: () => new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: makeStubConfigLoaderService, }); beforeEach(() => { @@ -290,6 +354,7 @@ describe("Lambda handler", () => { const faultyHandler = createHandler({ createObservabilityService: () => faultyObservability, + createConfigLoaderService: makeStubConfigLoaderService, }); const sqsMessage: SQSRecord = { @@ -446,7 +511,14 @@ describe("createHandler default wiring", () => { }); expect(state.testHandler).toBeDefined(); + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; const result = await state.testHandler!([]); + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } expect(state.createMetricLogger).toHaveBeenCalledTimes(1); expect(state.CallbackMetrics).toHaveBeenCalledWith(state.mockMetricsLogger); @@ -459,7 +531,7 @@ describe("createHandler default wiring", () => { expect(state.processEvents).toHaveBeenCalledWith( [], state.mockObservabilityInstance, - undefined, + expect.any(Object), ); expect(result).toEqual(["ok"]); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts new file mode 100644 index 0000000..753a1e8 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts @@ -0,0 +1,76 @@ +import { + ConfigLoaderService, + createS3Client, +} from "services/config-loader-service"; + +describe("ConfigLoaderService", () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + beforeEach(() => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + }); + + afterEach(() => { + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + }); + + describe("getLoader", () => { + it("returns the same loader instance on subsequent calls (lazy singleton)", () => { + const service = new ConfigLoaderService(); + const first = service.getLoader(); + const second = service.getLoader(); + expect(first).toBe(second); + }); + + it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const service = new ConfigLoaderService(); + expect(() => service.getLoader()).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); + }); + + describe("reset", () => { + it("clears the cached loader so a new one is created on next getLoader call", () => { + const service = new ConfigLoaderService(); + const before = service.getLoader(); + service.reset(); + const after = service.getLoader(); + expect(after).not.toBe(before); + }); + + it("initialises a new loader with a custom S3Client when provided", () => { + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + const service = new ConfigLoaderService(); + service.reset(customClient); + // Should not throw and the loader should be available immediately + expect(() => service.getLoader()).not.toThrow(); + }); + + it("uses the configured key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + const service = new ConfigLoaderService(); + expect(() => service.reset(customClient)).not.toThrow(); + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + }); + + it("throws when S3Client is provided but CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const customClient = createS3Client(); + const service = new ConfigLoaderService(); + expect(() => service.reset(customClient)).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts index b0e4578..66d43b8 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts @@ -131,4 +131,16 @@ describe("CallbackMetrics", () => { ); }); }); + + describe("emitFilteringStarted", () => { + it("should emit FilteringStarted metric", () => { + callbackMetrics.emitFilteringStarted(); + + expect(mockPutMetric).toHaveBeenCalledWith( + "FilteringStarted", + 1, + Unit.Count, + ); + }); + }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 5e5b919..71ee812 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -132,9 +132,11 @@ async function filterBatch( observability: ObservabilityService, stats: BatchStats, ): Promise { - const uniqueClientIds = [ - ...new Set(transformedEvents.map((e) => e.data.clientId)), - ]; + observability.recordFilteringStarted({ batchSize: transformedEvents.length }); + + const uniqueClientIds = new Set( + transformedEvents.map((e) => e.data.clientId), + ); const configEntries = await pMap( uniqueClientIds, @@ -200,7 +202,7 @@ async function transformBatch( export async function processEvents( event: SQSRecord[], observability: ObservabilityService, - configLoader?: ConfigLoader, + configLoader: ConfigLoader, ): Promise { const startTime = Date.now(); const stats = new BatchStats(); @@ -208,9 +210,12 @@ export async function processEvents( try { const transformedEvents = await transformBatch(event, observability, stats); - const filteredEvents = configLoader - ? await filterBatch(transformedEvents, configLoader, observability, stats) - : transformedEvents; + const filteredEvents = await filterBatch( + transformedEvents, + configLoader, + observability, + stats, + ); const processingTime = Date.now() - startTime; observability.logBatchProcessingCompleted({ diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts index 60bd978..5ef8e19 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -1,84 +1,15 @@ -import { S3Client } from "@aws-sdk/client-s3"; import type { SQSRecord } from "aws-lambda"; import { Logger } from "services/logger"; import { CallbackMetrics, createMetricLogger } from "services/metrics"; import { ObservabilityService } from "services/observability"; -import { ConfigCache } from "services/config-cache"; -import { ConfigLoader } from "services/config-loader"; +import { ConfigLoaderService } from "services/config-loader-service"; import { type TransformedEvent, processEvents } from "handler"; -const DEFAULT_CACHE_TTL_SECONDS = 60; - -export const resolveCacheTtlMs = ( - env: NodeJS.ProcessEnv = process.env, -): number => { - const configuredTtlSeconds = Number.parseInt( - env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`, - 10, - ); - const cacheTtlSeconds = Number.isFinite(configuredTtlSeconds) - ? configuredTtlSeconds - : DEFAULT_CACHE_TTL_SECONDS; - return cacheTtlSeconds * 1000; -}; - -const configCache = new ConfigCache(resolveCacheTtlMs()); - -let cachedLoader: ConfigLoader | undefined; - -export const createS3Client = ( - env: NodeJS.ProcessEnv = process.env, -): S3Client => { - const endpoint = env.AWS_ENDPOINT_URL; - const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; - return new S3Client({ endpoint, forcePathStyle }); -}; - -const getConfigLoader = (): ConfigLoader | undefined => { - const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - if (!bucketName) { - // Config bucket not configured - subscription filtering disabled - return undefined; - } - - if (cachedLoader) { - return cachedLoader; - } - - cachedLoader = new ConfigLoader({ - bucketName, - keyPrefix: - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? "client_subscriptions/", - s3Client: createS3Client(), - cache: configCache, - }); - - return cachedLoader; -}; - -// Exported for testing - resets the cached loader (and clears the config cache) to allow -// clean state between tests, with optional custom S3Client injection -export const resetConfigLoader = (s3Client?: S3Client): void => { - cachedLoader = undefined; - configCache.clear(); - if (s3Client) { - const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - if (!bucketName) { - throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); - } - cachedLoader = new ConfigLoader({ - bucketName, - keyPrefix: - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? - "client_subscriptions/", - s3Client, - cache: configCache, - }); - } -}; +export const configLoaderService = new ConfigLoaderService(); export interface HandlerDependencies { - createObservabilityService: () => ObservabilityService; + createObservabilityService?: () => ObservabilityService; + createConfigLoaderService?: () => ConfigLoaderService; } function createDefaultObservabilityService(): ObservabilityService { @@ -89,16 +20,23 @@ function createDefaultObservabilityService(): ObservabilityService { return new ObservabilityService(logger, metrics, metricsLogger); } +function createDefaultConfigLoaderService(): ConfigLoaderService { + return configLoaderService; +} + export function createHandler( dependencies: Partial = {}, ): (event: SQSRecord[]) => Promise { const createObservabilityService = dependencies.createObservabilityService ?? createDefaultObservabilityService; + const configLoader = ( + dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService + )(); return async (event: SQSRecord[]): Promise => { const observability = createObservabilityService(); - return processEvents(event, observability, getConfigLoader()); + return processEvents(event, observability, configLoader.getLoader()); }; } diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts new file mode 100644 index 0000000..b0af71b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts @@ -0,0 +1,77 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; + +const DEFAULT_CACHE_TTL_SECONDS = 60; + +export const resolveCacheTtlMs = ( + env: NodeJS.ProcessEnv = process.env, +): number => { + const configuredTtlSeconds = Number.parseInt( + env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`, + 10, + ); + const cacheTtlSeconds = Number.isFinite(configuredTtlSeconds) + ? configuredTtlSeconds + : DEFAULT_CACHE_TTL_SECONDS; + return cacheTtlSeconds * 1000; +}; + +export const createS3Client = ( + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + return new S3Client({ endpoint, forcePathStyle }); +}; + +export class ConfigLoaderService { + private readonly cache: ConfigCache; + + private loader: ConfigLoader | undefined; + + constructor(cacheTtlMs: number = resolveCacheTtlMs()) { + this.cache = new ConfigCache(cacheTtlMs); + } + + getLoader(): ConfigLoader { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + + if (this.loader) { + return this.loader; + } + + this.loader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client: createS3Client(), + cache: this.cache, + }); + + return this.loader; + } + + reset(s3Client?: S3Client): void { + this.loader = undefined; + this.cache.clear(); + if (s3Client) { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + this.loader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client, + cache: this.cache, + }); + } + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/logger.ts b/lambdas/client-transform-filter-lambda/src/services/logger.ts index 84b7be3..e5bad4b 100644 --- a/lambdas/client-transform-filter-lambda/src/services/logger.ts +++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts @@ -79,6 +79,7 @@ export function logLifecycleEvent( | "processing-started" | "transformation-started" | "transformation-completed" + | "filtering-started" | "delivery-initiated" | "batch-processing-completed", context: LogContext, diff --git a/lambdas/client-transform-filter-lambda/src/services/metrics.ts b/lambdas/client-transform-filter-lambda/src/services/metrics.ts index f77b487..6c808cd 100644 --- a/lambdas/client-transform-filter-lambda/src/services/metrics.ts +++ b/lambdas/client-transform-filter-lambda/src/services/metrics.ts @@ -39,4 +39,8 @@ export class CallbackMetrics { emitValidationError(): void { this.metrics.putMetric("ValidationErrors", 1, Unit.Count); } + + emitFilteringStarted(): void { + this.metrics.putMetric("FilteringStarted", 1, Unit.Count); + } } diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index bfbe279..8a52f52 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -39,6 +39,11 @@ export class ObservabilityService { logLifecycleEvent(this.logger, "transformation-started", context); } + recordFilteringStarted(context: { batchSize: number }): void { + logLifecycleEvent(this.logger, "filtering-started", context); + this.metrics.emitFilteringStarted(); + } + logBatchProcessingCompleted(context: { successful: number; failed: number; diff --git a/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts b/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts index 5611fb9..2f6a3ad 100644 --- a/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts +++ b/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts @@ -103,10 +103,10 @@ const channelStatusSubscriptionArgsSchema = { } as const; const ajv = new Ajv({ useDefaults: true }); -const validateMessageStatusArgs = ajv.compile( +const validateMessageStatusArgs = ajv.compile( messageStatusSubscriptionArgsSchema, ); -const validateChannelStatusArgs = ajv.compile( +const validateChannelStatusArgs = ajv.compile( channelStatusSubscriptionArgsSchema, ); From 6139500c9d6328e58d27b77759a41f19401a431b Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 5 Mar 2026 14:58:53 +0000 Subject: [PATCH 04/16] CCM-14201 - PR feedback --- eslint.config.mjs | 14 + .../src/__tests__/index.config-prefix.test.ts | 1 - .../src/__tests__/index.integration.test.ts | 27 +- .../src/__tests__/index.test.ts | 1 + .../__tests__/services/config-loader.test.ts | 82 +- .../config-update.integration.test.ts | 145 +- .../__tests__/services/error-handler.test.ts | 67 + .../filters/subscription-filter.test.ts | 1186 ++++++++++++++++ .../src/__tests__/services/metrics.test.ts | 12 + .../services/subscription-filter.test.ts | 1249 +++-------------- .../services/transform-pipeline.test.ts | 265 ---- .../validators/event-validator.test.ts | 4 +- .../src/handler.ts | 7 +- .../src/services/config-loader.ts | 52 +- .../src/services/error-handler.ts | 39 + .../services/filters/channel-status-filter.ts | 2 +- .../src/services/filters/event-pattern.ts | 26 +- .../services/filters/message-status-filter.ts | 16 +- .../src/services/metrics.ts | 4 + .../src/services/observability.ts | 9 + ...orm-pipeline.ts => subscription-filter.ts} | 3 +- .../services/validators/config-validator.ts | 35 +- .../services/validators/event-validator.ts | 15 +- package-lock.json | 39 +- package.json | 5 +- tests/integration/tsconfig.json | 12 - .../client-subscriptions-management/README.md | 6 + .../package.json | 7 +- .../client-subscription-builder.test.ts | 1 - ...y.test.ts => client-subscriptions.test.ts} | 19 +- .../src/__tests__/constants.test.ts | 2 +- .../src/__tests__/container.test.ts | 5 +- .../get-client-subscriptions.test.ts | 1 - .../src/__tests__/helper.test.ts | 23 +- .../src/__tests__/put-channel-status.test.ts | 7 +- .../src/__tests__/put-message-status.test.ts | 7 +- .../src/__tests__/s3-repository.test.ts | 128 -- .../src/__tests__/s3.test.ts | 68 + .../src/constants.ts | 18 - .../src/container.ts | 4 +- .../src/domain/client-subscription-builder.ts | 4 +- .../cli/get-client-subscriptions.ts | 3 - .../src/entrypoint/cli/helper.ts | 15 +- .../src/entrypoint/cli/put-channel-status.ts | 12 +- .../src/entrypoint/cli/put-message-status.ts | 11 +- .../client-subscriptions.ts} | 129 +- .../s3-repository.ts => repository/s3.ts} | 28 +- .../src/types.ts | 51 - 48 files changed, 1950 insertions(+), 1916 deletions(-) create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts rename lambdas/client-transform-filter-lambda/src/services/{transform-pipeline.ts => subscription-filter.ts} (91%) rename tools/client-subscriptions-management/src/__tests__/{client-subscription-repository.test.ts => client-subscriptions.test.ts} (96%) delete mode 100644 tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/s3.test.ts delete mode 100644 tools/client-subscriptions-management/src/constants.ts rename tools/client-subscriptions-management/src/{infra/client-subscription-repository.ts => repository/client-subscriptions.ts} (59%) rename tools/client-subscriptions-management/src/{infra/s3-repository.ts => repository/s3.ts} (55%) delete mode 100644 tools/client-subscriptions-management/src/types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d3ce40..22c3292 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -239,6 +239,20 @@ export default defineConfig([ ], }, }, + { + files: ["tools/client-subscriptions-management/**/*.ts"], + rules: { + "no-console": "off", + "import-x/first": "off", + }, + }, + { + files: ["lambdas/client-transform-filter-lambda/**/*.ts"], + rules: { + "no-console": "off", + "import-x/first": "off", + }, + }, // misc rule overrides { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts index 06a9ea4..bb11ccb 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/first */ // eslint-disable-next-line unicorn/no-useless-undefined const mockLoadClientConfig = jest.fn().mockResolvedValue(undefined); const mockConfigLoader = jest.fn().mockImplementation(() => ({ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts index c9ebf23..7d123b5 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -3,9 +3,6 @@ * subscription filtering. Uses the real ConfigLoader + ConfigCache + filter pipeline * with a mocked S3Client. */ -/* eslint-disable import-x/first */ -import { Readable } from "node:stream"; - // Mock S3Client before importing the handler const mockSend = jest.fn(); jest.mock("@aws-sdk/client-s3", () => { @@ -141,7 +138,11 @@ describe("Lambda handler with S3 subscription filtering", () => { it("passes event through when client config matches subscription", async () => { mockSend.mockResolvedValue({ - Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, }); const result = await handler([ @@ -155,7 +156,11 @@ describe("Lambda handler with S3 subscription filtering", () => { it("filters out event when status is not in subscription", async () => { mockSend.mockResolvedValue({ - Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, }); const result = await handler([ @@ -182,7 +187,11 @@ describe("Lambda handler with S3 subscription filtering", () => { // Second call (client-1 CREATED) → no match // Both share the same client config (cached after first call) mockSend.mockResolvedValue({ - Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, }); const result = await handler([ @@ -219,7 +228,11 @@ describe("Lambda handler with S3 subscription filtering", () => { "", ).replace(".json", ""); return Promise.resolve({ - Body: Readable.from([JSON.stringify(createValidConfig(clientId))]), + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig(clientId))), + }, }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 0a76b20..dcb4599 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -92,6 +92,7 @@ describe("Lambda handler", () => { emitDeliveryInitiated: jest.fn(), emitValidationError: jest.fn(), emitFilteringStarted: jest.fn(), + emitFilteringMatched: jest.fn(), } as unknown as CallbackMetrics; const mockMetricsLogger = { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index adccbbc..584309d 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -1,9 +1,12 @@ import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; -import { Readable } from "node:stream"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; import { ConfigValidationError } from "services/validators/config-validator"; +const mockBody = (json: string) => ({ + transformToString: jest.fn().mockResolvedValue(json), +}); + const createValidConfig = (clientId: string) => [ { Name: `${clientId}-message`, @@ -51,7 +54,7 @@ const createLoader = (send: jest.Mock) => describe("ConfigLoader", () => { it("loads and validates client configuration from S3", async () => { const send = jest.fn().mockResolvedValue({ - Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + Body: mockBody(JSON.stringify(createValidConfig("client-1"))), }); const loader = createLoader(send); @@ -68,7 +71,7 @@ describe("ConfigLoader", () => { it("returns cached configuration on subsequent calls", async () => { const send = jest.fn().mockResolvedValue({ - Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + Body: mockBody(JSON.stringify(createValidConfig("client-1"))), }); const loader = createLoader(send); @@ -91,9 +94,7 @@ describe("ConfigLoader", () => { it("throws when configuration fails validation", async () => { const send = jest.fn().mockResolvedValue({ - Body: Readable.from([ - JSON.stringify([{ SubscriptionType: "MessageStatus" }]), - ]), + Body: mockBody(JSON.stringify([{ SubscriptionType: "MessageStatus" }])), }); const loader = createLoader(send); @@ -107,72 +108,29 @@ describe("ConfigLoader", () => { const loader = createLoader(send); await expect(loader.loadClientConfig("client-1")).rejects.toThrow( - "S3 response body was empty", - ); - }); - - it("handles string response body from S3", async () => { - const send = jest.fn().mockResolvedValue({ - Body: JSON.stringify(createValidConfig("client-1")), - }); - const loader = createLoader(send); - - const result = await loader.loadClientConfig("client-1"); - - expect(result).toEqual(createValidConfig("client-1")); - }); - - it("handles Uint8Array response body from S3", async () => { - const configString = JSON.stringify(createValidConfig("client-1")); - const uint8Array = new TextEncoder().encode(configString); - const send = jest.fn().mockResolvedValue({ - Body: uint8Array, - }); - const loader = createLoader(send); - - const result = await loader.loadClientConfig("client-1"); - - expect(result).toEqual(createValidConfig("client-1")); - }); - - it("handles readable stream with Buffer chunks", async () => { - const configString = JSON.stringify(createValidConfig("client-1")); - const send = jest.fn().mockResolvedValue({ - Body: Readable.from([Buffer.from(configString)]), - }); - const loader = createLoader(send); - - const result = await loader.loadClientConfig("client-1"); - - expect(result).toEqual(createValidConfig("client-1")); - }); - - it("throws when response body is not readable", async () => { - const send = jest.fn().mockResolvedValue({ - Body: 12_345, - }); - const loader = createLoader(send); - - await expect(loader.loadClientConfig("client-1")).rejects.toThrow( - "Response body is not readable", + ConfigValidationError, ); }); - it("rethrows non-NoSuchKey errors", async () => { + it("wraps S3 errors as ConfigValidationError", async () => { const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); const loader = createLoader(send); - await expect(loader.loadClientConfig("client-1")).rejects.toThrow( - "S3 access denied", - ); + const error = await loader.loadClientConfig("client-1").catch((e) => e); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error.issues).toEqual([ + { path: "config", message: "S3 access denied" }, + ]); }); - it("wraps non-Error values thrown by S3 in an Error", async () => { + it("wraps non-Error values thrown by S3 as ConfigValidationError", async () => { const send = jest.fn().mockRejectedValue("unexpected string error"); const loader = createLoader(send); - await expect(loader.loadClientConfig("client-1")).rejects.toBe( - "unexpected string error", - ); + const error = await loader.loadClientConfig("client-1").catch((e) => e); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error.issues).toEqual([ + { path: "config", message: "unexpected string error" }, + ]); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts index 7934b60..bffc5f8 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts @@ -1,5 +1,4 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { Readable } from "node:stream"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; @@ -11,82 +10,86 @@ describe("config update integration", () => { const send = jest .fn() .mockResolvedValueOnce({ - Body: Readable.from([ - JSON.stringify([ - { - Name: "client-message", - ClientId: "client-1", - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", + Body: { + transformToString: jest.fn().mockResolvedValue( + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", }, }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]), - ]), + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]), + ), + }, }) .mockResolvedValueOnce({ - Body: Readable.from([ - JSON.stringify([ - { - Name: "client-message", - ClientId: "client-1", - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", + Body: { + transformToString: jest.fn().mockResolvedValue( + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", }, }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["FAILED"], - }, - ]), - ]), + ], + SubscriptionType: "MessageStatus", + Statuses: ["FAILED"], + }, + ]), + ), + }, }); const loader = new ConfigLoader({ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts index 57ef1fe..669d91e 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts @@ -1,8 +1,10 @@ import { + ConfigValidationError, ErrorType, LambdaError, TransformationError, ValidationError, + formatValidationIssuePath, getEventError, wrapUnknownError, } from "services/error-handler"; @@ -127,6 +129,55 @@ describe("TransformationError", () => { }); }); +describe("formatValidationIssuePath", () => { + it("returns empty string for empty path", () => { + expect(formatValidationIssuePath([])).toBe(""); + }); + + it("returns string segment directly at root", () => { + expect(formatValidationIssuePath(["traceparent"])).toBe("traceparent"); + }); + + it("uses dot notation for nested string segments", () => { + expect(formatValidationIssuePath(["data", "clientId"])).toBe( + "data.clientId", + ); + }); + + it("uses bracket notation for numeric segments", () => { + expect(formatValidationIssuePath([0])).toBe("[0]"); + }); + + it("combines bracket and dot notation for mixed paths", () => { + expect(formatValidationIssuePath(["channels", 0, "type"])).toBe( + "channels[0].type", + ); + }); +}); + +describe("ConfigValidationError", () => { + it("should create error with issues array", () => { + const issues = [{ path: "[0].Name", message: "Expected Name to be unique" }]; + const error = new ConfigValidationError(issues); + + expect(error.message).toBe( + "Client subscription configuration validation failed", + ); + expect(error.issues).toBe(issues); + expect(error.errorType).toBe(ErrorType.VALIDATION_ERROR); + expect(error.retryable).toBe(false); + expect(error.correlationId).toBeUndefined(); + expect(error.name).toBe("ConfigValidationError"); + }); + + it("should be instanceof LambdaError and Error", () => { + const error = new ConfigValidationError([]); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error).toBeInstanceOf(LambdaError); + expect(error).toBeInstanceOf(Error); + }); +}); + describe("wrapUnknownError", () => { it("should return LambdaError as-is", () => { const originalError = new ValidationError("Original", "corr-123"); @@ -298,4 +349,20 @@ describe("getEventError", () => { expect(mockMetrics.emitTransformationFailure).toHaveBeenCalled(); expect(mockMetrics.emitValidationError).not.toHaveBeenCalled(); }); + + it("should return ConfigValidationError and emit validation metric", () => { + const error = new ConfigValidationError([ + { path: "[0].Name", message: "Expected Name to be unique" }, + ]); + + const result = getEventError(error, mockMetrics, mockEventLogger); + + expect(result).toBe(error); + expect(mockEventLogger.error).toHaveBeenCalledWith( + "Client config validation failed", + { error }, + ); + expect(mockMetrics.emitValidationError).toHaveBeenCalled(); + expect(mockMetrics.emitTransformationFailure).not.toHaveBeenCalled(); + }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts new file mode 100644 index 0000000..91d5eaa --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts @@ -0,0 +1,1186 @@ +import type { + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: notifyData, +}); + +describe("subscription filters", () => { + it("matches message status subscriptions by client, status, and event pattern", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects message status subscriptions when event source mismatches", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches channel status subscriptions by channel and supplier status", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects channel status subscriptions when channel does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "SMS", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when event source mismatches", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when clientId does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-2", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when status does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "FAILED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when status has not changed", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + previousMessageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches message status subscriptions when status has changed", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + previousMessageStatus: "CREATED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects channel status subscriptions when clientId does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-2", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when channelStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "FAILED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when supplierStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "rejected", + previousSupplierStatus: "notified", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when neither status changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", // No change + supplierStatus: "read", + previousSupplierStatus: "read", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches when only channelStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "notified", + previousSupplierStatus: "notified", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], // Not subscribed to NOTIFIED + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "SENDING", + previousChannelStatus: "SENDING", // No change + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty supplierStatuses array when channelStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty channelStatuses array when supplierStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty array = not subscribed to any channel status changes + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects with both arrays empty", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty + SupplierStatuses: [], // Empty + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts index 66d43b8..bdbcc3a 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts @@ -143,4 +143,16 @@ describe("CallbackMetrics", () => { ); }); }); + + describe("emitFilteringMatched", () => { + it("should emit FilteringMatched metric", () => { + callbackMetrics.emitFilteringMatched(); + + expect(mockPutMetric).toHaveBeenCalledWith( + "FilteringMatched", + 1, + Unit.Count, + ); + }); + }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index d5a4a10..1ded580 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -1,1060 +1,263 @@ import type { + Channel, + ChannelStatus, ChannelStatusData, ClientSubscriptionConfiguration, + MessageStatus, MessageStatusData, StatusPublishEvent, + SupplierStatus, } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; -import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; +import { TransformationError } from "services/error-handler"; +import { evaluateSubscriptionFilters } from "services/subscription-filter"; -const createBaseEvent = ( - type: string, - source: string, - notifyData: T, -): StatusPublishEvent => ({ +const createMessageStatusEvent = ( + clientId: string, + status: MessageStatus, +): StatusPublishEvent => ({ specversion: "1.0", id: "event-id", - source, + source: "source-a", subject: "subject", - type, + type: EventTypes.MESSAGE_STATUS_PUBLISHED, time: "2025-01-01T10:00:00Z", datacontenttype: "application/json", dataschema: "schema", traceparent: "traceparent", - data: notifyData, + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: status, + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId, + }, }); -describe("subscription filters", () => { - it("matches message status subscriptions by client, status, and event pattern", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects message status subscriptions when event source mismatches", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("matches channel status subscriptions by channel and supplier status", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects channel status subscriptions when channel does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "SMS", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when event source mismatches", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects message status subscriptions when clientId does not match", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-2", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects message status subscriptions when status does not match", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "FAILED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when clientId does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-2", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when channelStatus does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "FAILED", - previousChannelStatus: "SENDING", - supplierStatus: "read", - previousSupplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when supplierStatus does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "DELIVERED", - supplierStatus: "rejected", - previousSupplierStatus: "notified", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when neither status changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "DELIVERED", // No change - supplierStatus: "read", - previousSupplierStatus: "read", // No change - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); +const createChannelStatusEvent = ( + clientId: string, + channel: Channel, + channelStatus: ChannelStatus, + supplierStatus: SupplierStatus, + previousChannelStatus?: ChannelStatus, + previousSupplierStatus?: SupplierStatus, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.CHANNEL_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: { + messageId: "msg-123", + messageReference: "ref-123", + channel, + channelStatus, + previousChannelStatus, + supplierStatus, + previousSupplierStatus, + cascadeType: "primary" as const, + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId, + }, +}); - const config: ClientSubscriptionConfiguration = [ +const createMessageStatusConfig = ( + clientId: string, + statuses: MessageStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: "client-message", + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("matches when only channelStatus changed and is subscribed (OR logic)", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "notified", - previousSupplierStatus: "notified", // No change - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ + ], + SubscriptionType: "MessageStatus", + Statuses: statuses, + }, +]; + +const createChannelStatusConfig = ( + clientId: string, + channelType: Channel, + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: `client-${channelType}`, + ClientId: clientId, + Description: `${channelType} channel status subscription`, + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: [channelType], + }), + Targets: [ { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], // Not subscribed to NOTIFIED + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); + ], + SubscriptionType: "ChannelStatus", + ChannelType: channelType, + ChannelStatuses: channelStatuses, + SupplierStatuses: supplierStatuses, + }, +]; + +describe("evaluateSubscriptionFilters", () => { + describe("when config is undefined", () => { + it("returns not matched with Unknown subscription type", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config + const result = evaluateSubscriptionFilters(event, undefined); + + expect(result).toEqual({ + matched: false, + subscriptionType: "Unknown", + }); + }); }); - it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "SENDING", - previousChannelStatus: "SENDING", // No change - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; + describe("when event is MessageStatus", () => { + it("returns matched true when status matches subscription", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); + const result = evaluateSubscriptionFilters(event, config); - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING - SupplierStatuses: ["read"], - }, - ]; + expect(result).toEqual({ + matched: true, + subscriptionType: "MessageStatus", + }); + }); - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); + it("returns matched false when status does not match subscription", () => { + const event = createMessageStatusEvent("client-1", "FAILED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); - it("matches with empty supplierStatuses array when channelStatus changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; + const result = evaluateSubscriptionFilters(event, config); - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); + expect(result).toEqual({ + matched: false, + subscriptionType: "MessageStatus", + }); + }); }); - it("matches with empty channelStatuses array when supplierStatus changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: [], // Empty array = not subscribed to any channel status changes - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); + describe("when event is ChannelStatus", () => { + it("returns matched true when channel and statuses match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "DELIVERED", + "delivered", + "SENDING", + "notified", + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "ChannelStatus", + }); + }); + + it("returns matched false when channel status does not match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "FAILED", + "delivered", + "FAILED", // previousChannelStatus (no change) + "delivered", // previousSupplierStatus (no change) + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "ChannelStatus", + }); + }); }); - it("rejects with both arrays empty", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: [], // Empty - SupplierStatuses: [], // Empty - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); + describe("when event type is unknown", () => { + it("throws a TransformationError", () => { + const event = { + ...createMessageStatusEvent("client-1", "DELIVERED"), + type: "unknown-event-type", + } as StatusPublishEvent; + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + expect(() => evaluateSubscriptionFilters(event, config)).toThrow( + new TransformationError("Unsupported event type: unknown-event-type"), + ); + }); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts deleted file mode 100644 index c71818a..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { - Channel, - ChannelStatus, - ChannelStatusData, - ClientSubscriptionConfiguration, - MessageStatus, - MessageStatusData, - StatusPublishEvent, - SupplierStatus, -} from "@nhs-notify-client-callbacks/models"; -import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { evaluateSubscriptionFilters } from "services/transform-pipeline"; - -const createMessageStatusEvent = ( - clientId: string, - status: MessageStatus, -): StatusPublishEvent => ({ - specversion: "1.0", - id: "event-id", - source: "source-a", - subject: "subject", - type: EventTypes.MESSAGE_STATUS_PUBLISHED, - time: "2025-01-01T10:00:00Z", - datacontenttype: "application/json", - dataschema: "schema", - traceparent: "traceparent", - data: { - messageId: "msg-123", - messageReference: "ref-123", - messageStatus: status, - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId, - }, -}); - -const createChannelStatusEvent = ( - clientId: string, - channel: Channel, - channelStatus: ChannelStatus, - supplierStatus: SupplierStatus, - previousChannelStatus?: ChannelStatus, - previousSupplierStatus?: SupplierStatus, -): StatusPublishEvent => ({ - specversion: "1.0", - id: "event-id", - source: "source-a", - subject: "subject", - type: EventTypes.CHANNEL_STATUS_PUBLISHED, - time: "2025-01-01T10:00:00Z", - datacontenttype: "application/json", - dataschema: "schema", - traceparent: "traceparent", - data: { - messageId: "msg-123", - messageReference: "ref-123", - channel, - channelStatus, - previousChannelStatus, - supplierStatus, - previousSupplierStatus, - cascadeType: "primary" as const, - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId, - }, -}); - -const createMessageStatusConfig = ( - clientId: string, - statuses: MessageStatus[], -): ClientSubscriptionConfiguration => [ - { - Name: "client-message", - ClientId: clientId, - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: statuses, - }, -]; - -const createChannelStatusConfig = ( - clientId: string, - channelType: Channel, - channelStatuses: ChannelStatus[], - supplierStatuses: SupplierStatus[], -): ClientSubscriptionConfiguration => [ - { - Name: `client-${channelType}`, - ClientId: clientId, - Description: `${channelType} channel status subscription`, - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["ChannelStatus"], - channel: [channelType], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: channelType, - ChannelStatuses: channelStatuses, - SupplierStatuses: supplierStatuses, - }, -]; - -describe("evaluateSubscriptionFilters", () => { - describe("when config is undefined", () => { - it("returns not matched with Unknown subscription type", () => { - const event = createMessageStatusEvent("client-1", "DELIVERED"); - // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config - const result = evaluateSubscriptionFilters(event, undefined); - - expect(result).toEqual({ - matched: false, - subscriptionType: "Unknown", - }); - }); - }); - - describe("when event is MessageStatus", () => { - it("returns matched true when status matches subscription", () => { - const event = createMessageStatusEvent("client-1", "DELIVERED"); - const config = createMessageStatusConfig("client-1", ["DELIVERED"]); - - const result = evaluateSubscriptionFilters(event, config); - - expect(result).toEqual({ - matched: true, - subscriptionType: "MessageStatus", - }); - }); - - it("returns matched false when status does not match subscription", () => { - const event = createMessageStatusEvent("client-1", "FAILED"); - const config = createMessageStatusConfig("client-1", ["DELIVERED"]); - - const result = evaluateSubscriptionFilters(event, config); - - expect(result).toEqual({ - matched: false, - subscriptionType: "MessageStatus", - }); - }); - }); - - describe("when event is ChannelStatus", () => { - it("returns matched true when channel and statuses match subscription", () => { - const event = createChannelStatusEvent( - "client-1", - "EMAIL", - "DELIVERED", - "delivered", - "SENDING", // previousChannelStatus (changed) - "notified", // previousSupplierStatus (changed) - ); - const config = createChannelStatusConfig( - "client-1", - "EMAIL", - ["DELIVERED"], - ["delivered"], - ); - - const result = evaluateSubscriptionFilters(event, config); - - expect(result).toEqual({ - matched: true, - subscriptionType: "ChannelStatus", - }); - }); - - it("returns matched false when channel status does not match subscription", () => { - const event = createChannelStatusEvent( - "client-1", - "EMAIL", - "FAILED", - "delivered", - "FAILED", // previousChannelStatus (no change) - "delivered", // previousSupplierStatus (no change) - ); - const config = createChannelStatusConfig( - "client-1", - "EMAIL", - ["DELIVERED"], - ["delivered"], - ); - - const result = evaluateSubscriptionFilters(event, config); - - expect(result).toEqual({ - matched: false, - subscriptionType: "ChannelStatus", - }); - }); - }); - - describe("when event type is unknown", () => { - it("returns not matched with Unknown subscription type", () => { - const event = { - ...createMessageStatusEvent("client-1", "DELIVERED"), - type: "unknown-event-type", - } as StatusPublishEvent; - const config = createMessageStatusConfig("client-1", ["DELIVERED"]); - - const result = evaluateSubscriptionFilters(event, config); - - expect(result).toEqual({ - matched: false, - subscriptionType: "Unknown", - }); - }); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts index 81413b4..8792bc4 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts @@ -199,7 +199,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.type: Invalid input: expected string, received undefined", + "Validation failed: channels[0].type: Invalid input: expected string, received undefined", ); }); @@ -213,7 +213,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.channelStatus: Invalid input: expected string, received undefined", + "Validation failed: channels[0].channelStatus: Invalid input: expected string, received undefined", ); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 71ee812..4e1f20a 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -10,7 +10,7 @@ import { extractCorrelationId } from "services/logger"; import { ValidationError, getEventError } from "services/error-handler"; import type { ObservabilityService } from "services/observability"; import type { ConfigLoader } from "services/config-loader"; -import { evaluateSubscriptionFilters } from "services/transform-pipeline"; +import { evaluateSubscriptionFilters } from "services/subscription-filter"; const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10; @@ -158,6 +158,11 @@ async function filterBatch( if (filterResult.matched) { filtered.push(event); + observability.recordFilteringMatched({ + clientId, + eventType: event.type, + subscriptionType: filterResult.subscriptionType, + }); } else { stats.recordFiltered(); observability diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts index 452f8db..2c721ea 100644 --- a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -1,5 +1,4 @@ import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; -import { Readable } from "node:stream"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; import { ConfigCache } from "services/config-cache"; import { logger } from "services/logger"; @@ -15,28 +14,19 @@ type ConfigLoaderOptions = { cache: ConfigCache; }; -const isReadableStream = (value: unknown): value is Readable => - typeof value === "object" && value !== null && "on" in value; - -const streamToString = async (value: unknown): Promise => { - if (typeof value === "string") { - return value; - } - - if (value instanceof Uint8Array) { - return Buffer.from(value).toString("utf8"); - } - - if (isReadableStream(value)) { - const chunks: Buffer[] = []; - for await (const chunk of value) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks).toString("utf8"); +function throwAsConfigError(error: unknown, clientId: string): never { + if (error instanceof ConfigValidationError) { + logger.error("Config validation failed with schema violations", { + clientId, + validationErrors: error.issues, + }); + throw error; } - throw new Error("Response body is not readable"); -}; + const message = error instanceof Error ? error.message : String(error); + logger.error("Failed to load config from S3", { clientId }); + throw new ConfigValidationError([{ path: "config", message }]); +} export class ConfigLoader { constructor(private readonly options: ConfigLoaderOptions) {} @@ -67,7 +57,7 @@ export class ConfigLoader { throw new Error("S3 response body was empty"); } - const rawConfig = await streamToString(response.Body); + const rawConfig = await response.Body.transformToString(); const parsedConfig = JSON.parse(rawConfig) as unknown; const validated = validateClientConfig(parsedConfig); this.options.cache.set(clientId, validated); @@ -78,21 +68,13 @@ export class ConfigLoader { return validated; } catch (error) { if (error instanceof NoSuchKey) { - logger.info("No config found in S3 for client", { clientId }); + logger.info( + "No config found in S3 for client - events will be filtered out", + { clientId }, + ); return undefined; } - if (error instanceof ConfigValidationError) { - logger.error("Config validation failed with schema violations", { - clientId, - validationErrors: error.issues, - }); - throw error; - } - logger.error("Failed to load config from S3", { - clientId, - error: error instanceof Error ? error : new Error(String(error)), - }); - throw error; + throwAsConfigError(error, clientId); } } } diff --git a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts index 26e99d2..fb51432 100644 --- a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts +++ b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts @@ -44,6 +44,37 @@ export class TransformationError extends LambdaError { } } +export type ValidationIssue = { + path: string; + message: string; +}; + +export function formatValidationIssuePath(path: (string | number)[]): string { + let formatted = ""; + + for (const segment of path) { + formatted = + typeof segment === "number" + ? `${formatted}[${segment}]` + : formatted + ? `${formatted}.${segment}` + : segment; + } + + return formatted; +} + +export class ConfigValidationError extends LambdaError { + constructor(public readonly issues: ValidationIssue[]) { + super( + ErrorType.VALIDATION_ERROR, + "Client subscription configuration validation failed", + undefined, + false, + ); + } +} + function serializeUnknownError(error: unknown): string { if (typeof error === "string") { return error; @@ -104,6 +135,14 @@ export function getEventError( ? error.correlationId : "unknown"; + if (error instanceof ConfigValidationError) { + eventLogger.error("Client config validation failed", { + error, + }); + metrics.emitValidationError(); + return error; + } + if (error instanceof ValidationError) { eventLogger.error("Event validation failed", { correlationId, diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts index 8bd40de..244f57b 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -93,7 +93,7 @@ export const matchesChannelStatusSubscription = ( }); if (matched) { - logger.info("Channel status filter matched", { + logger.debug("Channel status filter matched", { clientId: notifyData.clientId, channel: notifyData.channel, channelStatus: notifyData.channelStatus, diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts index 1ad9f30..bfd906b 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts @@ -1,13 +1,15 @@ import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -type EventPattern = { +// Parsed representation of a subscription's EventSource / EventDetail filter criteria. +// Each key in `detail` maps to the list of allowed values for that event attribute. +type SubscriptionFilter = { sources: string[]; detail: Record; }; -const parseEventPattern = ( +const parseSubscriptionFilter = ( subscription: ClientSubscriptionConfiguration[number], -): EventPattern => { +): SubscriptionFilter => { const sources = JSON.parse(subscription.EventSource) as string[]; const detail = JSON.parse(subscription.EventDetail) as Record< string, @@ -19,17 +21,19 @@ const parseEventPattern = ( const matchesEventSource = (sources: string[], source: string): boolean => sources.length === 0 || sources.includes(source); +// Checks that every attribute required by the subscription's detail filter is +// present in the event AND has one of the subscription's allowed values. const matchesEventDetail = ( - detail: Record, + allowedDetailValues: Record, eventDetail: Record, ): boolean => - Object.entries(detail).every(([key, values]) => { + Object.entries(allowedDetailValues).every(([key, allowedValues]) => { // eslint-disable-next-line security/detect-object-injection - const value = eventDetail[key]; - if (!value) { + const eventValue = eventDetail[key]; + if (!eventValue) { return false; } - return values.includes(value); + return allowedValues.includes(eventValue); }); export const matchesEventPattern = ( @@ -37,9 +41,9 @@ export const matchesEventPattern = ( eventSource: string, eventDetail: Record, ): boolean => { - const pattern = parseEventPattern(subscription); + const subscriptionFilter = parseSubscriptionFilter(subscription); return ( - matchesEventSource(pattern.sources, eventSource) && - matchesEventDetail(pattern.detail, eventDetail) + matchesEventSource(subscriptionFilter.sources, eventSource) && + matchesEventDetail(subscriptionFilter.detail, eventDetail) ); }; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts index 1da0f96..6e92f95 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -30,12 +30,22 @@ export const matchesMessageStatusSubscription = ( return false; } - if (!subscription.Statuses.includes(notifyData.messageStatus)) { + // Check if message status changed AND client is subscribed to it + const messageStatusChanged = + notifyData.previousMessageStatus !== notifyData.messageStatus; + const clientSubscribedStatus = subscription.Statuses.includes( + notifyData.messageStatus, + ); + + if (!messageStatusChanged || !clientSubscribedStatus) { logger.debug( - "Message status filter rejected: status not in subscription", + "Message status filter rejected: no matching status change for subscription", { clientId: notifyData.clientId, messageStatus: notifyData.messageStatus, + previousMessageStatus: notifyData.previousMessageStatus, + messageStatusChanged, + clientSubscribedStatus, expectedStatuses: subscription.Statuses, }, ); @@ -59,7 +69,7 @@ export const matchesMessageStatusSubscription = ( }); if (matched) { - logger.info("Message status filter matched", { + logger.debug("Message status filter matched", { clientId: notifyData.clientId, messageStatus: notifyData.messageStatus, eventSource: event.source, diff --git a/lambdas/client-transform-filter-lambda/src/services/metrics.ts b/lambdas/client-transform-filter-lambda/src/services/metrics.ts index 6c808cd..398c5ec 100644 --- a/lambdas/client-transform-filter-lambda/src/services/metrics.ts +++ b/lambdas/client-transform-filter-lambda/src/services/metrics.ts @@ -43,4 +43,8 @@ export class CallbackMetrics { emitFilteringStarted(): void { this.metrics.putMetric("FilteringStarted", 1, Unit.Count); } + + emitFilteringMatched(): void { + this.metrics.putMetric("FilteringMatched", 1, Unit.Count); + } } diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index 8a52f52..d59850a 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -44,6 +44,15 @@ export class ObservabilityService { this.metrics.emitFilteringStarted(); } + recordFilteringMatched(context: { + clientId: string; + eventType: string; + subscriptionType: string; + }): void { + logLifecycleEvent(this.logger, "filtering-matched", context); + this.metrics.emitFilteringMatched(); + } + logBatchProcessingCompleted(context: { successful: number; failed: number; diff --git a/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts similarity index 91% rename from lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts rename to lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts index 11b6615..33eddf8 100644 --- a/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts +++ b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts @@ -7,6 +7,7 @@ import type { import { EventTypes } from "@nhs-notify-client-callbacks/models"; import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; +import { TransformationError } from "services/error-handler"; import { logger } from "services/logger"; type FilterResult = { @@ -42,5 +43,5 @@ export const evaluateSubscriptionFilters = ( } logger.warn("Unknown event type for filtering", { eventType: event.type }); - return { matched: false, subscriptionType: "Unknown" }; + throw new TransformationError(`Unsupported event type: ${event.type}`); }; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index 01ebef6..25dbf3a 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -6,17 +6,13 @@ import { MESSAGE_STATUSES, SUPPLIER_STATUSES, } from "@nhs-notify-client-callbacks/models"; +import { + ConfigValidationError, + type ValidationIssue, + formatValidationIssuePath, +} from "services/error-handler"; -type ValidationIssue = { - path: string; - message: string; -}; - -export class ConfigValidationError extends Error { - constructor(public readonly issues: ValidationIssue[]) { - super("Client subscription configuration validation failed"); - } -} +export { ConfigValidationError }; const jsonStringArraySchema = z.array(z.string()); const jsonRecordSchema = z.record(z.string(), z.array(z.string())); @@ -132,33 +128,20 @@ const configSchema = z.array(subscriptionSchema).superRefine((config, ctx) => { } }); -const formatIssuePath = (path: (string | number)[]): string => { - let formatted = "config"; - - for (const segment of path) { - formatted = - typeof segment === "number" - ? `${formatted}[${segment}]` - : `${formatted}.${segment}`; - } - - return formatted; -}; - export const validateClientConfig = ( rawConfig: unknown, ): ClientSubscriptionConfiguration => { const result = configSchema.safeParse(rawConfig); if (!result.success) { - const issues = result.error.issues.map((issue) => { + const issues: ValidationIssue[] = result.error.issues.map((issue) => { const pathSegments = issue.path.filter( (segment): segment is string | number => typeof segment === "string" || typeof segment === "number", ); return { - path: formatIssuePath(pathSegments), + path: formatValidationIssuePath(pathSegments), message: issue.message, }; }); @@ -168,8 +151,6 @@ export const validateClientConfig = ( return result.data; }; -export type { ValidationIssue }; - export { type ChannelStatusSubscriptionConfiguration, type MessageStatusSubscriptionConfiguration, diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts index 82180eb..03e3780 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts @@ -7,7 +7,10 @@ import { EventTypes, type StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; -import { ValidationError } from "services/error-handler"; +import { + ValidationError, + formatValidationIssuePath, +} from "services/error-handler"; import { extractCorrelationId } from "services/logger"; const NHSNotifyExtensionsSchema = z.object({ @@ -66,7 +69,15 @@ function formatValidationError(error: unknown, event: unknown): never { message = `CloudEvents validation failed: ${error.message}`; } else if (error instanceof z.ZodError) { const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .map((issue) => { + const path = formatValidationIssuePath( + issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ), + ); + return path ? `${path}: ${issue.message}` : issue.message; + }) .join(", "); message = `Validation failed: ${issues}`; } else if (error instanceof Error) { diff --git a/package-lock.json b/package-lock.json index 302f4dd..8a324f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,13 +80,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "lambdas/client-transform-filter-lambda/node_modules/zod": { - "version": "4.3.6", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "lambdas/mock-webhook-lambda": { "name": "nhs-notify-mock-webhook-lambda", "version": "0.0.1", @@ -10640,6 +10633,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "src/models": { "name": "@nhs-notify-client-callbacks/models", "version": "0.0.1", @@ -10674,9 +10676,8 @@ "dependencies": { "@aws-sdk/client-s3": "^3.821.0", "@nhs-notify-client-callbacks/models": "*", - "ajv": "^8.12.0", - "fast-uri": "^3.1.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.10.10", @@ -10694,24 +10695,6 @@ "undici-types": "~6.21.0" } }, - "tools/client-subscriptions-management/node_modules/ajv": { - "version": "8.18.0", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "tools/client-subscriptions-management/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" - }, "tools/client-subscriptions-management/node_modules/undici-types": { "version": "6.21.0", "dev": true, diff --git a/package.json b/package.json index 37c9c5a..5fc917e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,10 @@ "test:integration": "npm run test:integration --workspace tests/integration", "test:unit": "npm run test:unit --workspaces", "typecheck": "npm run typecheck --workspaces", - "verify": "npm run lint && npm run typecheck && npm run test:unit" + "verify": "npm run lint && npm run typecheck && npm run test:unit", + "subscriptions:get": "npm run get-by-client-id --workspace tools/client-subscriptions-management --", + "subscriptions:put-channel-status": "npm run put-channel-status --workspace tools/client-subscriptions-management --", + "subscriptions:put-message-status": "npm run put-message-status --workspace tools/client-subscriptions-management --" }, "workspaces": [ "lambdas/client-transform-filter-lambda", diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json index 00cb81a..a5cc2b8 100644 --- a/tests/integration/tsconfig.json +++ b/tests/integration/tsconfig.json @@ -5,18 +5,6 @@ "paths": { "helpers": [ "./helpers/index" - ], - "models/*": [ - "../../lambdas/client-transform-filter-lambda/src/models/*" - ], - "services/*": [ - "../../lambdas/client-transform-filter-lambda/src/services/*" - ], - "transformers/*": [ - "../../lambdas/client-transform-filter-lambda/src/transformers/*" - ], - "validators/*": [ - "../../lambdas/client-transform-filter-lambda/src/validators/*" ] } }, diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md index ee93476..ef13a69 100644 --- a/tools/client-subscriptions-management/README.md +++ b/tools/client-subscriptions-management/README.md @@ -12,6 +12,8 @@ npm --workspace tools/client-subscriptions-management [options] Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable. +Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `put-message-status` and `put-channel-status` commands. + ## Commands ### Get Client Subscriptions By Client ID @@ -38,6 +40,8 @@ npm --workspace tools/client-subscriptions-management put-message-status \ Optional: `--client-name "Test Client"` (defaults to client-id if not provided) +Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable + ### Put Channel Status Subscription ```bash @@ -56,4 +60,6 @@ npm --workspace tools/client-subscriptions-management put-channel-status \ Optional: `--client-name "Test Client"` (defaults to client-id if not provided) +Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable + **Note**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided. diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index f255b54..33540bd 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -13,11 +13,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@nhs-notify-client-callbacks/models": "*", "@aws-sdk/client-s3": "^3.821.0", - "ajv": "^8.12.0", - "fast-uri": "^3.1.0", - "yargs": "^17.7.2" + "@nhs-notify-client-callbacks/models": "*", + "yargs": "^17.7.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.10.10", diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts index 52c3c8e..8862c58 100644 --- a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -1,7 +1,6 @@ const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source"; -// eslint-disable-next-line import-x/first -- Ensure env is set before module load import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; afterAll(() => { diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts similarity index 96% rename from tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts rename to tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts index 12661b3..d13ee08 100644 --- a/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts @@ -1,10 +1,11 @@ -import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; +import { z } from "zod"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import type { ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, -} from "src/types"; -import type { S3Repository } from "src/infra/s3-repository"; +} from "@nhs-notify-client-callbacks/models"; +import type { S3Repository } from "src/repository/s3"; import type { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; const createRepository = ( @@ -224,7 +225,7 @@ describe("ClientSubscriptionRepository", () => { expect(putRawData).not.toHaveBeenCalled(); }); - describe("AJV validation", () => { + describe("validation", () => { it("throws validation error for invalid message status", async () => { const { repository } = createRepository(); @@ -238,7 +239,7 @@ describe("ClientSubscriptionRepository", () => { rateLimit: 10, dryRun: false, }), - ).rejects.toThrow(/Validation failed/); + ).rejects.toThrow(z.ZodError); }); it("throws validation error for missing required fields in message subscription", async () => { @@ -255,7 +256,7 @@ describe("ClientSubscriptionRepository", () => { rateLimit: 10, dryRun: false, }), - ).rejects.toThrow(/Validation failed/); + ).rejects.toThrow(z.ZodError); }); it("throws validation error for invalid channel type", async () => { @@ -273,7 +274,7 @@ describe("ClientSubscriptionRepository", () => { rateLimit: 10, dryRun: false, }), - ).rejects.toThrow(/Validation failed/); + ).rejects.toThrow(z.ZodError); }); it("throws validation error for invalid channel status", async () => { @@ -291,7 +292,7 @@ describe("ClientSubscriptionRepository", () => { rateLimit: 10, dryRun: false, }), - ).rejects.toThrow(/Validation failed/); + ).rejects.toThrow(z.ZodError); }); it("throws validation error for invalid supplier status", async () => { @@ -309,7 +310,7 @@ describe("ClientSubscriptionRepository", () => { rateLimit: 10, dryRun: false, }), - ).rejects.toThrow(/Validation failed/); + ).rejects.toThrow(z.ZodError); }); it("throws validation error when neither channelStatuses nor supplierStatuses are provided", async () => { diff --git a/tools/client-subscriptions-management/src/__tests__/constants.test.ts b/tools/client-subscriptions-management/src/__tests__/constants.test.ts index 19192f0..9d53f53 100644 --- a/tools/client-subscriptions-management/src/__tests__/constants.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/constants.test.ts @@ -3,7 +3,7 @@ import { CHANNEL_TYPES, MESSAGE_STATUSES, SUPPLIER_STATUSES, -} from "src/constants"; +} from "@nhs-notify-client-callbacks/models"; describe("constants", () => { it("exposes message statuses", () => { diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts index fcb5514..108697e 100644 --- a/tools/client-subscriptions-management/src/__tests__/container.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -1,11 +1,10 @@ -/* eslint-disable import-x/first */ import { S3Client } from "@aws-sdk/client-s3"; const mockS3Repository = jest.fn(); const mockBuilder = jest.fn(); const mockRepository = jest.fn(); -jest.mock("src/infra/s3-repository", () => ({ +jest.mock("src/repository/s3", () => ({ S3Repository: mockS3Repository, })); @@ -13,7 +12,7 @@ jest.mock("src/domain/client-subscription-builder", () => ({ ClientSubscriptionConfigurationBuilder: mockBuilder, })); -jest.mock("src/infra/client-subscription-repository", () => ({ +jest.mock("src/repository/client-subscriptions", () => ({ ClientSubscriptionRepository: mockRepository, })); diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts index 1cad9ff..39d838c 100644 --- a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/first, no-console */ const mockGetClientSubscriptions = jest.fn(); const mockCreateRepository = jest.fn().mockReturnValue({ getClientSubscriptions: mockGetClientSubscriptions, diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts index 441bc43..b7f9145 100644 --- a/tools/client-subscriptions-management/src/__tests__/helper.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts @@ -2,11 +2,12 @@ import type { ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, -} from "src/types"; +} from "@nhs-notify-client-callbacks/models"; import { formatSubscriptionFileResponse, normalizeClientName, resolveBucketName, + resolveEventSource, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -127,6 +128,26 @@ describe("cli helper", () => { ); }); + it("resolves event source from argument", () => { + expect(resolveEventSource("my-source")).toBe("my-source"); + }); + + it("resolves event source from env", () => { + expect( + resolveEventSource(undefined, { + CLIENT_SUBSCRIPTION_EVENT_SOURCE: "env-source", + } as NodeJS.ProcessEnv), + ).toBe("env-source"); + }); + + it("throws when event source is missing", () => { + expect(() => + resolveEventSource(undefined, {} as NodeJS.ProcessEnv), + ).toThrow( + "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", + ); + }); + it("resolves region from argument", () => { expect(resolveRegion("eu-west-2")).toBe("eu-west-2"); }); diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts index afe146f..b4423ba 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/first, no-console */ const mockPutChannelStatusSubscription = jest.fn(); const mockCreateRepository = jest.fn().mockReturnValue({ putChannelStatusSubscription: mockPutChannelStatusSubscription, @@ -6,6 +5,7 @@ const mockCreateRepository = jest.fn().mockReturnValue({ const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); const mockResolveRegion = jest.fn().mockReturnValue("region"); +const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); jest.mock("src/container", () => ({ createClientSubscriptionRepository: mockCreateRepository, @@ -14,6 +14,7 @@ jest.mock("src/container", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, + resolveEventSource: mockResolveEventSource, resolveRegion: mockResolveRegion, })); @@ -32,6 +33,8 @@ describe("put-channel-status CLI", () => { mockResolveBucketName.mockReturnValue("bucket"); mockResolveRegion.mockReset(); mockResolveRegion.mockReturnValue("region"); + mockResolveEventSource.mockReset(); + mockResolveEventSource.mockReturnValue("source-a"); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -375,7 +378,7 @@ describe("put-channel-status CLI", () => { channelType: "SMS", rateLimit: 10, dryRun: false, - eventSource: undefined, + eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts index 5d2cbe5..007ddb4 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/first, no-console */ const mockPutMessageStatusSubscription = jest.fn(); const mockCreateRepository = jest.fn().mockReturnValue({ putMessageStatusSubscription: mockPutMessageStatusSubscription, @@ -6,6 +5,7 @@ const mockCreateRepository = jest.fn().mockReturnValue({ const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); const mockResolveRegion = jest.fn().mockReturnValue("region"); +const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); jest.mock("src/container", () => ({ createClientSubscriptionRepository: mockCreateRepository, @@ -14,6 +14,7 @@ jest.mock("src/container", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, + resolveEventSource: mockResolveEventSource, resolveRegion: mockResolveRegion, })); @@ -32,6 +33,8 @@ describe("put-message-status CLI", () => { mockResolveBucketName.mockReturnValue("bucket"); mockResolveRegion.mockReset(); mockResolveRegion.mockReturnValue("region"); + mockResolveEventSource.mockReset(); + mockResolveEventSource.mockReturnValue("source-a"); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -310,7 +313,7 @@ describe("put-message-status CLI", () => { statuses: ["DELIVERED"], rateLimit: 10, dryRun: false, - eventSource: undefined, + eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); diff --git a/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts b/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts deleted file mode 100644 index 89dff3e..0000000 --- a/tools/client-subscriptions-management/src/__tests__/s3-repository.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - GetObjectCommand, - NoSuchKey, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { Readable } from "node:stream"; -import { S3Repository } from "src/infra/s3-repository"; - -describe("S3Repository", () => { - it("returns string content from S3", async () => { - const send = jest.fn().mockResolvedValue({ Body: "content" }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - const result = await repository.getObject("key.json"); - - expect(result).toBe("content"); - expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); - }); - - it("returns string content from Uint8Array", async () => { - const send = jest - .fn() - .mockResolvedValue({ Body: new TextEncoder().encode("content") }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - const result = await repository.getObject("key.json"); - - expect(result).toBe("content"); - }); - - it("returns string content from readable stream", async () => { - const send = jest - .fn() - .mockResolvedValue({ Body: Readable.from([Buffer.from("content")]) }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - const result = await repository.getObject("key.json"); - - expect(result).toBe("content"); - }); - - it("returns string content from string chunks", async () => { - const send = jest - .fn() - .mockResolvedValue({ Body: Readable.from(["content"]) }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - const result = await repository.getObject("key.json"); - - expect(result).toBe("content"); - }); - - it("throws when body is not readable", async () => { - const send = jest.fn().mockResolvedValue({ Body: 123 }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await expect(repository.getObject("key.json")).rejects.toThrow( - "Response body is not readable", - ); - }); - - it("throws when body is object without stream interface", async () => { - const send = jest.fn().mockResolvedValue({ Body: {} }); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await expect(repository.getObject("key.json")).rejects.toThrow( - "Response body is not readable", - ); - }); - - it("throws when body is missing", async () => { - const send = jest.fn().mockResolvedValue({}); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await expect(repository.getObject("key.json")).rejects.toThrow( - "Response is not a readable stream", - ); - }); - - it("returns undefined when object is missing", async () => { - const send = jest - .fn() - .mockRejectedValue( - new NoSuchKey({ message: "Not found", $metadata: {} }), - ); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await expect(repository.getObject("key.json")).resolves.toBeUndefined(); - }); - - it("rethrows non-NoSuchKey errors", async () => { - const send = jest.fn().mockRejectedValue(new Error("Denied")); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await expect(repository.getObject("key.json")).rejects.toThrow("Denied"); - }); - - it("writes object to S3", async () => { - const send = jest.fn().mockResolvedValue({}); - const repository = new S3Repository("bucket", { - send, - } as unknown as S3Client); - - await repository.putRawData("payload", "key.json"); - - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); - }); -}); diff --git a/tools/client-subscriptions-management/src/__tests__/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/s3.test.ts new file mode 100644 index 0000000..04a9037 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/s3.test.ts @@ -0,0 +1,68 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { S3Repository } from "src/repository/s3"; + +describe("S3Repository", () => { + it("returns string content from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: { transformToString: jest.fn().mockResolvedValue("content") }, + }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("throws when body is missing", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is missing", + ); + }); + + it("returns undefined when object is missing", async () => { + const send = jest + .fn() + .mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).resolves.toBeUndefined(); + }); + + it("rethrows non-NoSuchKey errors", async () => { + const send = jest.fn().mockRejectedValue(new Error("Denied")); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow("Denied"); + }); + + it("writes object to S3", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await repository.putRawData("payload", "key.json"); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); + }); +}); diff --git a/tools/client-subscriptions-management/src/constants.ts b/tools/client-subscriptions-management/src/constants.ts deleted file mode 100644 index ab8af71..0000000 --- a/tools/client-subscriptions-management/src/constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - MESSAGE_STATUSES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; - -export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; -export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; -export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; -export type ChannelType = (typeof CHANNEL_TYPES)[number]; - -export { - CHANNEL_STATUSES, - MESSAGE_STATUSES, - CHANNEL_TYPES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts index 1b78f14..93488be 100644 --- a/tools/client-subscriptions-management/src/container.ts +++ b/tools/client-subscriptions-management/src/container.ts @@ -1,6 +1,6 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; -import { S3Repository } from "src/infra/s3-repository"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { S3Repository } from "src/repository/s3"; import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; type RepositoryOptions = { diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts index dcc82a8..2d2c0b1 100644 --- a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -2,11 +2,11 @@ import { normalizeClientName } from "src/entrypoint/cli/helper"; import type { ChannelStatusSubscriptionArgs, MessageStatusSubscriptionArgs, -} from "src/infra/client-subscription-repository"; +} from "src/repository/client-subscriptions"; import type { ChannelStatusSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, -} from "src/types"; +} from "@nhs-notify-client-callbacks/models"; const DEFAULT_EVENT_SOURCE = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts index 207c66f..5ec837a 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -38,10 +38,8 @@ export async function main(args: string[] = process.argv) { ); if (result) { - // eslint-disable-next-line no-console console.log(formatSubscriptionFileResponse(result)); } else { - // eslint-disable-next-line no-console console.log(`No configuration exists for client: ${argv["client-id"]}`); } } @@ -50,7 +48,6 @@ export const runCli = async (args: string[] = process.argv) => { try { await main(args); } catch (error) { - // eslint-disable-next-line no-console console.error(error); process.exitCode = 1; } diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index f468410..e3cdf8e 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,4 +1,4 @@ -import type { ClientSubscriptionConfiguration } from "src/types"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; export const formatSubscriptionFileResponse = ( subscriptions: ClientSubscriptionConfiguration, @@ -39,6 +39,19 @@ export const resolveBucketName = ( return bucketName; }; +export const resolveEventSource = ( + eventSourceArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string => { + const eventSource = eventSourceArg ?? env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + if (!eventSource) { + throw new Error( + "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", + ); + } + return eventSource; +}; + export const resolveRegion = ( regionArg?: string, env: NodeJS.ProcessEnv = process.env, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts index f9f4e32..d4472e7 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -4,11 +4,12 @@ import { CHANNEL_STATUSES, CHANNEL_TYPES, SUPPLIER_STATUSES, -} from "src/constants"; +} from "@nhs-notify-client-callbacks/models"; import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, + resolveEventSource, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -80,7 +81,6 @@ export async function main(args: string[] = process.argv) { const argv = parseArgs(args); const apiEndpoint = argv["api-endpoint"]; if (!/^https:\/\//.test(apiEndpoint)) { - // eslint-disable-next-line no-console console.error("Error: api-endpoint must start with https://"); process.exitCode = 1; return; @@ -89,7 +89,6 @@ export async function main(args: string[] = process.argv) { const channelStatuses = argv["channel-statuses"]; const supplierStatuses = argv["supplier-statuses"]; if (!channelStatuses?.length && !supplierStatuses?.length) { - // eslint-disable-next-line no-console console.error( "Error: at least one of --channel-statuses or --supplier-statuses must be provided", ); @@ -98,10 +97,11 @@ export async function main(args: string[] = process.argv) { } const bucketName = resolveBucketName(argv["bucket-name"]); + const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, region: resolveRegion(argv.region), - eventSource: argv["event-source"], + eventSource, }); const result = @@ -116,10 +116,9 @@ export async function main(args: string[] = process.argv) { supplierStatuses, rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource: argv["event-source"], + eventSource, }); - // eslint-disable-next-line no-console console.log(formatSubscriptionFileResponse(result)); } @@ -127,7 +126,6 @@ export const runCli = async (args: string[] = process.argv) => { try { await main(args); } catch (error) { - // eslint-disable-next-line no-console console.error(error); process.exitCode = 1; } diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts index 3eed0c3..5e12aa1 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -1,10 +1,11 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { MESSAGE_STATUSES } from "src/constants"; +import { MESSAGE_STATUSES } from "@nhs-notify-client-callbacks/models"; import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, + resolveEventSource, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -65,17 +66,17 @@ export async function main(args: string[] = process.argv) { const argv = parseArgs(args); const apiEndpoint = argv["api-endpoint"]; if (!/^https:\/\//.test(apiEndpoint)) { - // eslint-disable-next-line no-console console.error("Error: api-endpoint must start with https://"); process.exitCode = 1; return; } const bucketName = resolveBucketName(argv["bucket-name"]); + const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, region: resolveRegion(argv.region), - eventSource: argv["event-source"], + eventSource, }); const result = @@ -88,10 +89,9 @@ export async function main(args: string[] = process.argv) { statuses: argv["message-statuses"], rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource: argv["event-source"], + eventSource, }); - // eslint-disable-next-line no-console console.log(formatSubscriptionFileResponse(result)); } @@ -99,7 +99,6 @@ export const runCli = async (args: string[] = process.argv) => { try { await main(args); } catch (error) { - // eslint-disable-next-line no-console console.error(error); process.exitCode = 1; } diff --git a/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts similarity index 59% rename from tools/client-subscriptions-management/src/infra/client-subscription-repository.ts rename to tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 2f6a3ad..2132bf0 100644 --- a/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -1,17 +1,17 @@ -import Ajv from "ajv"; +import { z } from "zod"; import { CHANNEL_STATUSES, CHANNEL_TYPES, + type Channel, type ChannelStatus, - type ChannelType, + type ClientSubscriptionConfiguration, MESSAGE_STATUSES, type MessageStatus, SUPPLIER_STATUSES, type SupplierStatus, -} from "src/constants"; +} from "@nhs-notify-client-callbacks/models"; import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; -import type { ClientSubscriptionConfiguration } from "src/types"; -import { S3Repository } from "src/infra/s3-repository"; +import { S3Repository } from "src/repository/s3"; export type MessageStatusSubscriptionArgs = { clientName: string; @@ -25,33 +25,17 @@ export type MessageStatusSubscriptionArgs = { eventSource?: string; }; -const messageStatusSubscriptionArgsSchema = { - type: "object", - properties: { - clientName: { type: "string" }, - clientId: { type: "string" }, - apiKey: { type: "string" }, - apiEndpoint: { type: "string" }, - statuses: { - type: "array", - items: { type: "string", enum: MESSAGE_STATUSES }, - }, - rateLimit: { type: "number" }, - dryRun: { type: "boolean" }, - apiKeyHeaderName: { type: "string" }, - eventSource: { type: "string" }, - }, - required: [ - "clientName", - "clientId", - "apiKey", - "apiEndpoint", - "statuses", - "rateLimit", - "dryRun", - ], - additionalProperties: false, -} as const; +const messageStatusSubscriptionArgsSchema = z.object({ + clientName: z.string(), + clientId: z.string(), + apiKey: z.string(), + apiEndpoint: z.string(), + statuses: z.array(z.enum(MESSAGE_STATUSES)), + rateLimit: z.number(), + dryRun: z.boolean(), + apiKeyHeaderName: z.string().optional().default("x-api-key"), + eventSource: z.string().optional(), +}); export type ChannelStatusSubscriptionArgs = { clientName: string; @@ -60,55 +44,26 @@ export type ChannelStatusSubscriptionArgs = { apiEndpoint: string; channelStatuses?: ChannelStatus[]; supplierStatuses?: SupplierStatus[]; - channelType: ChannelType; + channelType: Channel; rateLimit: number; dryRun: boolean; apiKeyHeaderName?: string; eventSource?: string; }; -const channelStatusSubscriptionArgsSchema = { - type: "object", - properties: { - clientName: { type: "string" }, - clientId: { type: "string" }, - apiKey: { type: "string" }, - apiEndpoint: { type: "string" }, - channelStatuses: { - type: "array", - items: { type: "string", enum: CHANNEL_STATUSES }, - minItems: 1, - }, - supplierStatuses: { - type: "array", - items: { type: "string", enum: SUPPLIER_STATUSES }, - minItems: 1, - }, - channelType: { type: "string", enum: CHANNEL_TYPES }, - rateLimit: { type: "number" }, - dryRun: { type: "boolean" }, - apiKeyHeaderName: { type: "string" }, - eventSource: { type: "string" }, - }, - required: [ - "clientName", - "clientId", - "apiKey", - "apiEndpoint", - "channelType", - "rateLimit", - "dryRun", - ], - additionalProperties: false, -} as const; - -const ajv = new Ajv({ useDefaults: true }); -const validateMessageStatusArgs = ajv.compile( - messageStatusSubscriptionArgsSchema, -); -const validateChannelStatusArgs = ajv.compile( - channelStatusSubscriptionArgsSchema, -); +const channelStatusSubscriptionArgsSchema = z.object({ + clientName: z.string(), + clientId: z.string(), + apiKey: z.string(), + apiEndpoint: z.string(), + channelStatuses: z.array(z.enum(CHANNEL_STATUSES)).min(1).optional(), + supplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)).min(1).optional(), + channelType: z.enum(CHANNEL_TYPES), + rateLimit: z.number(), + dryRun: z.boolean(), + apiKeyHeaderName: z.string().optional().default("x-api-key"), + eventSource: z.string().optional(), +}); export class ClientSubscriptionRepository { constructor( @@ -132,16 +87,8 @@ export class ClientSubscriptionRepository { async putMessageStatusSubscription( subscriptionArgs: MessageStatusSubscriptionArgs, ) { - const parsedSubscriptionArgs = { - apiKeyHeaderName: "x-api-key", - ...subscriptionArgs, - }; - - if (!validateMessageStatusArgs(parsedSubscriptionArgs)) { - throw new Error( - `Validation failed: ${ajv.errorsText(validateMessageStatusArgs.errors)}`, - ); - } + const parsedSubscriptionArgs = + messageStatusSubscriptionArgsSchema.parse(subscriptionArgs); const { clientId } = parsedSubscriptionArgs; const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; @@ -176,16 +123,8 @@ export class ClientSubscriptionRepository { async putChannelStatusSubscription( subscriptionArgs: ChannelStatusSubscriptionArgs, ): Promise { - const parsedSubscriptionArgs = { - apiKeyHeaderName: "x-api-key", - ...subscriptionArgs, - }; - - if (!validateChannelStatusArgs(parsedSubscriptionArgs)) { - throw new Error( - `Validation failed: ${ajv.errorsText(validateChannelStatusArgs.errors)}`, - ); - } + const parsedSubscriptionArgs = + channelStatusSubscriptionArgsSchema.parse(subscriptionArgs); if ( !parsedSubscriptionArgs.channelStatuses?.length && diff --git a/tools/client-subscriptions-management/src/infra/s3-repository.ts b/tools/client-subscriptions-management/src/repository/s3.ts similarity index 55% rename from tools/client-subscriptions-management/src/infra/s3-repository.ts rename to tools/client-subscriptions-management/src/repository/s3.ts index 59a78bf..a306298 100644 --- a/tools/client-subscriptions-management/src/infra/s3-repository.ts +++ b/tools/client-subscriptions-management/src/repository/s3.ts @@ -5,30 +5,6 @@ import { PutObjectCommandInput, S3Client, } from "@aws-sdk/client-s3"; -import { Readable } from "node:stream"; - -const isReadableStream = (value: unknown): value is Readable => - typeof value === "object" && value !== null && "on" in value; - -const streamToString = async (value: unknown): Promise => { - if (typeof value === "string") { - return value; - } - - if (value instanceof Uint8Array) { - return Buffer.from(value).toString("utf8"); - } - - if (isReadableStream(value)) { - const chunks: Buffer[] = []; - for await (const chunk of value) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks).toString("utf8"); - } - - throw new Error("Response body is not readable"); -}; // eslint-disable-next-line import-x/prefer-default-export export class S3Repository { @@ -46,10 +22,10 @@ export class S3Repository { const { Body } = await this.s3Client.send(new GetObjectCommand(params)); if (!Body) { - throw new Error("Response is not a readable stream"); + throw new Error("Response body is missing"); } - return await streamToString(Body); + return await Body.transformToString(); } catch (error) { if (error instanceof NoSuchKey) { return undefined; diff --git a/tools/client-subscriptions-management/src/types.ts b/tools/client-subscriptions-management/src/types.ts deleted file mode 100644 index d24a9f6..0000000 --- a/tools/client-subscriptions-management/src/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - ChannelStatus, - ChannelType, - MessageStatus, - SupplierStatus, -} from "src/constants"; - -type SubscriptionConfigurationBase = { - Name: string; - ClientId: string; - Description: string; - EventSource: string; - EventDetail: string; - Targets: { - Type: "API"; - TargetId: string; - Name: string; - InputTransformer: { - InputPaths: string; - InputHeaders: { - "x-hmac-sha256-signature": string; - }; - }; - InvocationEndpoint: string; - InvocationMethod: "POST"; - InvocationRateLimit: number; - APIKey: { - HeaderName: string; - HeaderValue: string; - }; - }[]; -}; - -export type ChannelStatusSubscriptionConfiguration = - SubscriptionConfigurationBase & { - SubscriptionType: "ChannelStatus"; - ChannelType: ChannelType; - ChannelStatuses: ChannelStatus[]; - SupplierStatuses: SupplierStatus[]; - }; - -export type MessageStatusSubscriptionConfiguration = - SubscriptionConfigurationBase & { - SubscriptionType: "MessageStatus"; - Statuses: MessageStatus[]; - }; - -export type ClientSubscriptionConfiguration = ( - | MessageStatusSubscriptionConfiguration - | ChannelStatusSubscriptionConfiguration -)[]; From 610c8c2c4e613340fa529678eda5192115865977 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 5 Mar 2026 15:25:24 +0000 Subject: [PATCH 05/16] CCM-14201 - Fixed linting --- .../src/__tests__/services/config-loader.test.ts | 15 +++------------ .../src/__tests__/services/error-handler.test.ts | 12 ++++++++++-- .../services/filters/subscription-filter.test.ts | 2 +- .../src/services/config-loader.ts | 1 + .../src/services/error-handler.ts | 13 +++++++------ .../src/services/logger.ts | 1 + .../src/services/validators/config-validator.ts | 2 +- 7 files changed, 24 insertions(+), 22 deletions(-) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index 584309d..36a6fa2 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -116,18 +116,9 @@ describe("ConfigLoader", () => { const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); const loader = createLoader(send); - const error = await loader.loadClientConfig("client-1").catch((e) => e); - expect(error).toBeInstanceOf(ConfigValidationError); - expect(error.issues).toEqual([ - { path: "config", message: "S3 access denied" }, - ]); - }); - - it("wraps non-Error values thrown by S3 as ConfigValidationError", async () => { - const send = jest.fn().mockRejectedValue("unexpected string error"); - const loader = createLoader(send); - - const error = await loader.loadClientConfig("client-1").catch((e) => e); + const error = await loader + .loadClientConfig("client-1") + .catch((error_) => error_); expect(error).toBeInstanceOf(ConfigValidationError); expect(error.issues).toEqual([ { path: "config", message: "unexpected string error" }, diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts index 669d91e..bd4ad4c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts @@ -157,7 +157,12 @@ describe("formatValidationIssuePath", () => { describe("ConfigValidationError", () => { it("should create error with issues array", () => { - const issues = [{ path: "[0].Name", message: "Expected Name to be unique" }]; + const issues = [ + { + path: "[0].Name", + message: "Expected Name to be unique", + }, + ]; const error = new ConfigValidationError(issues); expect(error.message).toBe( @@ -352,7 +357,10 @@ describe("getEventError", () => { it("should return ConfigValidationError and emit validation metric", () => { const error = new ConfigValidationError([ - { path: "[0].Name", message: "Expected Name to be unique" }, + { + path: "[0].Name", + message: "Expected Name to be unique", + }, ]); const result = getEventError(error, mockMetrics, mockEventLogger); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts index 91d5eaa..682d0ff 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts @@ -534,7 +534,7 @@ describe("subscription filters", () => { messageId: "message-id", messageReference: "reference", messageStatus: "DELIVERED", - previousMessageStatus: "CREATED", + previousMessageStatus: "PENDING_ENRICHMENT", channels: [], timestamp: "2025-01-01T10:00:00Z", routingPlan: { diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts index 2c721ea..d95e141 100644 --- a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -75,6 +75,7 @@ export class ConfigLoader { return undefined; } throwAsConfigError(error, clientId); + return undefined; } } } diff --git a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts index fb51432..8eef206 100644 --- a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts +++ b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts @@ -53,12 +53,13 @@ export function formatValidationIssuePath(path: (string | number)[]): string { let formatted = ""; for (const segment of path) { - formatted = - typeof segment === "number" - ? `${formatted}[${segment}]` - : formatted - ? `${formatted}.${segment}` - : segment; + if (typeof segment === "number") { + formatted = `${formatted}[${segment}]`; + } else if (formatted) { + formatted = `${formatted}.${segment}`; + } else { + formatted = segment; + } } return formatted; diff --git a/lambdas/client-transform-filter-lambda/src/services/logger.ts b/lambdas/client-transform-filter-lambda/src/services/logger.ts index e5bad4b..1149d02 100644 --- a/lambdas/client-transform-filter-lambda/src/services/logger.ts +++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts @@ -80,6 +80,7 @@ export function logLifecycleEvent( | "transformation-started" | "transformation-completed" | "filtering-started" + | "filtering-matched" | "delivery-initiated" | "batch-processing-completed", context: LogContext, diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index 25dbf3a..7b196d6 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -12,7 +12,7 @@ import { formatValidationIssuePath, } from "services/error-handler"; -export { ConfigValidationError }; +export { ConfigValidationError } from "services/error-handler"; const jsonStringArraySchema = z.array(z.string()); const jsonRecordSchema = z.record(z.string(), z.array(z.string())); From 589b2b4f33447b4df0966cdf5bd92ac279682659 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 5 Mar 2026 15:30:44 +0000 Subject: [PATCH 06/16] CCM-14201 - Fixed test --- .../src/__tests__/services/config-loader.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index 36a6fa2..29dc52a 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -116,6 +116,19 @@ describe("ConfigLoader", () => { const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); const loader = createLoader(send); + const error = await loader + .loadClientConfig("client-1") + .catch((error_) => error_); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error.issues).toEqual([ + { path: "config", message: "S3 access denied" }, + ]); + }); + + it("wraps non-Error values thrown by S3 as ConfigValidationError", async () => { + const send = jest.fn().mockRejectedValue("unexpected string error"); + const loader = createLoader(send); + const error = await loader .loadClientConfig("client-1") .catch((error_) => error_); From a00642ca4d744dd0af03ca5bc3113dcc8dfac46e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Fri, 6 Mar 2026 14:11:06 +0000 Subject: [PATCH 07/16] Unit test refactoring, swap back to v8 coverage provider (#48) * Fix open handle errors in unit tests * Switch back to v8 as coverage provider and update to fix bug * Move index* tests which test cong-loader-service to config-loader-service test * Mock logger in config-loader / subscription filter tests * Split subscription filter tests * Fix restoration of env var in config-loader-service test --- jest.config.base.ts | 2 +- .../__tests__/index.cache-ttl-invalid.test.ts | 11 - .../__tests__/index.cache-ttl-valid.test.ts | 11 - .../src/__tests__/index.config-prefix.test.ts | 140 -- .../src/__tests__/index.s3-config.test.ts | 32 - .../src/__tests__/index.test.ts | 2 + .../services/config-loader-service.test.ts | 97 +- .../__tests__/services/config-loader.test.ts | 9 + .../filters/channel-status-filter.test.ts | 317 +++++ .../filters/message-status-filter.test.ts | 208 +++ .../filters/subscription-filter.test.ts | 1186 ----------------- .../services/subscription-filter.test.ts | 9 + package-lock.json | 4 +- 13 files changed, 644 insertions(+), 1384 deletions(-) delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts diff --git a/jest.config.base.ts b/jest.config.base.ts index 52c1d02..f057e3e 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -5,7 +5,7 @@ export const baseJestConfig: Config = { clearMocks: true, collectCoverage: true, coverageDirectory: "./.reports/unit/coverage", - coverageProvider: "babel", + coverageProvider: "v8", coveragePathIgnorePatterns: ["/__tests__/", "/node_modules/"], transform: { "^.+\\.ts$": "ts-jest" }, testPathIgnorePatterns: [".build"], diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts deleted file mode 100644 index 0c495c8..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { resolveCacheTtlMs } from "services/config-loader-service"; - -describe("cache ttl configuration", () => { - it("falls back to default TTL when invalid", () => { - const ttlMs = resolveCacheTtlMs({ - CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number", - } as NodeJS.ProcessEnv); - - expect(ttlMs).toBe(60_000); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts deleted file mode 100644 index a60973f..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { resolveCacheTtlMs } from "services/config-loader-service"; - -describe("cache ttl configuration", () => { - it("uses the configured TTL when valid", () => { - const ttlMs = resolveCacheTtlMs({ - CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "120", - } as NodeJS.ProcessEnv); - - expect(ttlMs).toBe(120_000); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts deleted file mode 100644 index bb11ccb..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -// eslint-disable-next-line unicorn/no-useless-undefined -const mockLoadClientConfig = jest.fn().mockResolvedValue(undefined); -const mockConfigLoader = jest.fn().mockImplementation(() => ({ - loadClientConfig: mockLoadClientConfig, -})); - -jest.mock("services/config-loader", () => ({ - ConfigLoader: mockConfigLoader, -})); - -jest.mock("aws-embedded-metrics", () => ({ - createMetricsLogger: jest.fn(() => ({ - setNamespace: jest.fn(), - setDimensions: jest.fn(), - putMetric: jest.fn(), - flush: jest.fn().mockResolvedValue(undefined as unknown), - })), - Unit: { - Count: "Count", - Milliseconds: "Milliseconds", - }, -})); - -import type { SQSRecord } from "aws-lambda"; -import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { configLoaderService, handler } from ".."; - -const makeSqsRecord = (body: object): SQSRecord => ({ - messageId: "sqs-id", - receiptHandle: "receipt", - body: JSON.stringify(body), - attributes: { - ApproximateReceiveCount: "1", - SentTimestamp: "1519211230", - SenderId: "ABCDEFGHIJ", - ApproximateFirstReceiveTimestamp: "1519211230", - }, - messageAttributes: {}, - md5OfBody: "md5", - eventSource: "aws:sqs", - eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue", - awsRegion: "eu-west-2", -}); - -const validEvent = { - specversion: "1.0", - id: "event-id", - source: "/nhs/england/notify/development/primary/data-plane/client-callbacks", - subject: "customer/test/message/msg-123", - type: EventTypes.MESSAGE_STATUS_PUBLISHED, - time: "2025-01-01T10:00:00.000Z", - datacontenttype: "application/json", - dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json", - traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", - data: { - messageId: "msg-123", - messageReference: "ref-123", - messageStatus: "DELIVERED", - channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }, -}; - -describe("config prefix resolution", () => { - beforeEach(() => { - mockLoadClientConfig.mockClear(); - mockConfigLoader.mockClear(); - configLoaderService.reset(); // force lazy re-creation of ConfigLoader on next call - process.env.METRICS_NAMESPACE = "test-namespace"; - process.env.ENVIRONMENT = "test"; - }); - - afterEach(() => { - delete process.env.METRICS_NAMESPACE; - delete process.env.ENVIRONMENT; - }); - - it("uses the default prefix when env is not set", async () => { - const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - - await handler([makeSqsRecord(validEvent)]); - - expect(mockConfigLoader).toHaveBeenCalledWith( - expect.objectContaining({ - keyPrefix: "client_subscriptions/", - }), - ); - - if (originalBucket === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - } else { - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; - } - - if (originalPrefix === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - } else { - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; - } - }); - - it("uses the configured prefix when env is set", async () => { - const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; - - await handler([makeSqsRecord(validEvent)]); - - expect(mockConfigLoader).toHaveBeenCalledWith( - expect.objectContaining({ - keyPrefix: "custom_prefix/", - }), - ); - - if (originalBucket === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; - } else { - process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; - } - - if (originalPrefix === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; - } else { - process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; - } - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts deleted file mode 100644 index ae483b7..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createS3Client } from "services/config-loader-service"; - -describe("createS3Client", () => { - it("sets forcePathStyle=true when endpoint contains localhost", () => { - const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; - const client = createS3Client(env); - - // Access the config through the client's config property - const { config } = client as any; - expect(config.endpoint).toBeDefined(); - expect(config.forcePathStyle).toBe(true); - }); - - it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { - const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; - const client = createS3Client(env); - - const { config } = client as any; - expect(config.endpoint).toBeDefined(); - // S3Client converts undefined to false, so we just check it's not true - expect(config.forcePathStyle).not.toBe(true); - }); - - it("does not set forcePathStyle=true when endpoint is not set", () => { - const env = {}; - const client = createS3Client(env); - - const { config } = client as any; - // S3Client converts undefined to false, so we just check it's not true - expect(config.forcePathStyle).not.toBe(true); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index dcb4599..4bc389c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -14,6 +14,8 @@ import { ObservabilityService } from "services/observability"; import { ConfigLoaderService } from "services/config-loader-service"; import { createHandler } from ".."; +jest.mock("aws-embedded-metrics"); + const createPassthroughConfigLoader = (): ConfigLoader => ({ loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts index 753a1e8..a5741d2 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts @@ -1,12 +1,28 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigLoader } from "services/config-loader"; import { ConfigLoaderService, createS3Client, + resolveCacheTtlMs, } from "services/config-loader-service"; +const mockS3Client = jest.mocked(S3Client); +const mockConfigLoader = jest.mocked(ConfigLoader); + +jest.mock("@aws-sdk/client-s3", () => ({ + S3Client: jest.fn(), +})); + +jest.mock("services/config-loader", () => ({ + ConfigLoader: jest.fn(), +})); + describe("ConfigLoaderService", () => { const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; beforeEach(() => { + mockConfigLoader.mockClear(); process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; }); @@ -16,6 +32,12 @@ describe("ConfigLoaderService", () => { } else { process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } }); describe("getLoader", () => { @@ -33,6 +55,24 @@ describe("ConfigLoaderService", () => { "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", ); }); + + it("uses the default key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + const service = new ConfigLoaderService(); + service.getLoader(); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "client_subscriptions/" }), + ); + }); + + it("uses the configured key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const service = new ConfigLoaderService(); + service.getLoader(); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "custom_prefix/" }), + ); + }); }); describe("reset", () => { @@ -60,8 +100,10 @@ describe("ConfigLoaderService", () => { AWS_ENDPOINT_URL: "http://localhost:4566", }); const service = new ConfigLoaderService(); - expect(() => service.reset(customClient)).not.toThrow(); - delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + service.reset(customClient); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "custom_prefix/" }), + ); }); it("throws when S3Client is provided but CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => { @@ -74,3 +116,54 @@ describe("ConfigLoaderService", () => { }); }); }); + +describe("createS3Client", () => { + beforeEach(() => { + mockS3Client.mockClear(); + }); + + it("sets forcePathStyle=true when endpoint contains localhost", () => { + createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: "http://localhost:4566", + forcePathStyle: true, + }); + }); + + it("does not set forcePathStyle when endpoint does not contain localhost", () => { + createS3Client({ AWS_ENDPOINT_URL: "https://custom-s3.example.com" }); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: "https://custom-s3.example.com", + forcePathStyle: undefined, + }); + }); + + it("does not set forcePathStyle when endpoint is not set", () => { + createS3Client({}); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: undefined, + forcePathStyle: undefined, + }); + }); +}); + +describe("resolveCacheTtlMs", () => { + it("falls back to default TTL when value is not a number", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(60_000); + }); + + it("uses the configured TTL when valid", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "120", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(120_000); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index 29dc52a..5f61b67 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -3,6 +3,15 @@ import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; import { ConfigValidationError } from "services/validators/config-validator"; +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + const mockBody = (json: string) => ({ transformToString: jest.fn().mockResolvedValue(json), }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts new file mode 100644 index 0000000..d7ed9d3 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts @@ -0,0 +1,317 @@ +import type { + ChannelStatus, + ChannelStatusData, + ClientSubscriptionConfiguration, + StatusPublishEvent, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: notifyData, +}); + +const createChannelStatusConfig = ( + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], + source = "source-a", + clientId = "client-1", +): ClientSubscriptionConfiguration => [ + { + Name: "client-1-email", + ClientId: clientId, + Description: "Channel config", + EventSource: JSON.stringify([source]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: channelStatuses, + SupplierStatuses: supplierStatuses, + }, +]; + +const createChannelStatusData = ( + overrides: Partial = {}, +): ChannelStatusData => ({ + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + ...overrides, +}); + +describe("matchesChannelStatusSubscription", () => { + it("matches by channel and supplier status", () => { + const notifyData = createChannelStatusData(); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("rejects when channel does not match", () => { + const notifyData = createChannelStatusData({ channel: "SMS" }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when event source mismatches", () => { + const notifyData = createChannelStatusData(); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when clientId does not match", () => { + const notifyData = createChannelStatusData({ clientId: "client-2" }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when channelStatus does not match", () => { + const notifyData = createChannelStatusData({ + channelStatus: "FAILED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "read", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when supplierStatus does not match", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "rejected", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when neither status changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "read", + previousSupplierStatus: "read", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("matches when only channelStatus changed and is subscribed (OR logic)", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "notified", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { + const notifyData = createChannelStatusData({ + channelStatus: "SENDING", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches with empty supplierStatuses when channelStatus changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], []), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches with empty channelStatuses when supplierStatus changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig([], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("rejects with both channelStatuses and supplierStatuses empty", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription(createChannelStatusConfig([], []), { + event, + notifyData, + }), + ).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts new file mode 100644 index 0000000..32a3da3 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts @@ -0,0 +1,208 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatus, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: notifyData, +}); + +const createMessageStatusConfig = ( + statuses: MessageStatus[], + source = "source-a", + clientId = "client-1", +): ClientSubscriptionConfiguration => [ + { + Name: "client-1-message", + ClientId: clientId, + Description: "Message config", + EventSource: JSON.stringify([source]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: statuses, + }, +]; + +const createMessageStatusData = ( + overrides: Partial = {}, +): MessageStatusData => ({ + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + ...overrides, +}); + +describe("matchesMessageStatusSubscription", () => { + it("matches by client, status, and event pattern", () => { + const notifyData = createMessageStatusData(); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(true); + }); + + it("rejects when event source mismatches", () => { + const notifyData = createMessageStatusData(); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("rejects when clientId does not match", () => { + const notifyData = createMessageStatusData({ clientId: "client-2" }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("rejects when status does not match", () => { + const notifyData = createMessageStatusData({ messageStatus: "FAILED" }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("rejects when status has not changed", () => { + const notifyData = createMessageStatusData({ + messageStatus: "DELIVERED", + previousMessageStatus: "DELIVERED", + }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("matches when status has changed", () => { + const notifyData = createMessageStatusData({ + messageStatus: "DELIVERED", + previousMessageStatus: "PENDING_ENRICHMENT", + }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(true); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts deleted file mode 100644 index 682d0ff..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts +++ /dev/null @@ -1,1186 +0,0 @@ -import type { - ChannelStatusData, - ClientSubscriptionConfiguration, - MessageStatusData, - StatusPublishEvent, -} from "@nhs-notify-client-callbacks/models"; -import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; -import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; - -const createBaseEvent = ( - type: string, - source: string, - notifyData: T, -): StatusPublishEvent => ({ - specversion: "1.0", - id: "event-id", - source, - subject: "subject", - type, - time: "2025-01-01T10:00:00Z", - datacontenttype: "application/json", - dataschema: "schema", - traceparent: "traceparent", - data: notifyData, -}); - -describe("subscription filters", () => { - it("matches message status subscriptions by client, status, and event pattern", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects message status subscriptions when event source mismatches", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("matches channel status subscriptions by channel and supplier status", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects channel status subscriptions when channel does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "SMS", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when event source mismatches", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects message status subscriptions when clientId does not match", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-2", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects message status subscriptions when status does not match", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "FAILED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects message status subscriptions when status has not changed", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - previousMessageStatus: "DELIVERED", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("matches message status subscriptions when status has changed", () => { - const notifyData: MessageStatusData = { - messageId: "message-id", - messageReference: "reference", - messageStatus: "DELIVERED", - previousMessageStatus: "PENDING_ENRICHMENT", - channels: [], - timestamp: "2025-01-01T10:00:00Z", - routingPlan: { - id: "plan-id", - name: "plan-name", - version: "1", - createdDate: "2025-01-01T10:00:00Z", - }, - clientId: "client-1", - }; - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-message", - ClientId: "client-1", - Description: "Message config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], - }, - ]; - - expect( - matchesMessageStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects channel status subscriptions when clientId does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - supplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-2", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when channelStatus does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "FAILED", - previousChannelStatus: "SENDING", - supplierStatus: "read", - previousSupplierStatus: "read", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when supplierStatus does not match", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "DELIVERED", - supplierStatus: "rejected", - previousSupplierStatus: "notified", - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("rejects channel status subscriptions when neither status changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "DELIVERED", // No change - supplierStatus: "read", - previousSupplierStatus: "read", // No change - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); - - it("matches when only channelStatus changed and is subscribed (OR logic)", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "notified", - previousSupplierStatus: "notified", // No change - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], // Not subscribed to NOTIFIED - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "SENDING", - previousChannelStatus: "SENDING", // No change - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("matches with empty supplierStatuses array when channelStatus changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("matches with empty channelStatuses array when supplierStatus changed", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: [], // Empty array = not subscribed to any channel status changes - SupplierStatuses: ["read"], - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(true); - }); - - it("rejects with both arrays empty", () => { - const notifyData: ChannelStatusData = { - messageId: "message-id", - messageReference: "reference", - channel: "EMAIL", - channelStatus: "DELIVERED", - previousChannelStatus: "SENDING", // Changed - supplierStatus: "read", - previousSupplierStatus: "notified", // Changed - cascadeType: "primary", - cascadeOrder: 1, - timestamp: "2025-01-01T10:00:00Z", - retryCount: 0, - clientId: "client-1", - }; - - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-a", - notifyData, - ); - - const config: ClientSubscriptionConfiguration = [ - { - Name: "client-1-email", - ClientId: "client-1", - Description: "Channel config", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), - Targets: [ - { - Type: "API", - TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: [], // Empty - SupplierStatuses: [], // Empty - }, - ]; - - expect( - matchesChannelStatusSubscription(config, { event, notifyData }), - ).toBe(false); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index 1ded580..9b7257f 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -12,6 +12,15 @@ import { EventTypes } from "@nhs-notify-client-callbacks/models"; import { TransformationError } from "services/error-handler"; import { evaluateSubscriptionFilters } from "services/subscription-filter"; +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + const createMessageStatusEvent = ( clientId: string, status: MessageStatus, diff --git a/package-lock.json b/package-lock.json index 8a324f5..9458699 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4408,7 +4408,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, From 5a2157f1d75750bd71e37646ac6dd1cf3493615c Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 09:52:22 +0000 Subject: [PATCH 08/16] CCM-14203 - PR feedback --- package-lock.json | 886 +++++++++++------- scripts/deploy_client_subscriptions.sh | 136 --- tests/integration/helpers/aws-helpers.ts | 33 +- .../integration/infrastructure-exists.test.ts | 11 +- .../client-subscriptions-management/README.md | 88 +- .../package.json | 3 + .../src/__tests__/container-s3-config.test.ts | 6 +- .../src/__tests__/helper.test.ts | 63 +- .../src/container.ts | 8 +- .../src/entrypoint/cli/deploy.ts | 318 +++++++ .../cli/get-client-subscriptions.ts | 27 +- .../src/entrypoint/cli/helper.ts | 50 +- .../src/entrypoint/cli/put-channel-status.ts | 38 +- .../src/entrypoint/cli/put-message-status.ts | 36 +- 14 files changed, 1180 insertions(+), 523 deletions(-) delete mode 100644 scripts/deploy_client_subscriptions.sh create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts diff --git a/package-lock.json b/package-lock.json index 9458699..cafde51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -359,6 +359,72 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1004.0.tgz", + "integrity": "sha512-iRFVMN0Rlh9tjEuz1c6eQnv9EiYH0uxIvobsn5IvOjsM0PdfsKpGdRKiQIA/OgmpTPfuYyySwaRRtDFH9TMlQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-eventbridge": { "version": "3.1001.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.1001.0.tgz", @@ -637,23 +703,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.16.tgz", - "integrity": "sha512-Nasoyb5K4jfvncTKQyA13q55xHoz9as01NVYP05B0Kzux/X5UhMn3qXsZDyWOSXkfSCAIrMBKmVVWbI0vUapdQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.9", - "@smithy/core": "^3.23.7", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.18.tgz", + "integrity": "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.8", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -671,15 +737,15 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.14.tgz", - "integrity": "sha512-PvnBY9rwBuLh9MEsAng28DG+WKl+txerKgf4BU9IPAqYI7FBIo1x6q/utLf4KLyQYgSy1TLQnbQuXx5xfBGASg==", + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.10.tgz", + "integrity": "sha512-R7saD8TvU6En8tFstAgbM9w6wlFxTwXrvMEpheVdGyDMKSxK412aRy87VNb2Mc2By0vL58OIE487afpxOc/rVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -687,21 +753,37 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-http": { + "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.16.tgz", - "integrity": "sha512-m/QAcvw5OahqGPjeAnKtgfWgjLxeWOYj7JSmxKK6PLyKp2S/t2TAHI6EELEzXnIz28RMgbQLukJkVAqPASVAGQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.16.tgz", + "integrity": "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/node-http-handler": "^4.4.13", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.16", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.18.tgz", + "integrity": "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -709,23 +791,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.14.tgz", - "integrity": "sha512-EGA7ufqNpZKZcD0RwM6gRDEQgwAf19wQ99R1ptdWYDJAnpcMcWiFyT0RIrgiZFLD28CwJmYjnra75hChnEveWA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/credential-provider-env": "^3.972.14", - "@aws-sdk/credential-provider-http": "^3.972.16", - "@aws-sdk/credential-provider-login": "^3.972.14", - "@aws-sdk/credential-provider-process": "^3.972.14", - "@aws-sdk/credential-provider-sso": "^3.972.14", - "@aws-sdk/credential-provider-web-identity": "^3.972.14", - "@aws-sdk/nested-clients": "^3.996.4", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.17.tgz", + "integrity": "sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-login": "^3.972.17", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -734,17 +816,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.14.tgz", - "integrity": "sha512-P2kujQHAoV7irCTv6EGyReKFofkHCjIK+F0ZYf5UxeLeecrCwtrDkHoO2Vjsv/eRUumaKblD8czuk3CLlzwGDw==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.17.tgz", + "integrity": "sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/nested-clients": "^3.996.4", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -753,21 +835,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.15.tgz", - "integrity": "sha512-59NBJgTcQ2FC94T+SWkN5UQgViFtrLnkswSKhG5xbjPAotOXnkEF2Bf0bfUV1F3VaXzqAPZJoZ3bpg4rr8XD5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.14", - "@aws-sdk/credential-provider-http": "^3.972.16", - "@aws-sdk/credential-provider-ini": "^3.972.14", - "@aws-sdk/credential-provider-process": "^3.972.14", - "@aws-sdk/credential-provider-sso": "^3.972.14", - "@aws-sdk/credential-provider-web-identity": "^3.972.14", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.18.tgz", + "integrity": "sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-ini": "^3.972.17", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -776,15 +858,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.14.tgz", - "integrity": "sha512-KAF5LBkJInUPaR9dJDw8LqmbPDRTLyXyRoWVGcJQ+DcN9rxVKBRzAK+O4dTIvQtQ7xaIDZ2kY7zUmDlz6CCXdw==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.16.tgz", + "integrity": "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -793,17 +875,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.14.tgz", - "integrity": "sha512-LQzIYrNABnZzkyuIguFa3VVOox9UxPpRW6PL+QYtRHaGl1Ux/+Zi54tAVK31VdeBKPKU3cxqeu8dbOgNqy+naw==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.17.tgz", + "integrity": "sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/nested-clients": "^3.996.4", - "@aws-sdk/token-providers": "3.1001.0", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/token-providers": "3.1004.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -812,16 +894,47 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.14.tgz", - "integrity": "sha512-rOwB3vXHHHnGvAOjTgQETxVAsWjgF61XlbGd/ulvYo7EpdXs8cbIHE3PGih9tTj/65ZOegSqZGFqLaKntaI9Kw==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.17.tgz", + "integrity": "sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/nested-clients": "^3.996.4", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1004.0.tgz", + "integrity": "sha512-THsua88i7DrPoO8WCIWLPWb8706s2ytl2ej+WB9sv39VPCJNc7YwGtTA51reziyzlLnJUGHkI+krp0oTHEGaBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1004.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.10", + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-ini": "^3.972.17", + "@aws-sdk/credential-provider-login": "^3.972.17", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -882,11 +995,13 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.6", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -907,10 +1022,12 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.6", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -919,12 +1036,14 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.6", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -986,17 +1105,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.16.tgz", - "integrity": "sha512-AmVxtxn8ZkNJbuPu3KKfW9IkJgTgcEtgSwbo0NVcAb31iGvLgHXj2nbbyrUDfh2fx8otXmqL+qw1lRaTi+V3vA==", + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.19.tgz", + "integrity": "sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.7", - "@smithy/protocol-http": "^5.3.10", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.8", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -1004,13 +1124,15 @@ } }, "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" }, "engines": { @@ -1018,48 +1140,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.4.tgz", - "integrity": "sha512-NowB1HfOnWC4kwZOnTg8E8rSL0U+RSjSa++UtEV4ipoH6JOjMLnHyGilqwl+Pe1f0Al6v9yMkSJ/8Ot0f578CQ==", + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.7.tgz", + "integrity": "sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.1", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.7", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.21", - "@smithy/middleware-retry": "^4.4.38", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.13", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.37", - "@smithy/util-defaults-mode-node": "^4.2.40", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1067,15 +1189,15 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" }, "engines": { @@ -1083,12 +1205,14 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.6", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/config-resolver": "^4.4.9", - "@smithy/node-config-provider": "^4.3.10", + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1114,16 +1238,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1001.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1001.0.tgz", - "integrity": "sha512-09XAq/uIYgeZhohuGRrR/R+ek3+ljFNdzWCXdqb9rlIERDjSfNiLjTtpHgSK1xTPmC5G4yWoEAyMfTXiggS6wA==", + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1004.0.tgz", + "integrity": "sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.16", - "@aws-sdk/nested-clients": "^3.996.4", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1132,7 +1256,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.4", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -1178,24 +1304,26 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.6", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.1.tgz", - "integrity": "sha512-kmgbDqT7aCBEVrqESM2JUjbf0zhDUQ7wnt3q1RuVS+3mglrcfVb2bwkbmf38npOyyPGtQPV5dWN3m+sSFAVAgQ==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.4.tgz", + "integrity": "sha512-uqKeLqZ9D3nQjH7HGIERNXK9qnSpUK08l4MlJ5/NZqSSdeJsVANYp437EM9sEzwU28c2xfj2V6qlkqzsgtKs6Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.16", - "@aws-sdk/types": "^3.973.4", - "@smithy/node-config-provider": "^4.3.10", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1212,9 +1340,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", - "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2469,7 +2597,9 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2501,14 +2631,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.9", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -2516,20 +2648,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.7.tgz", - "integrity": "sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==", + "version": "3.23.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.11", - "@smithy/protocol-http": "^5.3.10", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.16", - "@smithy/util-utf8": "^4.2.1", - "@smithy/uuid": "^1.1.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -2537,13 +2669,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", + "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -2611,15 +2745,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.12.tgz", - "integrity": "sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -2640,12 +2774,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2665,7 +2801,9 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2676,7 +2814,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2698,10 +2838,12 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2710,18 +2852,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.21", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.21.tgz", - "integrity": "sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==", + "version": "4.4.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.7", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-middleware": "^4.2.10", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -2729,19 +2871,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.38", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.38.tgz", - "integrity": "sha512-WdHvdhjE6Fj78vxFwDKFDwlqGOGRUWrwGeuENUbTVE46Su9mnQM+dXHtbnCaQvwuSYrRsjpe8zUsFpwUp/azlA==", + "version": "4.4.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/service-error-classification": "^4.2.10", - "@smithy/smithy-client": "^4.12.1", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/uuid": "^1.1.1", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -2749,10 +2891,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.11", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2761,7 +2905,9 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2772,11 +2918,13 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.10", + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2785,14 +2933,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.13.tgz", - "integrity": "sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2801,7 +2949,9 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2812,7 +2962,9 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.10", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2823,11 +2975,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2835,7 +2989,9 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2846,7 +3002,9 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0" @@ -2856,7 +3014,9 @@ } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.5", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2867,16 +3027,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.10", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/protocol-http": "^5.3.10", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-uri-escape": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2884,17 +3046,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.1.tgz", - "integrity": "sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.7", - "@smithy/middleware-endpoint": "^4.4.21", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.16", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -2912,10 +3074,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.10", + "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2924,11 +3088,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.1", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2936,7 +3102,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2946,7 +3114,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.2", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2956,10 +3126,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2967,7 +3139,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2977,13 +3151,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.37", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.37.tgz", - "integrity": "sha512-JlPZhV1kQCGNJgofRTU6E8kHrjCKsb6cps8gco8QDVaFl7biFYzHg0p1x89ytIWyVyCkY3nOpO8tJPM47Vqlww==", + "version": "4.3.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.1", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2992,16 +3166,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.40", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.40.tgz", - "integrity": "sha512-BM5cPEsyxHdYYO4Da77E94lenhaVPNUzBTyCGDkcw/n/mE8Q1cfHwr+n/w2bNPuUsPC30WaW5/hGKWOTKqw8kw==", + "version": "4.2.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.9", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.1", + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -3010,10 +3184,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.1", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -3022,7 +3198,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3032,7 +3210,9 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -3043,10 +3223,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.10", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.10", + "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -3055,18 +3237,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.16", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.16.tgz", - "integrity": "sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==", + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/node-http-handler": "^4.4.13", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3074,7 +3256,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3084,10 +3268,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.1", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3107,7 +3293,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.1", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -10677,6 +10865,8 @@ "version": "0.0.1", "dependencies": { "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.1004.0", + "@aws-sdk/credential-providers": "^3.1004.0", "@nhs-notify-client-callbacks/models": "*", "yargs": "^17.7.2", "zod": "^4.3.6" @@ -10689,6 +10879,72 @@ "typescript": "^5.8.2" } }, + "tools/client-subscriptions-management/node_modules/@aws-sdk/client-sts": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1004.0.tgz", + "integrity": "sha512-fxTiEmAwj91OtrmhafZtmxrUa4wfT1CmnnV45jZ3NCHSTJhZy0MrtNZShxSnuhbF0i/JfsZdst3oxQGzGcCCmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "tools/client-subscriptions-management/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "tools/client-subscriptions-management/node_modules/@types/node": { "version": "22.19.11", "dev": true, diff --git a/scripts/deploy_client_subscriptions.sh b/scripts/deploy_client_subscriptions.sh deleted file mode 100644 index be8683d..0000000 --- a/scripts/deploy_client_subscriptions.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -usage() { - cat < \ - [--terraform-apply] \ - [--environment --group --project --tf-region ] \ - -- - -Examples: - # Message status subscription - ./scripts/deploy_client_subscriptions.sh \ - --subscription-type message \ - --terraform-apply \ - --environment dev \ - --group dev \ - -- \ - --bucket-name my-bucket \ - --client-name "Test Client" \ - --client-id client-123 \ - --message-statuses DELIVERED FAILED \ - --api-endpoint https://webhook.example.com \ - --api-key 1234.4321 \ - --dry-run false \ - --rate-limit 20 - - # Channel status subscription - ./scripts/deploy_client_subscriptions.sh \ - --subscription-type channel \ - --terraform-apply \ - --environment dev \ - --group dev \ - -- \ - --bucket-name my-bucket \ - --client-name "Test Client" \ - --client-id client-123 \ - --channel-type NHSAPP \ - --channel-statuses DELIVERED FAILED \ - --supplier-statuses delivered failed \ - --api-endpoint https://webhook.example.com \ - --api-key 1234.4321 \ - --dry-run false \ - --rate-limit 20 -EOF -} - -subscription_type="" -terraform_apply="false" -environment="" -group="" -project="nhs" -tf_region="" -forward_args=() - -while [ "$#" -gt 0 ]; do - case "$1" in - --subscription-type) - subscription_type="$2" - shift 2 - ;; - --terraform-apply) - terraform_apply="true" - shift - ;; - --environment) - environment="$2" - shift 2 - ;; - --group) - group="$2" - shift 2 - ;; - --project) - project="$2" - shift 2 - ;; - --tf-region) - tf_region="$2" - shift 2 - ;; - --help) - usage - exit 0 - ;; - --) - shift - forward_args+=("$@") - break - ;; - *) - forward_args+=("$1") - shift - ;; - esac -done - -if [ -z "$subscription_type" ]; then - echo "Error: --subscription-type is required" - usage - exit 1 -fi - -if [ "$subscription_type" != "message" ] && [ "$subscription_type" != "channel" ]; then - echo "Error: --subscription-type must be 'message' or 'channel'" - usage - exit 1 -fi - -repo_root="$(git rev-parse --show-toplevel)" -cd "$repo_root" - -echo "[deploy-client-subscriptions] Uploading subscription config ($subscription_type)..." - -if [ "$subscription_type" = "message" ]; then - npm --workspace tools/client-subscriptions-management run put-message-status -- "${forward_args[@]}" -else - npm --workspace tools/client-subscriptions-management run put-channel-status -- "${forward_args[@]}" -fi - -if [ "$terraform_apply" = "true" ]; then - if [ -z "$environment" ] || [ -z "$group" ]; then - echo "Error: --environment and --group are required for terraform apply" - exit 1 - fi - - echo "[deploy-client-subscriptions] Running terraform apply for callbacks component..." - if [ -n "$tf_region" ]; then - make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" region="$tf_region" - else - make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" - fi -fi diff --git a/tests/integration/helpers/aws-helpers.ts b/tests/integration/helpers/aws-helpers.ts index 75d8f9c..c4c2ebe 100644 --- a/tests/integration/helpers/aws-helpers.ts +++ b/tests/integration/helpers/aws-helpers.ts @@ -3,50 +3,37 @@ import { S3Client } from "@aws-sdk/client-s3"; export type DeploymentDetails = { region: string; environment: string; - project: string; - component: string; accountId: string; }; /** * Reads deployment context from environment variables * - * Requires: AWS_REGION, PR_NUMBER, PROJECT, COMPONENT, AWS_ACCOUNT_ID + * Requires: AWS_REGION, ENVIRONMENT, AWS_ACCOUNT_ID */ export function getDeploymentDetails(): DeploymentDetails { const region = process.env.AWS_REGION ?? "eu-west-2"; - const environment = process.env.PR_NUMBER; - const project = process.env.PROJECT; - const component = process.env.COMPONENT; + const environment = process.env.ENVIRONMENT; const accountId = process.env.AWS_ACCOUNT_ID; if (!environment) { - throw new Error("PR_NUMBER environment variable must be set"); - } - if (!project) { - throw new Error("PROJECT environment variable must be set"); - } - if (!component) { - throw new Error("COMPONENT environment variable must be set"); + throw new Error("ENVIRONMENT environment variable must be set"); } if (!accountId) { throw new Error("AWS_ACCOUNT_ID environment variable must be set"); } - return { region, environment, project, component, accountId }; + return { region, environment, accountId }; } /** - * Builds the subscription config S3 bucket name from deployment details. + * Builds an S3 bucket name from deployment details and a bucket-specific suffix. */ -export function buildSubscriptionConfigBucketName({ - accountId, - component, - environment, - project, - region, -}: DeploymentDetails): string { - return `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; +export function buildBucketName( + { accountId, environment, region }: DeploymentDetails, + suffix: string, +): string { + return `nhs-${accountId}-${region}-${environment}-${suffix}`; } /** diff --git a/tests/integration/infrastructure-exists.test.ts b/tests/integration/infrastructure-exists.test.ts index 62a8186..bf53f2c 100644 --- a/tests/integration/infrastructure-exists.test.ts +++ b/tests/integration/infrastructure-exists.test.ts @@ -1,10 +1,6 @@ import { HeadBucketCommand } from "@aws-sdk/client-s3"; import type { S3Client } from "@aws-sdk/client-s3"; -import { - buildSubscriptionConfigBucketName, - createS3Client, - getDeploymentDetails, -} from "helpers"; +import { buildBucketName, createS3Client, getDeploymentDetails } from "helpers"; describe("Infrastructure exists", () => { let s3Client: S3Client; @@ -12,7 +8,10 @@ describe("Infrastructure exists", () => { beforeAll(async () => { const deploymentDetails = getDeploymentDetails(); - bucketName = buildSubscriptionConfigBucketName(deploymentDetails); + bucketName = buildBucketName( + deploymentDetails, + "callbacks-subscription-config", + ); s3Client = createS3Client(); }); diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md index ef13a69..3cf258a 100644 --- a/tools/client-subscriptions-management/README.md +++ b/tools/client-subscriptions-management/README.md @@ -7,58 +7,116 @@ TypeScript CLI utility for managing NHS Notify client subscription configuration From the repository root run: ```bash -npm --workspace tools/client-subscriptions-management [options] +npm --workspace tools/client-subscriptions-management run -- [options] ``` -Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable. +Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `deploy`, `put-message-status`, and `put-channel-status` commands. -Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `put-message-status` and `put-channel-status` commands. +## Example + +Deploy a message status subscription to the `dev` environment using a named AWS profile: + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- message \ + --environment dev \ + --profile my-profile \ + --client-id my-client \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.invalid/callbacks \ + --api-key 1234.4321 \ + --rate-limit 20 \ + --dry-run false \ + --terraform-apply \ + --group dev +``` ## Commands +### Deploy a Subscription (upload config + optionally apply terraform) + +Use `deploy` to upload a subscription config to S3 and optionally trigger a terraform apply in one step. + +#### Message status + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- message \ + --environment dev \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.invalid \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 \ + --terraform-apply \ + --group dev +``` + +#### Channel status + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- channel \ + --environment dev \ + --client-id client-123 \ + --channel-type EMAIL \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses READ REJECTED \ + --api-endpoint https://webhook.example.invalid \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 \ + --terraform-apply \ + --group dev +``` + +Optional for both: `--client-name "Test Client"` (defaults to client-id if not provided), `--project ` (defaults to `nhs`), `--region ` (defaults to `eu-west-2`), `--profile `, `--tf-region `, `--bucket-name ` (override derived bucket name) + +**Note (channel)**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided. + ### Get Client Subscriptions By Client ID ```bash -npm --workspace tools/client-subscriptions-management get-by-client-id \ - --bucket-name my-bucket \ +npm --workspace tools/client-subscriptions-management run get-by-client-id -- \ + --environment dev \ --client-id client-123 ``` -### Put Message Status Subscription +### Put Message Status Subscription (S3 upload only) ```bash -npm --workspace tools/client-subscriptions-management put-message-status \ - --bucket-name my-bucket \ +npm --workspace tools/client-subscriptions-management run put-message-status -- \ + --environment dev \ --client-id client-123 \ --message-statuses DELIVERED FAILED \ - --api-endpoint https://webhook.example.com \ + --api-endpoint https://webhook.example.invalid \ --api-key-header-name x-api-key \ --api-key 1234.4321 \ --dry-run false \ --rate-limit 20 ``` -Optional: `--client-name "Test Client"` (defaults to client-id if not provided) +Optional: `--client-name "Test Client"` (defaults to client-id if not provided), `--profile `, `--bucket-name ` Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable -### Put Channel Status Subscription +### Put Channel Status Subscription (S3 upload only) ```bash -npm --workspace tools/client-subscriptions-management put-channel-status \ - --bucket-name my-bucket \ +npm --workspace tools/client-subscriptions-management run put-channel-status -- \ + --environment dev \ --client-id client-123 \ --channel-type EMAIL \ --channel-statuses DELIVERED FAILED \ --supplier-statuses READ REJECTED \ - --api-endpoint https://webhook.example.com \ + --api-endpoint https://webhook.example.invalid \ --api-key-header-name x-api-key \ --api-key 1234.4321 \ --dry-run false \ --rate-limit 20 ``` -Optional: `--client-name "Test Client"` (defaults to client-id if not provided) +Optional: `--client-name "Test Client"` (defaults to client-id if not provided), `--profile `, `--bucket-name ` Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index 33540bd..9b57ca8 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -7,6 +7,7 @@ "get-by-client-id": "tsx ./src/entrypoint/cli/get-client-subscriptions.ts", "put-channel-status": "tsx ./src/entrypoint/cli/put-channel-status.ts", "put-message-status": "tsx ./src/entrypoint/cli/put-message-status.ts", + "deploy": "tsx ./src/entrypoint/cli/deploy.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", @@ -14,6 +15,8 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.1004.0", + "@aws-sdk/credential-providers": "^3.1004.0", "@nhs-notify-client-callbacks/models": "*", "yargs": "^17.7.2", "zod": "^4.3.6" diff --git a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts index ec19865..47c1ddc 100644 --- a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -3,7 +3,7 @@ import { createS3Client } from "src/container"; describe("createS3Client", () => { it("sets forcePathStyle=true when endpoint contains localhost", () => { const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; - const client = createS3Client("eu-west-2", env); + const client = createS3Client("eu-west-2", undefined, env); // Access the config through the client's config property const { config } = client as any; @@ -13,7 +13,7 @@ describe("createS3Client", () => { it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; - const client = createS3Client("eu-west-2", env); + const client = createS3Client("eu-west-2", undefined, env); const { config } = client as any; expect(config.endpoint).toBeDefined(); @@ -23,7 +23,7 @@ describe("createS3Client", () => { it("does not set forcePathStyle=true when endpoint is not set", () => { const env = {}; - const client = createS3Client("eu-west-2", env); + const client = createS3Client("eu-west-2", undefined, env); const { config } = client as any; // S3Client converts undefined to false, so we just check it's not true diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts index b7f9145..493cc2d 100644 --- a/tools/client-subscriptions-management/src/__tests__/helper.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts @@ -4,13 +4,22 @@ import type { MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import { + deriveBucketName, formatSubscriptionFileResponse, normalizeClientName, resolveBucketName, resolveEventSource, + resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; +jest.mock("@aws-sdk/client-sts", () => ({ + STSClient: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockResolvedValue({ Account: "123456789012" }), + })), + GetCallerIdentityCommand: jest.fn(), +})); + describe("cli helper", () => { const messageSubscription: MessageStatusSubscriptionConfiguration = { Name: "client-a", @@ -110,22 +119,56 @@ describe("cli helper", () => { expect(normalizeClientName("My Client Name")).toBe("my-client-name"); }); - it("resolves bucket name from argument", () => { - expect(resolveBucketName("bucket-1")).toBe("bucket-1"); + it("resolves bucket name from explicit argument", async () => { + await expect(resolveBucketName("bucket-1")).resolves.toBe("bucket-1"); + }); + + it("derives bucket name from environment using STS account ID", async () => { + await expect( + resolveBucketName(undefined, "dev", "eu-west-2"), + ).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("uses default region eu-west-2 when region is not provided", async () => { + await expect(resolveBucketName(undefined, "dev")).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("throws when neither bucket name nor environment provided", async () => { + await expect(resolveBucketName()).rejects.toThrow( + "Bucket name is required: use --bucket-name to specify directly, or --environment", + ); }); - it("resolves bucket name from env", () => { + it("derives bucket name correctly", () => { + expect(deriveBucketName("123456789012", "dev", "eu-west-2")).toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("derives bucket name with custom project and component", () => { expect( - resolveBucketName(undefined, { - CLIENT_SUBSCRIPTION_BUCKET_NAME: "bucket-env", + deriveBucketName("123456789012", "prod", "eu-west-2", "myproj", "mycomp"), + ).toBe("myproj-123456789012-eu-west-2-prod-mycomp-subscription-config"); + }); + + it("resolves profile from argument", () => { + expect(resolveProfile("my-profile")).toBe("my-profile"); + }); + + it("resolves profile from AWS_PROFILE env", () => { + expect( + resolveProfile(undefined, { + AWS_PROFILE: "env-profile", } as NodeJS.ProcessEnv), - ).toBe("bucket-env"); + ).toBe("env-profile"); }); - it("throws when bucket name is missing", () => { - expect(() => resolveBucketName(undefined, {} as NodeJS.ProcessEnv)).toThrow( - "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", - ); + it("returns undefined when profile is not set", () => { + expect(resolveProfile(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); }); it("resolves event source from argument", () => { diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts index 93488be..3274e98 100644 --- a/tools/client-subscriptions-management/src/container.ts +++ b/tools/client-subscriptions-management/src/container.ts @@ -1,4 +1,5 @@ import { S3Client } from "@aws-sdk/client-s3"; +import { fromIni } from "@aws-sdk/credential-providers"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import { S3Repository } from "src/repository/s3"; import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; @@ -6,16 +7,19 @@ import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscr type RepositoryOptions = { bucketName: string; region?: string; + profile?: string; eventSource?: string; }; export const createS3Client = ( region?: string, + profile?: string, env: NodeJS.ProcessEnv = process.env, ): S3Client => { const endpoint = env.AWS_ENDPOINT_URL; const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; - return new S3Client({ region, endpoint, forcePathStyle }); + const credentials = profile ? fromIni({ profile }) : undefined; + return new S3Client({ region, endpoint, forcePathStyle, credentials }); }; export const createClientSubscriptionRepository = ( @@ -23,7 +27,7 @@ export const createClientSubscriptionRepository = ( ): ClientSubscriptionRepository => { const s3Repository = new S3Repository( options.bucketName, - createS3Client(options.region), + createS3Client(options.region, options.profile), ); const configurationBuilder = new ClientSubscriptionConfigurationBuilder( options.eventSource, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts new file mode 100644 index 0000000..fb281a5 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts @@ -0,0 +1,318 @@ +import { spawnSync } from "node:child_process"; +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveEventSource, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +const sharedOptions = { + "bucket-name": { + type: "string" as const, + demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + "client-name": { + type: "string" as const, + demandOption: false, + description: "Display name for the client (defaults to client-id)", + }, + "client-id": { + type: "string" as const, + demandOption: true, + description: "Client identifier", + }, + "api-endpoint": { + type: "string" as const, + demandOption: true, + description: "Webhook endpoint URL (must start with https://)", + }, + "api-key": { + type: "string" as const, + demandOption: true, + description: "API key value for authenticating webhook calls", + }, + "api-key-header-name": { + type: "string" as const, + default: "x-api-key", + demandOption: false, + description: "HTTP header name for the API key", + }, + "rate-limit": { + type: "number" as const, + demandOption: true, + description: "Maximum number of webhook calls per second", + }, + "dry-run": { + type: "boolean" as const, + demandOption: true, + description: "Validate config without writing to S3", + }, + region: { + type: "string" as const, + demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + "event-source": { + type: "string" as const, + demandOption: false, + description: + "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", + }, + "terraform-apply": { + type: "boolean" as const, + default: false, + demandOption: false, + description: "Run terraform apply after uploading config", + }, + environment: { + type: "string" as const, + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + group: { + type: "string" as const, + demandOption: false, + description: "Group name (required when --terraform-apply is set)", + }, + project: { + type: "string" as const, + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + "tf-region": { + type: "string" as const, + demandOption: false, + description: "AWS region override for terraform", + }, + profile: { + type: "string" as const, + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, +} as const; + +function runTerraformApply(argv: { + environment?: string; + group?: string; + project?: string; + "tf-region"?: string; +}) { + const { environment, group, project = "nhs", "tf-region": tfRegion } = argv; + if (!environment || !group) { + console.error( + "Error: --environment and --group are required when --terraform-apply is set", + ); + process.exitCode = 1; + return false; + } + + console.log( + "[deploy-client-subscriptions] Running terraform apply for callbacks component...", + ); + + const makeArgs = [ + "terraform-apply", + `component=callbacks`, + `environment=${environment}`, + `group=${group}`, + `project=${project}`, + ]; + if (tfRegion) { + makeArgs.push(`region=${tfRegion}`); + } + + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("make", makeArgs, { stdio: "inherit" }); + if (result.status !== 0) { + console.error( + `Error: terraform apply failed with exit code ${result.status}`, + ); + process.exitCode = result.status ?? 1; + return false; + } + return true; +} + +export async function main(args: string[] = process.argv) { + await yargs(hideBin(args)) + .command( + "message", + "Deploy a message status subscription", + { + ...sharedOptions, + "message-statuses": { + string: true, + type: "array" as const, + demandOption: true, + choices: MESSAGE_STATUSES, + }, + }, + async (argv) => { + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + console.log( + "[deploy-client-subscriptions] Uploading message status subscription config...", + ); + + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const eventSource = resolveEventSource(argv["event-source"]); + const clientSubscriptionRepository = createClientSubscriptionRepository( + { + bucketName, + region, + profile, + eventSource, + }, + ); + + const result = + await clientSubscriptionRepository.putMessageStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + statuses: argv["message-statuses"], + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource, + }); + + console.log(formatSubscriptionFileResponse(result)); + + if (argv["terraform-apply"]) { + runTerraformApply(argv); + } + }, + ) + .command( + "channel", + "Deploy a channel status subscription", + { + ...sharedOptions, + "channel-type": { + type: "string" as const, + demandOption: true, + choices: CHANNEL_TYPES, + }, + "channel-statuses": { + string: true, + type: "array" as const, + demandOption: false, + choices: CHANNEL_STATUSES, + }, + "supplier-statuses": { + string: true, + type: "array" as const, + demandOption: false, + choices: SUPPLIER_STATUSES, + }, + }, + async (argv) => { + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const channelStatuses = argv["channel-statuses"]; + const supplierStatuses = argv["supplier-statuses"]; + if (!channelStatuses?.length && !supplierStatuses?.length) { + console.error( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + process.exitCode = 1; + return; + } + + console.log( + "[deploy-client-subscriptions] Uploading channel status subscription config...", + ); + + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const eventSource = resolveEventSource(argv["event-source"]); + const clientSubscriptionRepository = createClientSubscriptionRepository( + { + bucketName, + region, + profile, + eventSource, + }, + ); + + const result = + await clientSubscriptionRepository.putChannelStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + channelType: argv["channel-type"], + channelStatuses, + supplierStatuses, + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource, + }); + + console.log(formatSubscriptionFileResponse(result)); + + if (argv["terraform-apply"]) { + runTerraformApply(argv); + } + }, + ) + .demandCommand(1, "Please specify a command: message or channel") + .strict() + .parseAsync(); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +}; + +(async () => { + if (require.main === module) { + await runCli(); + } +})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts index 5ec837a..f9ce855 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -4,6 +4,7 @@ import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, + resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -13,24 +14,46 @@ export const parseArgs = (args: string[]) => "bucket-name": { type: "string", demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", }, "client-id": { type: "string", demandOption: true, + description: "Client identifier", }, region: { type: "string", demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", }, }) .parseSync(); export async function main(args: string[] = process.argv) { const argv = parseArgs(args); - const bucketName = resolveBucketName(argv["bucket-name"]); + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, - region: resolveRegion(argv.region), + region, + profile, }); const result = await clientSubscriptionRepository.getClientSubscriptions( diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index e3cdf8e..b9ccec9 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,3 +1,5 @@ +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; +import { fromIni } from "@aws-sdk/credential-providers"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; export const formatSubscriptionFileResponse = ( @@ -26,17 +28,51 @@ export const formatSubscriptionFileResponse = ( export const normalizeClientName = (name: string): string => name.replaceAll(/\s+/g, "-").toLowerCase(); -export const resolveBucketName = ( - bucketArg?: string, +export const resolveProfile = ( + profileArg?: string, env: NodeJS.ProcessEnv = process.env, -): string => { - const bucketName = bucketArg ?? env.CLIENT_SUBSCRIPTION_BUCKET_NAME; - if (!bucketName) { +): string | undefined => profileArg ?? env.AWS_PROFILE; + +export const resolveAccountId = async ( + profile?: string, + region?: string, +): Promise => { + const credentials = profile ? fromIni({ profile }) : undefined; + const client = new STSClient({ region, credentials }); + const { Account } = await client.send(new GetCallerIdentityCommand({})); + if (!Account) { + throw new Error("Unable to determine AWS account ID from STS"); + } + return Account; +}; + +export const deriveBucketName = ( + accountId: string, + environment: string, + region: string, + project = "nhs", + component = "callbacks", +): string => + `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; + +export const resolveBucketName = async ( + bucketArg?: string, + environment?: string, + region?: string, + profile?: string, + project?: string, +): Promise => { + if (bucketArg) { + return bucketArg; + } + if (!environment) { throw new Error( - "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", + "Bucket name is required: use --bucket-name to specify directly, or --environment (with --region and optionally --profile) to determine this automatically", ); } - return bucketName; + const resolvedRegion = region ?? "eu-west-2"; + const accountId = await resolveAccountId(profile, resolvedRegion); + return deriveBucketName(accountId, environment, resolvedRegion, project); }; export const resolveEventSource = ( diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts index d4472e7..cb86540 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -10,6 +10,7 @@ import { formatSubscriptionFileResponse, resolveBucketName, resolveEventSource, + resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -19,60 +20,85 @@ export const parseArgs = (args: string[]) => "bucket-name": { type: "string", demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", }, "client-name": { type: "string", demandOption: false, + description: "Display name for the client (defaults to client-id)", }, "client-id": { type: "string", demandOption: true, + description: "Client identifier", }, "api-endpoint": { type: "string", demandOption: true, + description: "Webhook endpoint URL (must start with https://)", }, "api-key-header-name": { type: "string", default: "x-api-key", demandOption: false, + description: "HTTP header name for the API key", }, "api-key": { type: "string", demandOption: true, + description: "API key value for authenticating webhook calls", }, "channel-statuses": { string: true, type: "array", demandOption: false, choices: CHANNEL_STATUSES, + description: "Channel statuses to subscribe to", }, "supplier-statuses": { string: true, type: "array", demandOption: false, choices: SUPPLIER_STATUSES, + description: "Supplier statuses to subscribe to", }, "channel-type": { type: "string", demandOption: true, choices: CHANNEL_TYPES, + description: "Channel type", }, "rate-limit": { type: "number", demandOption: true, + description: "Maximum number of webhook calls per second", }, "dry-run": { type: "boolean", demandOption: true, + description: "Validate config without writing to S3", }, region: { type: "string", demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", }, "event-source": { type: "string", demandOption: false, + description: + "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", }, }) .parseSync(); @@ -96,11 +122,19 @@ export async function main(args: string[] = process.argv) { return; } - const bucketName = resolveBucketName(argv["bucket-name"]); + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, - region: resolveRegion(argv.region), + region, + profile, eventSource, }); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts index 5e12aa1..40591e6 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -6,6 +6,7 @@ import { formatSubscriptionFileResponse, resolveBucketName, resolveEventSource, + resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -15,49 +16,72 @@ export const parseArgs = (args: string[]) => "bucket-name": { type: "string", demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", }, "client-name": { type: "string", demandOption: false, + description: "Display name for the client (defaults to client-id)", }, "client-id": { type: "string", demandOption: true, + description: "Client identifier", }, "api-endpoint": { type: "string", demandOption: true, + description: "Webhook endpoint URL (must start with https://)", }, "api-key": { type: "string", demandOption: true, + description: "API key value for authenticating webhook calls", }, "api-key-header-name": { type: "string", default: "x-api-key", demandOption: false, + description: "HTTP header name for the API key", }, "message-statuses": { string: true, type: "array", demandOption: true, choices: MESSAGE_STATUSES, + description: "Message statuses to subscribe to", }, "rate-limit": { type: "number", demandOption: true, + description: "Maximum number of webhook calls per second", }, "dry-run": { type: "boolean", demandOption: true, + description: "Validate config without writing to S3", }, region: { type: "string", demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", }, "event-source": { type: "string", demandOption: false, + description: + "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", }, }) .parseSync(); @@ -71,11 +95,19 @@ export async function main(args: string[] = process.argv) { return; } - const bucketName = resolveBucketName(argv["bucket-name"]); + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, - region: resolveRegion(argv.region), + region, + profile, eventSource, }); From 72a3a192802266710e3e0b071399de34d403cc2a Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 09:56:18 +0000 Subject: [PATCH 09/16] CCM-14203 - PR feedback --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5fc917e..e254dc1 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "pretty-format": { "react-is": "19.0.0" }, - "minimatch@^3.0.0": "3.1.5", - "minimatch": "10.2.4" + "minimatch@^3.0.0": "^3.1.5", + "minimatch": "^10.2.4" }, "scripts": { "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", From 931bb32397d221cd14102d812778519aac4700ea Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 13:18:40 +0000 Subject: [PATCH 10/16] CCM-14203 - PR feedback --- .../src/__tests__/index.integration.test.ts | 17 +- .../src/__tests__/index.test.ts | 17 +- .../__tests__/services/config-cache.test.ts | 21 +- .../__tests__/services/config-loader.test.ts | 20 +- .../config-update.integration.test.ts | 40 +--- .../__tests__/services/error-handler.test.ts | 8 +- .../filters/channel-status-filter.test.ts | 33 +-- .../services/filters/event-pattern.test.ts | 100 --------- .../filters/message-status-filter.test.ts | 37 +--- .../services/subscription-filter.test.ts | 35 +--- .../validators/config-validator.test.ts | 67 +----- .../src/handler.ts | 4 + .../services/filters/channel-status-filter.ts | 20 +- .../src/services/filters/event-pattern.ts | 49 ----- .../services/filters/message-status-filter.ts | 23 +- .../src/services/observability.ts | 1 + .../services/validators/config-validator.ts | 63 +----- package-lock.json | 71 +++++++ src/models/src/client-config.ts | 14 +- .../package.json | 1 + .../client-subscription-builder.test.ts | 112 ++-------- .../__tests__/client-subscriptions.test.ts | 27 +-- .../src/__tests__/container.test.ts | 14 +- .../get-client-subscriptions.test.ts | 3 + .../src/__tests__/helper.test.ts | 87 ++------ .../src/__tests__/put-channel-status.test.ts | 12 +- .../src/__tests__/put-message-status.test.ts | 12 +- .../src/container.ts | 9 +- .../src/domain/client-subscription-builder.ts | 196 ++++++++---------- .../src/entrypoint/cli/deploy.ts | 13 -- .../src/entrypoint/cli/helper.ts | 75 ++++--- .../src/entrypoint/cli/put-channel-status.ts | 10 - .../src/entrypoint/cli/put-message-status.ts | 10 - .../src/repository/client-subscriptions.ts | 8 +- 34 files changed, 322 insertions(+), 907 deletions(-) delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts delete mode 100644 lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts index 7d123b5..0b70068 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -53,23 +53,12 @@ const makeSqsRecord = (body: object): SQSRecord => ({ const createValidConfig = (clientId: string) => [ { - Name: `${clientId}-message`, + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Message status subscription", - EventSource: JSON.stringify([]), - EventDetail: JSON.stringify({}), Targets: [ { Type: "API", - TargetId: "SendToWebhook", - Name: `${clientId}-target`, - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, + TargetId: "00000000-0000-4000-8000-000000000001", InvocationEndpoint: "https://example.com/webhook", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -80,7 +69,7 @@ const createValidConfig = (clientId: string) => [ }, ], SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED", "FAILED"], + MessageStatuses: ["DELIVERED", "FAILED"], }, ]; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 4bc389c..3c5a652 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -21,13 +21,10 @@ const createPassthroughConfigLoader = (): ConfigLoader => loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ { SubscriptionType: "MessageStatus", - Name: "unit-test-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Pass-through for unit tests", - EventSource: "[]", - EventDetail: "{}", Targets: [], - Statuses: [ + MessageStatuses: [ "DELIVERED", "FAILED", "PENDING", @@ -38,11 +35,8 @@ const createPassthroughConfigLoader = (): ConfigLoader => }, { SubscriptionType: "ChannelStatus", - Name: "unit-test-nhsapp", + SubscriptionId: "00000000-0000-0000-0000-000000000002", ClientId: clientId, - Description: "Pass-through for unit tests", - EventSource: "[]", - EventDetail: "{}", Targets: [], ChannelType: "NHSAPP", ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], @@ -54,11 +48,8 @@ const createPassthroughConfigLoader = (): ConfigLoader => }, { SubscriptionType: "ChannelStatus", - Name: "unit-test-sms", + SubscriptionId: "00000000-0000-0000-0000-000000000003", ClientId: clientId, - Description: "Pass-through for unit tests", - EventSource: "[]", - EventDetail: "{}", Targets: [], ChannelType: "SMS", ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts index 4341818..cab5893 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -6,14 +6,11 @@ describe("ConfigCache", () => { const cache = new ConfigCache(60_000); const config: ClientSubscriptionConfiguration = [ { - Name: "test", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Test", - EventSource: "[]", - EventDetail: "{}", Targets: [], SubscriptionType: "MessageStatus" as const, - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, ]; @@ -37,14 +34,11 @@ describe("ConfigCache", () => { const cache = new ConfigCache(1000); // 1 second TTL const config: ClientSubscriptionConfiguration = [ { - Name: "test", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Test", - EventSource: "[]", - EventDetail: "{}", Targets: [], SubscriptionType: "MessageStatus" as const, - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, ]; @@ -64,14 +58,11 @@ describe("ConfigCache", () => { const cache = new ConfigCache(60_000); const config: ClientSubscriptionConfiguration = [ { - Name: "test", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Test", - EventSource: "[]", - EventDetail: "{}", Targets: [], SubscriptionType: "MessageStatus" as const, - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, ]; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index 5f61b67..044035d 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -18,26 +18,12 @@ const mockBody = (json: string) => ({ const createValidConfig = (clientId: string) => [ { - Name: `${clientId}-message`, + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", - TargetId: "SendToWebhook", - Name: `${clientId}-target`, - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, + TargetId: "00000000-0000-4000-8000-000000000001", InvocationEndpoint: "https://example.com/webhook", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -48,7 +34,7 @@ const createValidConfig = (clientId: string) => [ }, ], SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, ]; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts index bffc5f8..9f3e9ba 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts @@ -14,26 +14,12 @@ describe("config update integration", () => { transformToString: jest.fn().mockResolvedValue( JSON.stringify([ { - Name: "client-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -44,7 +30,7 @@ describe("config update integration", () => { }, ], SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, ]), ), @@ -55,26 +41,12 @@ describe("config update integration", () => { transformToString: jest.fn().mockResolvedValue( JSON.stringify([ { - Name: "client-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -85,7 +57,7 @@ describe("config update integration", () => { }, ], SubscriptionType: "MessageStatus", - Statuses: ["FAILED"], + MessageStatuses: ["FAILED"], }, ]), ), @@ -103,7 +75,7 @@ describe("config update integration", () => { const firstMessage = first?.find( (subscription) => subscription.SubscriptionType === "MessageStatus", ); - expect(firstMessage?.Statuses).toEqual(["DELIVERED"]); + expect(firstMessage?.MessageStatuses).toEqual(["DELIVERED"]); jest.advanceTimersByTime(1500); @@ -111,7 +83,7 @@ describe("config update integration", () => { const secondMessage = second?.find( (subscription) => subscription.SubscriptionType === "MessageStatus", ); - expect(secondMessage?.Statuses).toEqual(["FAILED"]); + expect(secondMessage?.MessageStatuses).toEqual(["FAILED"]); jest.useRealTimers(); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts index bd4ad4c..b7c1555 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts @@ -159,8 +159,8 @@ describe("ConfigValidationError", () => { it("should create error with issues array", () => { const issues = [ { - path: "[0].Name", - message: "Expected Name to be unique", + path: "[0].SubscriptionId", + message: "Expected SubscriptionId to be unique", }, ]; const error = new ConfigValidationError(issues); @@ -358,8 +358,8 @@ describe("getEventError", () => { it("should return ConfigValidationError and emit validation metric", () => { const error = new ConfigValidationError([ { - path: "[0].Name", - message: "Expected Name to be unique", + path: "[0].SubscriptionId", + message: "Expected SubscriptionId to be unique", }, ]); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts index d7ed9d3..8c6eefa 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts @@ -37,31 +37,15 @@ const createBaseEvent = ( const createChannelStatusConfig = ( channelStatuses: ChannelStatus[], supplierStatuses: SupplierStatus[], - source = "source-a", clientId = "client-1", ): ClientSubscriptionConfiguration => [ { - Name: "client-1-email", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Channel config", - EventSource: JSON.stringify([source]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -125,21 +109,6 @@ describe("matchesChannelStatusSubscription", () => { ).toBe(false); }); - it("rejects when event source mismatches", () => { - const notifyData = createChannelStatusData(); - const event = createBaseEvent( - EventTypes.CHANNEL_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - expect( - matchesChannelStatusSubscription( - createChannelStatusConfig(["DELIVERED"], ["read"]), - { event, notifyData }, - ), - ).toBe(false); - }); - it("rejects when clientId does not match", () => { const notifyData = createChannelStatusData({ clientId: "client-2" }); const event = createBaseEvent( diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts deleted file mode 100644 index be8d9cd..0000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -import { matchesEventPattern } from "services/filters/event-pattern"; - -const createSubscription = ( - eventSource: string[], - eventDetail: Record, -): ClientSubscriptionConfiguration[number] => ({ - Name: "test", - ClientId: "client-1", - Description: "Test subscription", - EventSource: JSON.stringify(eventSource), - EventDetail: JSON.stringify(eventDetail), - Targets: [], - SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], -}); - -describe("matchesEventPattern", () => { - it("matches when source and detail match", () => { - const subscription = createSubscription(["source-a"], { - clientId: ["client-1"], - type: ["MessageStatus"], - }); - - const result = matchesEventPattern(subscription, "source-a", { - clientId: "client-1", - type: "MessageStatus", - }); - - expect(result).toBe(true); - }); - - it("matches when sources list is empty", () => { - const subscription = createSubscription([], { - clientId: ["client-1"], - }); - - const result = matchesEventPattern(subscription, "any-source", { - clientId: "client-1", - }); - - expect(result).toBe(true); - }); - - it("does not match when source is different", () => { - const subscription = createSubscription(["source-a"], { - clientId: ["client-1"], - }); - - const result = matchesEventPattern(subscription, "source-b", { - clientId: "client-1", - }); - - expect(result).toBe(false); - }); - - it("does not match when detail value is different", () => { - const subscription = createSubscription(["source-a"], { - clientId: ["client-1"], - type: ["MessageStatus"], - }); - - const result = matchesEventPattern(subscription, "source-a", { - clientId: "client-1", - type: "ChannelStatus", - }); - - expect(result).toBe(false); - }); - - it("does not match when detail key is missing in event", () => { - const subscription = createSubscription(["source-a"], { - clientId: ["client-1"], - type: ["MessageStatus"], - channel: ["EMAIL"], - }); - - const result = matchesEventPattern(subscription, "source-a", { - clientId: "client-1", - type: "MessageStatus", - // channel is missing - }); - - expect(result).toBe(false); - }); - - it("does not match when detail value is undefined", () => { - const subscription = createSubscription(["source-a"], { - clientId: ["client-1"], - type: ["MessageStatus"], - }); - - const result = matchesEventPattern(subscription, "source-a", { - clientId: "client-1", - type: undefined, - }); - - expect(result).toBe(false); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts index 32a3da3..ee3a070 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts @@ -35,30 +35,15 @@ const createBaseEvent = ( const createMessageStatusConfig = ( statuses: MessageStatus[], - source = "source-a", clientId = "client-1", ): ClientSubscriptionConfiguration => [ { - Name: "client-1-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Message config", - EventSource: JSON.stringify([source]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -69,7 +54,7 @@ const createMessageStatusConfig = ( }, ], SubscriptionType: "MessageStatus", - Statuses: statuses, + MessageStatuses: statuses, }, ]; @@ -110,24 +95,6 @@ describe("matchesMessageStatusSubscription", () => { ).toBe(true); }); - it("rejects when event source mismatches", () => { - const notifyData = createMessageStatusData(); - const event = createBaseEvent( - EventTypes.MESSAGE_STATUS_PUBLISHED, - "source-b", - notifyData, - ); - expect( - matchesMessageStatusSubscription( - createMessageStatusConfig(["DELIVERED"]), - { - event, - notifyData, - }, - ), - ).toBe(false); - }); - it("rejects when clientId does not match", () => { const notifyData = createMessageStatusData({ clientId: "client-2" }); const event = createBaseEvent( diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index 9b7257f..92c227c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -88,26 +88,12 @@ const createMessageStatusConfig = ( statuses: MessageStatus[], ): ClientSubscriptionConfiguration => [ { - Name: "client-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: clientId, - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -118,7 +104,7 @@ const createMessageStatusConfig = ( }, ], SubscriptionType: "MessageStatus", - Statuses: statuses, + MessageStatuses: statuses, }, ]; @@ -129,27 +115,12 @@ const createChannelStatusConfig = ( supplierStatuses: SupplierStatus[], ): ClientSubscriptionConfiguration => [ { - Name: `client-${channelType}`, + SubscriptionId: "00000000-0000-0000-0000-000000000002", ClientId: clientId, - Description: `${channelType} channel status subscription`, - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["ChannelStatus"], - channel: [channelType], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts index 8fb189d..ad4f680 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -6,26 +6,12 @@ import { const createValidConfig = (): ClientSubscriptionConfiguration => [ { - Name: "client-message", + SubscriptionId: "00000000-0000-0000-0000-000000000001", ClientId: "client-1", - Description: "Message status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["MessageStatus"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -36,30 +22,15 @@ const createValidConfig = (): ClientSubscriptionConfiguration => [ }, ], SubscriptionType: "MessageStatus", - Statuses: ["DELIVERED"], + MessageStatuses: ["DELIVERED"], }, { - Name: "client-channel", + SubscriptionId: "00000000-0000-0000-0000-000000000002", ClientId: "client-1", - Description: "Channel status subscription", - EventSource: JSON.stringify(["source-a"]), - EventDetail: JSON.stringify({ - clientId: ["client-1"], - type: ["ChannelStatus"], - channel: ["EMAIL"], - }), Targets: [ { Type: "API", TargetId: "target", - Name: "target", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, InvocationEndpoint: "https://example.com", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -94,37 +65,9 @@ describe("validateClientConfig", () => { expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); }); - it("throws when subscription names are not unique", () => { - const config = createValidConfig(); - config[1].Name = config[0].Name; - - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); - - it("throws when EventSource is invalid JSON", () => { - const config = createValidConfig(); - config[0].EventSource = "not-json"; - - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); - - it("throws when EventSource is valid JSON but not an array", () => { - const config = createValidConfig(); - config[0].EventSource = JSON.stringify({ not: "array" }); - - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); - - it("throws when EventDetail is invalid JSON", () => { - const config = createValidConfig(); - config[0].EventDetail = "not-json"; - - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); - - it("throws when EventDetail is valid JSON but not a record of string arrays", () => { + it("throws when subscription IDs are not unique", () => { const config = createValidConfig(); - config[0].EventDetail = JSON.stringify({ key: "not-array" }); + config[1].SubscriptionId = config[0].SubscriptionId; expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 4e1f20a..cb53fd5 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -158,10 +158,14 @@ async function filterBatch( if (filterResult.matched) { filtered.push(event); + const targetIds = config?.flatMap((s) => + s.Targets.map((t) => t.TargetId), + ); observability.recordFilteringMatched({ clientId, eventType: event.type, subscriptionType: filterResult.subscriptionType, + targetIds, }); } else { stats.recordFiltered(); diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts index 244f57b..23a287f 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -5,7 +5,6 @@ import type { StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; import { logger } from "services/logger"; -import { matchesEventPattern } from "services/filters/event-pattern"; type FilterContext = { event: StatusPublishEvent; @@ -21,7 +20,7 @@ export const matchesChannelStatusSubscription = ( config: ClientSubscriptionConfiguration, context: FilterContext, ): boolean => { - const { event, notifyData } = context; + const { notifyData } = context; const matched = config .filter((sub) => isChannelStatusSubscription(sub)) @@ -75,21 +74,7 @@ export const matchesChannelStatusSubscription = ( return false; } - const patternMatch = matchesEventPattern(subscription, event.source, { - channel: notifyData.channel, - clientId: notifyData.clientId, - type: "ChannelStatus", - }); - - if (!patternMatch) { - logger.debug("Channel status filter rejected: event pattern mismatch", { - clientId: notifyData.clientId, - eventSource: event.source, - subscriptionName: subscription.Name, - }); - } - - return patternMatch; + return true; }); if (matched) { @@ -100,7 +85,6 @@ export const matchesChannelStatusSubscription = ( previousChannelStatus: notifyData.previousChannelStatus, supplierStatus: notifyData.supplierStatus, previousSupplierStatus: notifyData.previousSupplierStatus, - eventSource: event.source, }); } diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts deleted file mode 100644 index bfd906b..0000000 --- a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; - -// Parsed representation of a subscription's EventSource / EventDetail filter criteria. -// Each key in `detail` maps to the list of allowed values for that event attribute. -type SubscriptionFilter = { - sources: string[]; - detail: Record; -}; - -const parseSubscriptionFilter = ( - subscription: ClientSubscriptionConfiguration[number], -): SubscriptionFilter => { - const sources = JSON.parse(subscription.EventSource) as string[]; - const detail = JSON.parse(subscription.EventDetail) as Record< - string, - string[] - >; - return { sources, detail }; -}; - -const matchesEventSource = (sources: string[], source: string): boolean => - sources.length === 0 || sources.includes(source); - -// Checks that every attribute required by the subscription's detail filter is -// present in the event AND has one of the subscription's allowed values. -const matchesEventDetail = ( - allowedDetailValues: Record, - eventDetail: Record, -): boolean => - Object.entries(allowedDetailValues).every(([key, allowedValues]) => { - // eslint-disable-next-line security/detect-object-injection - const eventValue = eventDetail[key]; - if (!eventValue) { - return false; - } - return allowedValues.includes(eventValue); - }); - -export const matchesEventPattern = ( - subscription: ClientSubscriptionConfiguration[number], - eventSource: string, - eventDetail: Record, -): boolean => { - const subscriptionFilter = parseSubscriptionFilter(subscription); - return ( - matchesEventSource(subscriptionFilter.sources, eventSource) && - matchesEventDetail(subscriptionFilter.detail, eventDetail) - ); -}; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts index 6e92f95..e79c33a 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -5,7 +5,6 @@ import type { StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; import { logger } from "services/logger"; -import { matchesEventPattern } from "services/filters/event-pattern"; type FilterContext = { event: StatusPublishEvent; @@ -21,7 +20,7 @@ export const matchesMessageStatusSubscription = ( config: ClientSubscriptionConfiguration, context: FilterContext, ): boolean => { - const { event, notifyData } = context; + const { notifyData } = context; const matched = config .filter((sub) => isMessageStatusSubscription(sub)) @@ -33,7 +32,7 @@ export const matchesMessageStatusSubscription = ( // Check if message status changed AND client is subscribed to it const messageStatusChanged = notifyData.previousMessageStatus !== notifyData.messageStatus; - const clientSubscribedStatus = subscription.Statuses.includes( + const clientSubscribedStatus = subscription.MessageStatuses.includes( notifyData.messageStatus, ); @@ -46,33 +45,19 @@ export const matchesMessageStatusSubscription = ( previousMessageStatus: notifyData.previousMessageStatus, messageStatusChanged, clientSubscribedStatus, - expectedStatuses: subscription.Statuses, + expectedStatuses: subscription.MessageStatuses, }, ); return false; } - const patternMatch = matchesEventPattern(subscription, event.source, { - clientId: notifyData.clientId, - type: "MessageStatus", - }); - - if (!patternMatch) { - logger.debug("Message status filter rejected: event pattern mismatch", { - clientId: notifyData.clientId, - eventSource: event.source, - subscriptionName: subscription.Name, - }); - } - - return patternMatch; + return true; }); if (matched) { logger.debug("Message status filter matched", { clientId: notifyData.clientId, messageStatus: notifyData.messageStatus, - eventSource: event.source, }); } diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index d59850a..e1ee13d 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -48,6 +48,7 @@ export class ObservabilityService { clientId: string; eventType: string; subscriptionType: string; + targetIds?: string[]; }): void { logLifecycleEvent(this.logger, "filtering-matched", context); this.metrics.emitFilteringMatched(); diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index 7b196d6..cf476d5 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -14,45 +14,6 @@ import { export { ConfigValidationError } from "services/error-handler"; -const jsonStringArraySchema = z.array(z.string()); -const jsonRecordSchema = z.record(z.string(), z.array(z.string())); - -const eventSourceSchema = z.string().superRefine((value, ctx) => { - try { - const parsed = JSON.parse(value) as unknown; - const result = jsonStringArraySchema.safeParse(parsed); - if (!result.success) { - ctx.addIssue({ - code: "custom", - message: "Expected JSON array of strings", - }); - } - } catch { - ctx.addIssue({ - code: "custom", - message: "Expected valid JSON array", - }); - } -}); - -const eventDetailSchema = z.string().superRefine((value, ctx) => { - try { - const parsed = JSON.parse(value) as unknown; - const result = jsonRecordSchema.safeParse(parsed); - if (!result.success) { - ctx.addIssue({ - code: "custom", - message: "Expected JSON object of string arrays", - }); - } - } catch { - ctx.addIssue({ - code: "custom", - message: "Expected valid JSON object", - }); - } -}); - const httpsUrlSchema = z.string().refine( (value) => { try { @@ -70,13 +31,6 @@ const httpsUrlSchema = z.string().refine( const targetSchema = z.object({ Type: z.literal("API"), TargetId: z.string(), - Name: z.string(), - InputTransformer: z.object({ - InputPaths: z.string(), - InputHeaders: z.object({ - "x-hmac-sha256-signature": z.string(), - }), - }), InvocationEndpoint: httpsUrlSchema, InvocationMethod: z.literal("POST"), InvocationRateLimit: z.number(), @@ -87,17 +41,14 @@ const targetSchema = z.object({ }); const baseSubscriptionSchema = z.object({ - Name: z.string(), + SubscriptionId: z.string().min(1), ClientId: z.string(), - Description: z.string(), - EventSource: eventSourceSchema, - EventDetail: eventDetailSchema, Targets: z.array(targetSchema).min(1), }); const messageStatusSchema = baseSubscriptionSchema.extend({ SubscriptionType: z.literal("MessageStatus"), - Statuses: z.array(z.enum(MESSAGE_STATUSES)), + MessageStatuses: z.array(z.enum(MESSAGE_STATUSES)), }); const channelStatusSchema = baseSubscriptionSchema.extend({ @@ -113,17 +64,17 @@ const subscriptionSchema = z.discriminatedUnion("SubscriptionType", [ ]); const configSchema = z.array(subscriptionSchema).superRefine((config, ctx) => { - const seenNames = new Set(); + const seenSubscriptionIds = new Set(); for (const [index, subscription] of config.entries()) { - if (seenNames.has(subscription.Name)) { + if (seenSubscriptionIds.has(subscription.SubscriptionId)) { ctx.addIssue({ code: "custom", - message: "Expected Name to be unique", - path: [index, "Name"], + message: "Expected SubscriptionId to be unique", + path: [index, "SubscriptionId"], }); } else { - seenNames.add(subscription.Name); + seenSubscriptionIds.add(subscription.SubscriptionId); } } }); diff --git a/package-lock.json b/package-lock.json index cafde51..4bfd023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4089,6 +4089,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -8274,6 +8283,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "dev": true, @@ -9548,6 +9563,23 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "license": "MIT", @@ -9856,6 +9888,44 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "dev": true, @@ -10868,6 +10938,7 @@ "@aws-sdk/client-sts": "^3.1004.0", "@aws-sdk/credential-providers": "^3.1004.0", "@nhs-notify-client-callbacks/models": "*", + "table": "^6.9.0", "yargs": "^17.7.2", "zod": "^4.3.6" }, diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts index 6b10f90..1afcc2c 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -11,21 +11,11 @@ export type ClientSubscriptionConfiguration = ( )[]; interface SubscriptionConfigurationBase { - Name: string; + SubscriptionId: string; ClientId: string; - Description: string; - EventSource: string; - EventDetail: string; Targets: { Type: "API"; TargetId: string; - Name: string; - InputTransformer: { - InputPaths: string; - InputHeaders: { - "x-hmac-sha256-signature": string; - }; - }; InvocationEndpoint: string; InvocationMethod: "POST"; InvocationRateLimit: number; @@ -39,7 +29,7 @@ interface SubscriptionConfigurationBase { export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { SubscriptionType: "MessageStatus"; - Statuses: MessageStatus[]; + MessageStatuses: MessageStatus[]; } export interface ChannelStatusSubscriptionConfiguration diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index 9b57ca8..ef4857d 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -18,6 +18,7 @@ "@aws-sdk/client-sts": "^3.1004.0", "@aws-sdk/credential-providers": "^3.1004.0", "@nhs-notify-client-callbacks/models": "*", + "table": "^6.9.0", "yargs": "^17.7.2", "zod": "^4.3.6" }, diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts index 8862c58..1ff192e 100644 --- a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -1,21 +1,11 @@ -const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; -process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source"; - -import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; - -afterAll(() => { - if (originalEventSource === undefined) { - delete process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; - } else { - process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = originalEventSource; - } -}); - -describe("ClientSubscriptionConfigurationBuilder", () => { - it("builds message status subscription with default event source", () => { - const builder = new ClientSubscriptionConfigurationBuilder(); - - const result = builder.messageStatus({ +import { + buildChannelStatusSubscription, + buildMessageStatusSubscription, +} from "src/domain/client-subscription-builder"; + +describe("buildMessageStatusSubscription", () => { + it("builds message status subscription", () => { + const result = buildMessageStatusSubscription({ apiEndpoint: "https://example.com/webhook", apiKey: "secret", apiKeyHeaderName: "x-api-key", @@ -27,39 +17,20 @@ describe("ClientSubscriptionConfigurationBuilder", () => { }); expect(result).toMatchObject({ - Name: "client-one", + SubscriptionId: "client-one", SubscriptionType: "MessageStatus", ClientId: "client-1", - Statuses: ["DELIVERED"], - EventSource: JSON.stringify(["env-source"]), + MessageStatuses: ["DELIVERED"], }); - }); - - it("builds message status subscription with explicit event source", () => { - const builder = new ClientSubscriptionConfigurationBuilder( - "default-source", + expect(result.Targets[0].TargetId).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, ); - - const result = builder.messageStatus({ - apiEndpoint: "https://example.com/webhook", - apiKey: "secret", - clientId: "client-1", - clientName: "Client One", - rateLimit: 10, - statuses: ["FAILED"], - dryRun: false, - eventSource: "explicit-source", - }); - - expect(result.EventSource).toBe(JSON.stringify(["explicit-source"])); }); +}); - it("builds channel status subscription with explicit event source", () => { - const builder = new ClientSubscriptionConfigurationBuilder( - "default-source", - ); - - const result = builder.channelStatus({ +describe("buildChannelStatusSubscription", () => { + it("builds channel status subscription", () => { + const result = buildChannelStatusSubscription({ apiEndpoint: "https://example.com/webhook", apiKey: "secret", clientId: "client-1", @@ -69,26 +40,23 @@ describe("ClientSubscriptionConfigurationBuilder", () => { channelType: "SMS", rateLimit: 20, dryRun: false, - eventSource: "explicit-source", }); expect(result).toMatchObject({ - Name: "client-one-SMS", + SubscriptionId: "client-one-SMS", SubscriptionType: "ChannelStatus", ClientId: "client-1", ChannelType: "SMS", ChannelStatuses: ["DELIVERED"], SupplierStatuses: ["delivered"], - EventSource: JSON.stringify(["explicit-source"]), }); + expect(result.Targets[0].TargetId).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, + ); }); it("defaults channelStatuses and supplierStatuses to [] when not provided", () => { - const builder = new ClientSubscriptionConfigurationBuilder( - "default-source", - ); - - const result = builder.channelStatus({ + const result = buildChannelStatusSubscription({ apiEndpoint: "https://example.com/webhook", apiKey: "secret", clientId: "client-1", @@ -101,42 +69,4 @@ describe("ClientSubscriptionConfigurationBuilder", () => { expect(result.ChannelStatuses).toEqual([]); expect(result.SupplierStatuses).toEqual([]); }); - - it("throws if no event source is available for messageStatus", () => { - const builder = new ClientSubscriptionConfigurationBuilder(""); - - expect(() => - builder.messageStatus({ - apiEndpoint: "https://example.com/webhook", - apiKey: "secret", - clientId: "client-1", - clientName: "Client One", - rateLimit: 10, - statuses: ["DELIVERED"], - dryRun: false, - }), - ).toThrow( - "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", - ); - }); - - it("throws if no event source is available for channelStatus", () => { - const builder = new ClientSubscriptionConfigurationBuilder(""); - - expect(() => - builder.channelStatus({ - apiEndpoint: "https://example.com/webhook", - apiKey: "secret", - clientId: "client-1", - clientName: "Client One", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["delivered"], - channelType: "SMS", - rateLimit: 20, - dryRun: false, - }), - ).toThrow( - "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", - ); - }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts index d13ee08..a1a5561 100644 --- a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts @@ -6,7 +6,7 @@ import type { MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import type { S3Repository } from "src/repository/s3"; -import type { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import type { SubscriptionBuilder } from "src/domain/client-subscription-builder"; const createRepository = ( overrides?: Partial<{ @@ -24,7 +24,7 @@ const createRepository = ( const configurationBuilder = { messageStatus: overrides?.messageStatus ?? jest.fn(), channelStatus: overrides?.channelStatus ?? jest.fn(), - } as unknown as ClientSubscriptionConfigurationBuilder; + } as unknown as SubscriptionBuilder; const repository = new ClientSubscriptionRepository( s3Repository, @@ -38,14 +38,7 @@ describe("ClientSubscriptionRepository", () => { const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] = { Type: "API", - TargetId: "SendToWebhook", - Name: "client-1", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": "$.detail.headers.x-hmac-sha256-signature", - }, - }, + TargetId: "00000000-0000-4000-8000-000000000001", InvocationEndpoint: "https://example.com/webhook", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -56,26 +49,20 @@ describe("ClientSubscriptionRepository", () => { }; const messageSubscription: MessageStatusSubscriptionConfiguration = { - Name: "client-1", + SubscriptionId: "client-1", SubscriptionType: "MessageStatus", ClientId: "client-1", - Statuses: ["DELIVERED"], - Description: "Message subscription", - EventSource: "[]", - EventDetail: "{}", + MessageStatuses: ["DELIVERED"], Targets: [baseTarget], }; const channelSubscription: ChannelStatusSubscriptionConfiguration = { - Name: "client-1-SMS", + SubscriptionId: "client-1-SMS", SubscriptionType: "ChannelStatus", ClientId: "client-1", ChannelType: "SMS", ChannelStatuses: ["DELIVERED"], SupplierStatuses: ["delivered"], - Description: "Channel subscription", - EventSource: "[]", - EventDetail: "{}", Targets: [baseTarget], }; @@ -108,7 +95,7 @@ describe("ClientSubscriptionRepository", () => { const putRawData = jest.fn(); const newMessage: MessageStatusSubscriptionConfiguration = { ...messageSubscription, - Statuses: ["FAILED"], + MessageStatuses: ["FAILED"], }; const messageStatus = jest.fn().mockReturnValue(newMessage); diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts index 108697e..f264e1e 100644 --- a/tools/client-subscriptions-management/src/__tests__/container.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -1,7 +1,10 @@ import { S3Client } from "@aws-sdk/client-s3"; const mockS3Repository = jest.fn(); -const mockBuilder = jest.fn(); +const mockBuilderObject = { + messageStatus: jest.fn(), + channelStatus: jest.fn(), +}; const mockRepository = jest.fn(); jest.mock("src/repository/s3", () => ({ @@ -9,7 +12,7 @@ jest.mock("src/repository/s3", () => ({ })); jest.mock("src/domain/client-subscription-builder", () => ({ - ClientSubscriptionConfigurationBuilder: mockBuilder, + clientSubscriptionBuilder: mockBuilderObject, })); jest.mock("src/repository/client-subscriptions", () => ({ @@ -26,15 +29,16 @@ describe("createClientSubscriptionRepository", () => { const result = createClientSubscriptionRepository({ bucketName: "bucket-1", region: "eu-west-2", - eventSource: "event-source", }); expect(mockS3Repository).toHaveBeenCalledWith( "bucket-1", expect.any(S3Client), ); - expect(mockBuilder).toHaveBeenCalledWith("event-source"); - expect(mockRepository).toHaveBeenCalledTimes(1); + expect(mockRepository).toHaveBeenCalledWith( + mockS3Repository.mock.instances[0], + mockBuilderObject, + ); expect(result).toBe(repoInstance); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts index 39d838c..cae16eb 100644 --- a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts @@ -4,6 +4,8 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +// eslint-disable-next-line unicorn/no-useless-undefined +const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); jest.mock("src/container", () => ({ @@ -13,6 +15,7 @@ jest.mock("src/container", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, })); diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts index 493cc2d..2c3c291 100644 --- a/tools/client-subscriptions-management/src/__tests__/helper.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts @@ -8,7 +8,6 @@ import { formatSubscriptionFileResponse, normalizeClientName, resolveBucketName, - resolveEventSource, resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -22,25 +21,14 @@ jest.mock("@aws-sdk/client-sts", () => ({ describe("cli helper", () => { const messageSubscription: MessageStatusSubscriptionConfiguration = { - Name: "client-a", + SubscriptionId: "client-a", SubscriptionType: "MessageStatus", ClientId: "client-a", - Statuses: ["DELIVERED"], - Description: "Message subscription", - EventSource: '["source-a"]', - EventDetail: "{}", + MessageStatuses: ["DELIVERED"], Targets: [ { Type: "API", - TargetId: "SendToWebhook", - Name: "client-a", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, + TargetId: "00000000-0000-4000-8000-000000000001", InvocationEndpoint: "https://example.com/webhook", InvocationMethod: "POST", InvocationRateLimit: 10, @@ -53,27 +41,16 @@ describe("cli helper", () => { }; const channelSubscription: ChannelStatusSubscriptionConfiguration = { - Name: "client-a-sms", + SubscriptionId: "client-a-sms", SubscriptionType: "ChannelStatus", ClientId: "client-a", ChannelType: "SMS", ChannelStatuses: ["DELIVERED"], SupplierStatuses: ["delivered"], - Description: "Channel subscription", - EventSource: '["source-a"]', - EventDetail: "{}", Targets: [ { Type: "API", - TargetId: "SendToWebhook", - Name: "client-a-sms", - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, + TargetId: "00000000-0000-4000-8000-000000000002", InvocationEndpoint: "https://example.com/webhook", InvocationMethod: "POST", InvocationRateLimit: 20, @@ -85,7 +62,7 @@ describe("cli helper", () => { ], }; - it("formats subscription output", () => { + it("formats subscription output as a table string", () => { const config: ClientSubscriptionConfiguration = [ messageSubscription, channelSubscription, @@ -93,26 +70,18 @@ describe("cli helper", () => { const result = formatSubscriptionFileResponse(config); - expect(result).toEqual([ - { - clientId: "client-a", - subscriptionType: "MessageStatus", - statuses: ["DELIVERED"], - clientApiEndpoint: "https://example.com/webhook", - clientApiKey: "secret", - rateLimit: 10, - }, - { - clientId: "client-a", - subscriptionType: "ChannelStatus", - channelType: "SMS", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["delivered"], - clientApiEndpoint: "https://example.com/webhook", - clientApiKey: "secret", - rateLimit: 20, - }, - ]); + expect(typeof result).toBe("string"); + // message status row + expect(result).toContain("client-a"); + expect(result).toContain("MessageStatus"); + expect(result).toContain("DELIVERED"); + expect(result).toContain("https://example.com/webhook"); + expect(result).toContain("POST"); + expect(result).toContain("x-api-key"); + expect(result).toContain("secret"); + // channel status row + expect(result).toContain("ChannelStatus"); + expect(result).toContain("SMS"); }); it("normalizes client name", () => { @@ -171,26 +140,6 @@ describe("cli helper", () => { expect(resolveProfile(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); }); - it("resolves event source from argument", () => { - expect(resolveEventSource("my-source")).toBe("my-source"); - }); - - it("resolves event source from env", () => { - expect( - resolveEventSource(undefined, { - CLIENT_SUBSCRIPTION_EVENT_SOURCE: "env-source", - } as NodeJS.ProcessEnv), - ).toBe("env-source"); - }); - - it("throws when event source is missing", () => { - expect(() => - resolveEventSource(undefined, {} as NodeJS.ProcessEnv), - ).toThrow( - "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", - ); - }); - it("resolves region from argument", () => { expect(resolveRegion("eu-west-2")).toBe("eu-west-2"); }); diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts index b4423ba..37ad14d 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts @@ -4,9 +4,9 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +// eslint-disable-next-line unicorn/no-useless-undefined +const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); -const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); - jest.mock("src/container", () => ({ createClientSubscriptionRepository: mockCreateRepository, })); @@ -14,7 +14,7 @@ jest.mock("src/container", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, - resolveEventSource: mockResolveEventSource, + resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, })); @@ -33,8 +33,6 @@ describe("put-channel-status CLI", () => { mockResolveBucketName.mockReturnValue("bucket"); mockResolveRegion.mockReset(); mockResolveRegion.mockReturnValue("region"); - mockResolveEventSource.mockReset(); - mockResolveEventSource.mockReturnValue("source-a"); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -138,8 +136,6 @@ describe("put-channel-status CLI", () => { "false", "--bucket-name", "bucket-1", - "--event-source", - "source-a", "--api-key-header-name", "x-api-key", ]); @@ -155,7 +151,6 @@ describe("put-channel-status CLI", () => { supplierStatuses: ["delivered"], rateLimit: 10, dryRun: false, - eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); @@ -378,7 +373,6 @@ describe("put-channel-status CLI", () => { channelType: "SMS", rateLimit: 10, dryRun: false, - eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts index 007ddb4..748293c 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts @@ -4,9 +4,9 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +// eslint-disable-next-line unicorn/no-useless-undefined +const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); -const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); - jest.mock("src/container", () => ({ createClientSubscriptionRepository: mockCreateRepository, })); @@ -14,7 +14,7 @@ jest.mock("src/container", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, - resolveEventSource: mockResolveEventSource, + resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, })); @@ -33,8 +33,6 @@ describe("put-message-status CLI", () => { mockResolveBucketName.mockReturnValue("bucket"); mockResolveRegion.mockReset(); mockResolveRegion.mockReturnValue("region"); - mockResolveEventSource.mockReset(); - mockResolveEventSource.mockReturnValue("source-a"); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -101,8 +99,6 @@ describe("put-message-status CLI", () => { "false", "--bucket-name", "bucket-1", - "--event-source", - "source-a", "--api-key-header-name", "x-api-key", ]); @@ -116,7 +112,6 @@ describe("put-message-status CLI", () => { statuses: ["DELIVERED"], rateLimit: 10, dryRun: false, - eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); @@ -313,7 +308,6 @@ describe("put-message-status CLI", () => { statuses: ["DELIVERED"], rateLimit: 10, dryRun: false, - eventSource: "source-a", }); expect(console.log).toHaveBeenCalledWith(["formatted"]); }); diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts index 3274e98..ddac500 100644 --- a/tools/client-subscriptions-management/src/container.ts +++ b/tools/client-subscriptions-management/src/container.ts @@ -2,13 +2,12 @@ import { S3Client } from "@aws-sdk/client-s3"; import { fromIni } from "@aws-sdk/credential-providers"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import { S3Repository } from "src/repository/s3"; -import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import { clientSubscriptionBuilder } from "src/domain/client-subscription-builder"; type RepositoryOptions = { bucketName: string; region?: string; profile?: string; - eventSource?: string; }; export const createS3Client = ( @@ -29,8 +28,8 @@ export const createClientSubscriptionRepository = ( options.bucketName, createS3Client(options.region, options.profile), ); - const configurationBuilder = new ClientSubscriptionConfigurationBuilder( - options.eventSource, + return new ClientSubscriptionRepository( + s3Repository, + clientSubscriptionBuilder, ); - return new ClientSubscriptionRepository(s3Repository, configurationBuilder); }; diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts index 2d2c0b1..11602f9 100644 --- a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -8,124 +8,90 @@ import type { MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; -const DEFAULT_EVENT_SOURCE = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; - -// eslint-disable-next-line import-x/prefer-default-export -export class ClientSubscriptionConfigurationBuilder { - constructor( - private readonly eventSource: string | undefined = DEFAULT_EVENT_SOURCE, - ) {} - - private resolveEventSource(override?: string): string { - const source = override ?? this.eventSource; - if (!source) { - throw new Error( - "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", - ); - } - return source; - } - +export type SubscriptionBuilder = { messageStatus( args: MessageStatusSubscriptionArgs, - ): MessageStatusSubscriptionConfiguration { - const { - apiEndpoint, - apiKey, - apiKeyHeaderName = "x-api-key", - clientId, - clientName, - eventSource, - rateLimit, - statuses, - } = args; - const normalizedClientName = normalizeClientName(clientName); - return { - Name: normalizedClientName, - SubscriptionType: "MessageStatus", - ClientId: clientId, - Statuses: statuses, - Description: `Message Status Subscription for ${clientName}`, - EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["MessageStatus"], - }), - Targets: [ - { - Type: "API", - TargetId: "SendToWebhook", - Name: normalizedClientName, - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: apiEndpoint, - InvocationMethod: "POST", - InvocationRateLimit: rateLimit, - APIKey: { - HeaderName: apiKeyHeaderName, - HeaderValue: apiKey, - }, - }, - ], - }; - } - + ): MessageStatusSubscriptionConfiguration; channelStatus( args: ChannelStatusSubscriptionArgs, - ): ChannelStatusSubscriptionConfiguration { - const { - apiEndpoint, - apiKey, - apiKeyHeaderName = "x-api-key", - channelStatuses, - channelType, - clientId, - clientName, - eventSource, - rateLimit, - supplierStatuses, - } = args; - const normalizedClientName = normalizeClientName(clientName); - return { - Name: `${normalizedClientName}-${channelType}`, - SubscriptionType: "ChannelStatus", - ClientId: clientId, - ChannelType: channelType, - ChannelStatuses: channelStatuses ?? [], - SupplierStatuses: supplierStatuses ?? [], - Description: `Channel Status Subscription for ${clientName} - ${channelType}`, - EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), - EventDetail: JSON.stringify({ - clientId: [clientId], - type: ["ChannelStatus"], - channel: [channelType], - }), - Targets: [ - { - Type: "API", - TargetId: "SendToWebhook", - Name: `${normalizedClientName}-${channelType}`, - InputTransformer: { - InputPaths: "$.detail.event", - InputHeaders: { - "x-hmac-sha256-signature": - "$.detail.headers.x-hmac-sha256-signature", - }, - }, - InvocationEndpoint: apiEndpoint, - InvocationMethod: "POST", - InvocationRateLimit: rateLimit, - APIKey: { - HeaderName: apiKeyHeaderName, - HeaderValue: apiKey, - }, + ): ChannelStatusSubscriptionConfiguration; +}; + +export function buildMessageStatusSubscription( + args: MessageStatusSubscriptionArgs, +): MessageStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + clientId, + clientName, + rateLimit, + statuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + const subscriptionId = normalizedClientName; + return { + SubscriptionId: subscriptionId, + SubscriptionType: "MessageStatus", + ClientId: clientId, + MessageStatuses: statuses, + Targets: [ + { + Type: "API", + TargetId: crypto.randomUUID(), + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, }, - ], - }; - } + }, + ], + }; } + +export function buildChannelStatusSubscription( + args: ChannelStatusSubscriptionArgs, +): ChannelStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + channelStatuses, + channelType, + clientId, + clientName, + rateLimit, + supplierStatuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + const subscriptionId = `${normalizedClientName}-${channelType}`; + return { + SubscriptionId: subscriptionId, + SubscriptionType: "ChannelStatus", + ClientId: clientId, + ChannelType: channelType, + ChannelStatuses: channelStatuses ?? [], + SupplierStatuses: supplierStatuses ?? [], + Targets: [ + { + Type: "API", + TargetId: crypto.randomUUID(), + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; +} + +export const clientSubscriptionBuilder: SubscriptionBuilder = { + messageStatus: buildMessageStatusSubscription, + channelStatus: buildChannelStatusSubscription, +}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts index fb281a5..c1f4665 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts @@ -11,7 +11,6 @@ import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, - resolveEventSource, resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -63,12 +62,6 @@ const sharedOptions = { demandOption: false, description: "AWS region (defaults to AWS_REGION or eu-west-2)", }, - "event-source": { - type: "string" as const, - demandOption: false, - description: - "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", - }, "terraform-apply": { type: "boolean" as const, default: false, @@ -181,13 +174,11 @@ export async function main(args: string[] = process.argv) { profile, argv.project, ); - const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository( { bucketName, region, profile, - eventSource, }, ); @@ -201,7 +192,6 @@ export async function main(args: string[] = process.argv) { statuses: argv["message-statuses"], rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource, }); console.log(formatSubscriptionFileResponse(result)); @@ -265,13 +255,11 @@ export async function main(args: string[] = process.argv) { profile, argv.project, ); - const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository( { bucketName, region, profile, - eventSource, }, ); @@ -287,7 +275,6 @@ export async function main(args: string[] = process.argv) { supplierStatuses, rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource, }); console.log(formatSubscriptionFileResponse(result)); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index b9ccec9..d060417 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,29 +1,51 @@ import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { fromIni } from "@aws-sdk/credential-providers"; +import { table } from "table"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +const SUBSCRIPTION_TABLE_HEADER = [ + "Client ID", + "Subscription Type", + "Statuses", + "Target ID", + "Endpoint", + "Method", + "Rate Limit", + "API Key Header", + "API Key Value", +]; + +const subscriptionStatuses = ( + subscription: ClientSubscriptionConfiguration[number], +): string => { + if (subscription.SubscriptionType === "MessageStatus") { + return subscription.MessageStatuses.join(", "); + } + const statuses = [ + ...subscription.ChannelStatuses, + ...subscription.SupplierStatuses, + ]; + return `${subscription.ChannelType}: ${statuses.join(", ")}`; +}; + export const formatSubscriptionFileResponse = ( subscriptions: ClientSubscriptionConfiguration, -) => - subscriptions.map((subscription) => ({ - clientId: subscription.ClientId, - subscriptionType: subscription.SubscriptionType, - ...(subscription.SubscriptionType === "ChannelStatus" - ? { - channelType: subscription.ChannelType, - channelStatuses: subscription.ChannelStatuses, - supplierStatuses: subscription.SupplierStatuses, - } - : {}), - ...(subscription.SubscriptionType === "MessageStatus" - ? { - statuses: subscription.Statuses, - } - : {}), - clientApiEndpoint: subscription.Targets[0].InvocationEndpoint, - clientApiKey: subscription.Targets[0].APIKey.HeaderValue, - rateLimit: subscription.Targets[0].InvocationRateLimit, - })); +): string => { + const rows = subscriptions.flatMap((subscription) => + subscription.Targets.map((target) => [ + subscription.ClientId, + subscription.SubscriptionType, + subscriptionStatuses(subscription), + target.TargetId, + target.InvocationEndpoint, + target.InvocationMethod, + String(target.InvocationRateLimit), + target.APIKey.HeaderName, + target.APIKey.HeaderValue, + ]), + ); + return table([SUBSCRIPTION_TABLE_HEADER, ...rows]); +}; export const normalizeClientName = (name: string): string => name.replaceAll(/\s+/g, "-").toLowerCase(); @@ -75,19 +97,6 @@ export const resolveBucketName = async ( return deriveBucketName(accountId, environment, resolvedRegion, project); }; -export const resolveEventSource = ( - eventSourceArg?: string, - env: NodeJS.ProcessEnv = process.env, -): string => { - const eventSource = eventSourceArg ?? env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; - if (!eventSource) { - throw new Error( - "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", - ); - } - return eventSource; -}; - export const resolveRegion = ( regionArg?: string, env: NodeJS.ProcessEnv = process.env, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts index cb86540..4097dd9 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -9,7 +9,6 @@ import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, - resolveEventSource, resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -94,12 +93,6 @@ export const parseArgs = (args: string[]) => demandOption: false, description: "AWS profile to use (overrides AWS_PROFILE)", }, - "event-source": { - type: "string", - demandOption: false, - description: - "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", - }, }) .parseSync(); @@ -130,12 +123,10 @@ export async function main(args: string[] = process.argv) { region, profile, ); - const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, region, profile, - eventSource, }); const result = @@ -150,7 +141,6 @@ export async function main(args: string[] = process.argv) { supplierStatuses, rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource, }); console.log(formatSubscriptionFileResponse(result)); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts index 40591e6..8dcdb35 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -5,7 +5,6 @@ import { createClientSubscriptionRepository } from "src/container"; import { formatSubscriptionFileResponse, resolveBucketName, - resolveEventSource, resolveProfile, resolveRegion, } from "src/entrypoint/cli/helper"; @@ -77,12 +76,6 @@ export const parseArgs = (args: string[]) => demandOption: false, description: "AWS profile to use (overrides AWS_PROFILE)", }, - "event-source": { - type: "string", - demandOption: false, - description: - "EventBridge event source (overrides CLIENT_SUBSCRIPTION_EVENT_SOURCE)", - }, }) .parseSync(); @@ -103,12 +96,10 @@ export async function main(args: string[] = process.argv) { region, profile, ); - const eventSource = resolveEventSource(argv["event-source"]); const clientSubscriptionRepository = createClientSubscriptionRepository({ bucketName, region, profile, - eventSource, }); const result = @@ -121,7 +112,6 @@ export async function main(args: string[] = process.argv) { statuses: argv["message-statuses"], rateLimit: argv["rate-limit"], dryRun: argv["dry-run"], - eventSource, }); console.log(formatSubscriptionFileResponse(result)); diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 2132bf0..48c5629 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -10,7 +10,7 @@ import { SUPPLIER_STATUSES, type SupplierStatus, } from "@nhs-notify-client-callbacks/models"; -import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import type { SubscriptionBuilder } from "src/domain/client-subscription-builder"; import { S3Repository } from "src/repository/s3"; export type MessageStatusSubscriptionArgs = { @@ -22,7 +22,6 @@ export type MessageStatusSubscriptionArgs = { rateLimit: number; dryRun: boolean; apiKeyHeaderName?: string; - eventSource?: string; }; const messageStatusSubscriptionArgsSchema = z.object({ @@ -34,7 +33,6 @@ const messageStatusSubscriptionArgsSchema = z.object({ rateLimit: z.number(), dryRun: z.boolean(), apiKeyHeaderName: z.string().optional().default("x-api-key"), - eventSource: z.string().optional(), }); export type ChannelStatusSubscriptionArgs = { @@ -48,7 +46,6 @@ export type ChannelStatusSubscriptionArgs = { rateLimit: number; dryRun: boolean; apiKeyHeaderName?: string; - eventSource?: string; }; const channelStatusSubscriptionArgsSchema = z.object({ @@ -62,13 +59,12 @@ const channelStatusSubscriptionArgsSchema = z.object({ rateLimit: z.number(), dryRun: z.boolean(), apiKeyHeaderName: z.string().optional().default("x-api-key"), - eventSource: z.string().optional(), }); export class ClientSubscriptionRepository { constructor( private readonly s3Repository: S3Repository, - private readonly configurationBuilder: ClientSubscriptionConfigurationBuilder, + private readonly configurationBuilder: SubscriptionBuilder, ) {} async getClientSubscriptions( From 46563dad7c71cf704d76047807dbcb674f7a4569 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 13:22:23 +0000 Subject: [PATCH 11/16] CCM-14203 - PR feedback --- .../{index.integration.test.ts => index.component.test.ts} | 2 +- ...date.integration.test.ts => config-update.component.test.ts} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename lambdas/client-transform-filter-lambda/src/__tests__/{index.integration.test.ts => index.component.test.ts} (98%) rename lambdas/client-transform-filter-lambda/src/__tests__/services/{config-update.integration.test.ts => config-update.component.test.ts} (98%) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts similarity index 98% rename from lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts rename to lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts index 0b70068..b8851d5 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts @@ -1,5 +1,5 @@ /** - * Integration-style test for the complete handler flow including S3 config loading and + * Component test for the complete handler flow including S3 config loading and * subscription filtering. Uses the real ConfigLoader + ConfigCache + filter pipeline * with a mocked S3Client. */ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts similarity index 98% rename from lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts rename to lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts index 9f3e9ba..3cf95b9 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts @@ -2,7 +2,7 @@ import { S3Client } from "@aws-sdk/client-s3"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; -describe("config update integration", () => { +describe("config update component", () => { it("reloads configuration after cache expiry", async () => { jest.useFakeTimers(); jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); From f13d7ac6c7bdcba4e6355840814c2b70be6c9133 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 13:25:14 +0000 Subject: [PATCH 12/16] CCM-14203 - PR feedback --- tools/client-subscriptions-management/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md index 3cf258a..4b16913 100644 --- a/tools/client-subscriptions-management/README.md +++ b/tools/client-subscriptions-management/README.md @@ -32,9 +32,9 @@ npm --workspace tools/client-subscriptions-management run deploy -- message \ ## Commands -### Deploy a Subscription (upload config + optionally apply terraform) +### Deploy a Subscription (upload config + optionally apply Terraform) -Use `deploy` to upload a subscription config to S3 and optionally trigger a terraform apply in one step. +Use `deploy` to upload a subscription config to S3 and optionally trigger a Terraform apply in one step. #### Message status From 86e77449eb4c77377f467bfc7f6f6534083092da Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 9 Mar 2026 13:39:41 +0000 Subject: [PATCH 13/16] CCM-14203 - PR feedback --- .github/actions/acceptance-tests/action.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 9f0d603..922232e 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -39,10 +39,11 @@ runs: with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} - - name: "Set PR NUMBER environment variable" + - name: "Set environment variables" shell: bash run: | echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + echo "ENVIRONMENT=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV - name: Run test - ${{ inputs.testType }} shell: bash From 102b58ed38b22cfcfa015f87f6d6357a40eba19a Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 10 Mar 2026 08:16:07 +0000 Subject: [PATCH 14/16] CCM-14201 - PR feedback --- eslint.config.mjs | 1 + .../src/__tests__/index.test.ts | 1 - .../services/subscription-filter.test.ts | 1 - .../src/__tests__/constants.test.ts | 28 ------------------- .../src/__tests__/container-s3-config.test.ts | 20 +++++++++++++ .../client-subscription-builder.test.ts | 0 .../cli}/get-client-subscriptions.test.ts | 5 ---- .../{ => entrypoint/cli}/helper.test.ts | 0 .../cli}/put-channel-status.test.ts | 1 - .../cli}/put-message-status.test.ts | 1 - .../client-subscriptions.test.ts | 3 -- .../src/__tests__/{ => repository}/s3.test.ts | 0 12 files changed, 21 insertions(+), 40 deletions(-) delete mode 100644 tools/client-subscriptions-management/src/__tests__/constants.test.ts rename tools/client-subscriptions-management/src/__tests__/{ => domain}/client-subscription-builder.test.ts (100%) rename tools/client-subscriptions-management/src/__tests__/{ => entrypoint/cli}/get-client-subscriptions.test.ts (93%) rename tools/client-subscriptions-management/src/__tests__/{ => entrypoint/cli}/helper.test.ts (100%) rename tools/client-subscriptions-management/src/__tests__/{ => entrypoint/cli}/put-channel-status.test.ts (99%) rename tools/client-subscriptions-management/src/__tests__/{ => entrypoint/cli}/put-message-status.test.ts (99%) rename tools/client-subscriptions-management/src/__tests__/{ => repository}/client-subscriptions.test.ts (98%) rename tools/client-subscriptions-management/src/__tests__/{ => repository}/s3.test.ts (100%) diff --git a/eslint.config.mjs b/eslint.config.mjs index 22c3292..61b5183 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -101,6 +101,7 @@ export default defineConfig([ }, ], "unicorn/no-null": 0, + "unicorn/no-useless-undefined": 0, "unicorn/prefer-module": 0, "unicorn/import-style": [ 2, diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 3c5a652..bda75a6 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -342,7 +342,6 @@ describe("Lambda handler", () => { }), getLogger: jest.fn().mockReturnValue(faultyLogger), getMetrics: jest.fn().mockReturnValue(faultyMetrics), - // eslint-disable-next-line unicorn/no-useless-undefined flush: jest.fn().mockResolvedValue(undefined), } as unknown as ObservabilityService; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index 92c227c..2df22a2 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -141,7 +141,6 @@ describe("evaluateSubscriptionFilters", () => { describe("when config is undefined", () => { it("returns not matched with Unknown subscription type", () => { const event = createMessageStatusEvent("client-1", "DELIVERED"); - // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config const result = evaluateSubscriptionFilters(event, undefined); expect(result).toEqual({ diff --git a/tools/client-subscriptions-management/src/__tests__/constants.test.ts b/tools/client-subscriptions-management/src/__tests__/constants.test.ts deleted file mode 100644 index 9d53f53..0000000 --- a/tools/client-subscriptions-management/src/__tests__/constants.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - MESSAGE_STATUSES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; - -describe("constants", () => { - it("exposes message statuses", () => { - expect(MESSAGE_STATUSES).toContain("DELIVERED"); - expect(MESSAGE_STATUSES).toContain("FAILED"); - }); - - it("exposes channel statuses", () => { - expect(CHANNEL_STATUSES).toContain("SENDING"); - expect(CHANNEL_STATUSES).toContain("SKIPPED"); - }); - - it("exposes supplier statuses", () => { - expect(SUPPLIER_STATUSES).toContain("delivered"); - expect(SUPPLIER_STATUSES).toContain("unknown"); - }); - - it("exposes channel types", () => { - expect(CHANNEL_TYPES).toContain("SMS"); - expect(CHANNEL_TYPES).toContain("EMAIL"); - }); -}); diff --git a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts index 47c1ddc..d8214e6 100644 --- a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -1,5 +1,10 @@ import { createS3Client } from "src/container"; +const mockFromIni = jest.fn().mockReturnValue({ accessKeyId: "from-ini" }); +jest.mock("@aws-sdk/credential-providers", () => ({ + fromIni: (...args: unknown[]) => mockFromIni(...args), +})); + describe("createS3Client", () => { it("sets forcePathStyle=true when endpoint contains localhost", () => { const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; @@ -29,4 +34,19 @@ describe("createS3Client", () => { // S3Client converts undefined to false, so we just check it's not true expect(config.forcePathStyle).not.toBe(true); }); + + it("uses fromIni credentials when a profile is provided", () => { + const client = createS3Client("eu-west-2", "my-profile", {}); + + const { config } = client as any; + expect(mockFromIni).toHaveBeenCalledWith({ profile: "my-profile" }); + expect(config.credentials).toBeDefined(); + }); + + it("does not use fromIni credentials when profile is undefined", () => { + mockFromIni.mockClear(); + createS3Client("eu-west-2", undefined, {}); + + expect(mockFromIni).not.toHaveBeenCalled(); + }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts similarity index 100% rename from tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts rename to tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts similarity index 93% rename from tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts rename to tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts index cae16eb..a0993ab 100644 --- a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts @@ -4,7 +4,6 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -// eslint-disable-next-line unicorn/no-useless-undefined const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); @@ -67,7 +66,6 @@ describe("get-client-subscriptions CLI", () => { }); it("prints message when no configuration exists", async () => { - // eslint-disable-next-line unicorn/no-useless-undefined mockGetClientSubscriptions.mockResolvedValue(undefined); await cli.main([ @@ -103,7 +101,6 @@ describe("get-client-subscriptions CLI", () => { }); it("executes when run as main module", async () => { - // eslint-disable-next-line unicorn/no-useless-undefined mockGetClientSubscriptions.mockResolvedValue(undefined); const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); @@ -151,7 +148,6 @@ describe("get-client-subscriptions CLI", () => { "--bucket-name", "bucket-1", ]; - // eslint-disable-next-line unicorn/no-useless-undefined mockGetClientSubscriptions.mockResolvedValue(undefined); await cli.runCli(); @@ -168,7 +164,6 @@ describe("get-client-subscriptions CLI", () => { "--bucket-name", "bucket-2", ]; - // eslint-disable-next-line unicorn/no-useless-undefined mockGetClientSubscriptions.mockResolvedValue(undefined); await cli.main(); diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts similarity index 100% rename from tools/client-subscriptions-management/src/__tests__/helper.test.ts rename to tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts similarity index 99% rename from tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts rename to tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts index 37ad14d..92b3a2b 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts @@ -4,7 +4,6 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -// eslint-disable-next-line unicorn/no-useless-undefined const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); jest.mock("src/container", () => ({ diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts similarity index 99% rename from tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts rename to tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts index 748293c..afdb8bf 100644 --- a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts @@ -4,7 +4,6 @@ const mockCreateRepository = jest.fn().mockReturnValue({ }); const mockFormatSubscriptionFileResponse = jest.fn(); const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -// eslint-disable-next-line unicorn/no-useless-undefined const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); jest.mock("src/container", () => ({ diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts similarity index 98% rename from tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts rename to tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts index a1a5561..93fa6f5 100644 --- a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts @@ -77,7 +77,6 @@ describe("ClientSubscriptionRepository", () => { }); it("returns undefined when config file is missing", async () => { - // eslint-disable-next-line unicorn/no-useless-undefined const getObject = jest.fn().mockResolvedValue(undefined); const { repository } = createRepository({ getObject }); @@ -123,7 +122,6 @@ describe("ClientSubscriptionRepository", () => { }); it("skips S3 write when dry run is enabled", async () => { - // eslint-disable-next-line unicorn/no-useless-undefined const getObject = jest.fn().mockResolvedValue(undefined); const putRawData = jest.fn(); const messageStatus = jest.fn().mockReturnValue(messageSubscription); @@ -186,7 +184,6 @@ describe("ClientSubscriptionRepository", () => { }); it("skips S3 write for channel status dry run", async () => { - // eslint-disable-next-line unicorn/no-useless-undefined const getObject = jest.fn().mockResolvedValue(undefined); const putRawData = jest.fn(); const channelStatus = jest.fn().mockReturnValue(channelSubscription); diff --git a/tools/client-subscriptions-management/src/__tests__/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts similarity index 100% rename from tools/client-subscriptions-management/src/__tests__/s3.test.ts rename to tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts From f16f9d6491a5c9a8ce275ef0c987cd5e75f204b1 Mon Sep 17 00:00:00 2001 From: Tim Marston Date: Tue, 10 Mar 2026 14:30:24 +0000 Subject: [PATCH 15/16] fixed make config --- scripts/init.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/init.mk b/scripts/init.mk index 885d2d3..89a0611 100644 --- a/scripts/init.mk +++ b/scripts/init.mk @@ -46,7 +46,7 @@ _install-dependency: # Install asdf dependency - mandatory: name=[listed in the asdf install ${name} $(or ${version},) _install-dependencies: # Install all the dependencies listed in .tool-versions - for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do + for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do \ $(MAKE) _install-dependency name=$${plugin}; \ done From 0ad55d12d6c73d139701eb75d2ccaabd4a273903 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 10 Mar 2026 15:13:58 +0000 Subject: [PATCH 16/16] CCM-14201 - PR feedback --- tools/client-subscriptions-management/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md index 4b16913..32a900f 100644 --- a/tools/client-subscriptions-management/README.md +++ b/tools/client-subscriptions-management/README.md @@ -10,8 +10,6 @@ From the repository root run: npm --workspace tools/client-subscriptions-management run -- [options] ``` -Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `deploy`, `put-message-status`, and `put-channel-status` commands. - ## Example Deploy a message status subscription to the `dev` environment using a named AWS profile: @@ -98,8 +96,6 @@ npm --workspace tools/client-subscriptions-management run put-message-status -- Optional: `--client-name "Test Client"` (defaults to client-id if not provided), `--profile `, `--bucket-name ` -Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable - ### Put Channel Status Subscription (S3 upload only) ```bash @@ -118,6 +114,4 @@ npm --workspace tools/client-subscriptions-management run put-channel-status -- Optional: `--client-name "Test Client"` (defaults to client-id if not provided), `--profile `, `--bucket-name ` -Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable - **Note**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided.