diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index bb88afb..448e91d 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -15,6 +15,7 @@ permissions: id-token: write contents: write packages: read + pull-requests: read jobs: diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf index 8fe7ff6..0610e73 100644 --- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf @@ -35,6 +35,9 @@ module "client_transform_filter_lambda" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.id + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" } } diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index a8954f5..613ff86 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -1,11 +1,12 @@ { "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", "esbuild": "^0.25.0", "p-map": "^4.0.0", - "pino": "^9.5.0", - "zod": "^3.25.76" + "pino": "^9.6.0", + "zod": "^4.1.13" }, "devDependencies": { "@tsconfig/node22": "^22.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..2f0e327 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -0,0 +1,134 @@ +/* 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 "models/status-transition-event"; +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_TRANSITIONED, + 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 + }); + + 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..8d1675d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -0,0 +1,210 @@ +/** + * 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 "models/status-transition-event"; +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_TRANSITIONED, + 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"; + }); + + 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; + }); + + 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"; + }); +}); 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 fdeebfa..8208669 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -152,7 +152,7 @@ describe("Lambda handler", () => { }; await expect(handler([sqsMessage])).rejects.toThrow( - "Validation failed: type: Invalid enum value", + "Validation failed: type: Invalid option", ); }); 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..fc51474 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -0,0 +1,86 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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..6d28928 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -0,0 +1,169 @@ +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", + ); + }); +}); 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..d19dadb --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts @@ -0,0 +1,100 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts new file mode 100644 index 0000000..161153a --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -0,0 +1,1058 @@ +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { MessageStatusData } from "models/message-status-data"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusTransitionEvent => ({ + 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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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..d2b2517 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts @@ -0,0 +1,265 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +import type { MessageStatusData } from "models/message-status-data"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { Channel } from "models/channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "models/status-types"; +import { evaluateSubscriptionFilters } from "services/transform-pipeline"; + +const createMessageStatusEvent = ( + clientId: string, + status: MessageStatus, +): StatusTransitionEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + 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, +): StatusTransitionEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.CHANNEL_STATUS_TRANSITIONED, + 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 StatusTransitionEvent; + 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/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts new file mode 100644 index 0000000..10146f1 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -0,0 +1,138 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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 6dc317b..ab06adf 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 @@ -56,7 +56,7 @@ describe("event-validator", () => { delete invalidEvent.traceparent; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: traceparent: Required", + "Validation failed: traceparent: Invalid input: expected string, received undefined", ); }); }); @@ -69,7 +69,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: type: Invalid enum value", + "Validation failed: type: Invalid option", ); }); }); @@ -82,7 +82,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - 'Validation failed: datacontenttype: Invalid literal value, expected "application/json"', + 'Validation failed: datacontenttype: Invalid input: expected "application/json"', ); }); }); @@ -98,7 +98,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: clientId: Required", + "Validation failed: clientId: Invalid input: expected string, received undefined", ); }); @@ -112,7 +112,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: messageId: Required", + "Validation failed: messageId: Invalid input: expected string, received undefined", ); }); @@ -126,7 +126,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: timestamp: Required", + "Validation failed: timestamp: Invalid input: expected string, received undefined", ); }); @@ -156,7 +156,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: messageStatus: Required", + "Validation failed: messageStatus: Invalid input: expected string, received undefined", ); }); @@ -170,7 +170,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: channels: Required", + "Validation failed: channels: Invalid input: expected array, received undefined", ); }); @@ -198,7 +198,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.type: Required", + "Validation failed: channels.0.type: Invalid input: expected string, received undefined", ); }); @@ -212,7 +212,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.channelStatus: Required", + "Validation failed: channels.0.channelStatus: Invalid input: expected string, received undefined", ); }); }); @@ -251,7 +251,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: channel: Required", + "Validation failed: channel: Invalid input: expected string, received undefined", ); }); @@ -265,7 +265,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: channelStatus: Required", + "Validation failed: channelStatus: Invalid input: expected string, received undefined", ); }); @@ -279,7 +279,7 @@ describe("event-validator", () => { }; expect(() => validateStatusTransitionEvent(invalidEvent)).toThrow( - "Validation failed: supplierStatus: Required", + "Validation failed: supplierStatus: 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 bc5f891..19eaabd 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -6,6 +6,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; @@ -114,6 +116,34 @@ function recordDeliveryInitiated( } } +async function filterBatch( + transformedEvents: TransformedEvent[], + configLoader: ConfigLoader, + observability: ObservabilityService, +): Promise { + const filtered: TransformedEvent[] = []; + + for (const event of transformedEvents) { + const { clientId } = event.data; + const config = await configLoader.loadClientConfig(clientId); + const filterResult = evaluateSubscriptionFilters(event, config); + + if (filterResult.matched) { + filtered.push(event); + } else { + 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, @@ -143,6 +173,7 @@ async function transformBatch( export async function processEvents( event: SQSRecord[], observability: ObservabilityService, + configLoader?: ConfigLoader, ): Promise { const startTime = Date.now(); const stats = new BatchStats(); @@ -150,6 +181,10 @@ export async function processEvents( try { const transformedEvents = await transformBatch(event, observability, stats); + const filteredEvents = configLoader + ? await filterBatch(transformedEvents, configLoader, observability) + : transformedEvents; + const processingTime = Date.now() - startTime; observability.logBatchProcessingCompleted({ ...stats.toObject(), @@ -157,10 +192,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 5c457bc..8a74904 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -1,22 +1,95 @@ +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 const handler = async ( event: SQSRecord[], ): Promise => { const metricsLogger = createMetricLogger(); const metrics = new CallbackMetrics(metricsLogger); - const logger = new Logger(); + const handlerLogger = new Logger(); const observability = new ObservabilityService( - logger, + handlerLogger, metrics, metricsLogger, ); - return processEvents(event, observability); + return processEvents(event, observability, getConfigLoader()); }; export { type TransformedEvent } from "handler"; diff --git a/lambdas/client-transform-filter-lambda/src/models/channel-types.ts b/lambdas/client-transform-filter-lambda/src/models/channel-types.ts index d4526fb..d50c7c7 100644 --- a/lambdas/client-transform-filter-lambda/src/models/channel-types.ts +++ b/lambdas/client-transform-filter-lambda/src/models/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/lambdas/client-transform-filter-lambda/src/models/client-config.ts b/lambdas/client-transform-filter-lambda/src/models/client-config.ts index f24330f..776982a 100644 --- a/lambdas/client-transform-filter-lambda/src/models/client-config.ts +++ b/lambdas/client-transform-filter-lambda/src/models/client-config.ts @@ -1,3 +1,10 @@ +import type { Channel } from "models/channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "models/status-types"; + export type ClientSubscriptionConfiguration = ( | MessageStatusSubscriptionConfiguration | ChannelStatusSubscriptionConfiguration @@ -32,13 +39,13 @@ interface SubscriptionConfigurationBase { export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { SubscriptionType: "MessageStatus"; - Statuses: string[]; + Statuses: MessageStatus[]; } export interface ChannelStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { SubscriptionType: "ChannelStatus"; - ChannelType: string; - ChannelStatuses: string[]; - SupplierStatuses: string[]; + ChannelType: Channel; + ChannelStatuses: ChannelStatus[]; + SupplierStatuses: SupplierStatus[]; } diff --git a/lambdas/client-transform-filter-lambda/src/models/status-types.ts b/lambdas/client-transform-filter-lambda/src/models/status-types.ts index 516bd74..45b08d5 100644 --- a/lambdas/client-transform-filter-lambda/src/models/status-types.ts +++ b/lambdas/client-transform-filter-lambda/src/models/status-types.ts @@ -1,31 +1,40 @@ -export type MessageStatus = - | "CREATED" - | "PENDING_ENRICHMENT" - | "ENRICHED" - | "SENDING" - | "DELIVERED" - | "FAILED"; +export const MESSAGE_STATUSES = [ + "CREATED", + "PENDING_ENRICHMENT", + "ENRICHED", + "SENDING", + "DELIVERED", + "FAILED", +] as const; -export type ChannelStatus = - | "CREATED" - | "SENDING" - | "DELIVERED" - | "FAILED" - | "SKIPPED"; +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; -export type SupplierStatus = - | "DELIVERED" - | "READ" - | "NOTIFICATION_ATTEMPTED" - | "UNNOTIFIED" - | "REJECTED" - | "NOTIFIED" - | "RECEIVED" - | "PERMANENT_FAILURE" - | "TEMPORARY_FAILURE" - | "TECHNICAL_FAILURE" - | "ACCEPTED" - | "CANCELLED" - | "PENDING_VIRUS_CHECK" - | "VALIDATION_FAILED" - | "UNKNOWN"; +export const CHANNEL_STATUSES = [ + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "SKIPPED", +] as const; + +export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; + +export const SUPPLIER_STATUSES = [ + "DELIVERED", + "READ", + "NOTIFICATION_ATTEMPTED", + "UNNOTIFIED", + "REJECTED", + "NOTIFIED", + "RECEIVED", + "PERMANENT_FAILURE", + "TEMPORARY_FAILURE", + "TECHNICAL_FAILURE", + "ACCEPTED", + "CANCELLED", + "PENDING_VIRUS_CHECK", + "VALIDATION_FAILED", + "UNKNOWN", +] as const; + +export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; 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..c08b185 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts @@ -0,0 +1,37 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; + +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..aa20bb9 --- /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 "models/client-config"; +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..72ee2a0 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -0,0 +1,108 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, +} from "models/client-config"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusTransitionEvent; + 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..f4a013e --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts @@ -0,0 +1,45 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; + +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..0ac3a58 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -0,0 +1,70 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "models/client-config"; +import type { MessageStatusData } from "models/message-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusTransitionEvent; + 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/transform-pipeline.ts b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts new file mode 100644 index 0000000..ca85b1d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts @@ -0,0 +1,44 @@ +import type { ChannelStatusData } from "models/channel-status-data"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import type { MessageStatusData } from "models/message-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +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: StatusTransitionEvent, + 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_TRANSITIONED) { + const notifyData = event.data as MessageStatusData; + return { + matched: matchesMessageStatusSubscription(config, { event, notifyData }), + subscriptionType: "MessageStatus", + }; + } + + if (event.type === EventTypes.CHANNEL_STATUS_TRANSITIONED) { + 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..e1e919a --- /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 "models/client-config"; +import { CHANNEL_TYPES } from "models/channel-types"; +import { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "models/status-types"; + +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 "models/client-config"; 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 c00e5d3..3675ee2 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 @@ -26,9 +26,11 @@ const EventConstraintsSchema = z.object({ const BaseDataSchema = z.object({ clientId: z.string().min(1), messageId: z.string().min(1), - timestamp: z - .string() - .datetime("data.timestamp must be a valid RFC 3339 timestamp"), + timestamp: z.string().pipe( + z.iso.datetime({ + error: "data.timestamp must be a valid RFC 3339 timestamp", + }), + ), }); const MessageStatusDataSchema = BaseDataSchema.extend({ diff --git a/package-lock.json b/package-lock.json index 089ab9d..ed0326c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "workspaces": [ "lambdas/client-transform-filter-lambda", "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", @@ -28,6 +30,7 @@ "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-prettier": "^5.4.0", "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-security": "^3.0.1", "eslint-plugin-sonarjs": "^3.0.2", "eslint-plugin-sort-destructure-keys": "^2.0.0", @@ -47,12 +50,13 @@ "name": "nhs-notify-client-transform-filter-lambda", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", "esbuild": "^0.25.0", "p-map": "^4.0.0", - "pino": "^9.5.0", - "zod": "^3.25.76" + "pino": "^9.6.0", + "zod": "^4.1.13" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", @@ -78,6 +82,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "lambdas/client-transform-filter-lambda/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" + } + }, "lambdas/example-lambda": { "name": "nhs-notify-repository-template-example-lambda", "version": "0.0.1", @@ -155,6 +168,69 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "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", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "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", + "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" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-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", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-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", + "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", @@ -400,6 +476,105 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.996.0.tgz", + "integrity": "sha512-BZsCeq8Sgqbm6xs8VfjyVVwhQZvxDR45P22dcbNNDFaGkkQ/TbJ5KxER19APR9aK+IC7l4KuLxInqeVab2DFfg==", + "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", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.0.tgz", + "integrity": "sha512-CLSrCdBoyIXSthaUcDzKw3fzRNbbyA/BawEMQBxsybYTZhGeC9P9p2DXuqTqVvla+PtEXBgRq0/Sgz2fEOBKyg==", + "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", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.0.tgz", + "integrity": "sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==", + "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-sqs": { "version": "3.990.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.990.0.tgz", @@ -453,44 +628,44 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.990.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz", - "integrity": "sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==", + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.996.0.tgz", + "integrity": "sha512-QzlZozTam0modnGanLjXBHbHC53mMxH/4XmoA9f6ZjPYaGlCcHPYLcslO6w2w68v+F3qN0kxVldUAcL/edtBBA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@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/middleware-user-agent": "^3.972.12", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-endpoints": "3.996.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.972.11", "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", + "@smithy/core": "^3.23.2", "@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-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.3", + "@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.30", - "@smithy/util-defaults-mode-node": "^4.2.33", + "@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", @@ -501,20 +676,36 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.0.tgz", + "integrity": "sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==", + "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/core": { - "version": "3.973.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.10.tgz", - "integrity": "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.12.tgz", + "integrity": "sha512-hFiezao0lCEddPhSQEF6vCu+TepUN3edKxWYbswMoH87XpUvHJmFVX5+zttj4qi33saGiuOaJciswWcN6YSA9g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.4", - "@smithy/core": "^3.23.0", + "@aws-sdk/xml-builder": "^3.972.5", + "@smithy/core": "^3.23.2", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", + "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", @@ -525,13 +716,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "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.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.8.tgz", - "integrity": "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.10.tgz", + "integrity": "sha512-YTWjM78Wiqix0Jv/anbq7+COFOFIBBMLZ+JsLKGwbTZNJ2DG4JNBnLVJAWylPOHwurMws9157pqzU8ODrpBOow==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", @@ -542,18 +746,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.10.tgz", - "integrity": "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.12.tgz", + "integrity": "sha512-adDRE3iFrgJJ7XhRHkb6RdFDMrA5x64WAWxygI3F6wND+3v5qQ4Uks12vsnEZgduU/+JQBgFB6L4vfwUS+rpBQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", + "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" @@ -563,19 +767,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.8.tgz", - "integrity": "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.10.tgz", + "integrity": "sha512-uAXUMfnQJxJ25qeiX4e3Z36NTm1XT7woajV8BXx2yAUDD4jF6kubqnLEcqtiPzHANxmhta2SXm5PbDwSdhThBw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", - "@aws-sdk/credential-provider-env": "^3.972.8", - "@aws-sdk/credential-provider-http": "^3.972.10", - "@aws-sdk/credential-provider-login": "^3.972.8", - "@aws-sdk/credential-provider-process": "^3.972.8", - "@aws-sdk/credential-provider-sso": "^3.972.8", - "@aws-sdk/credential-provider-web-identity": "^3.972.8", - "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/credential-provider-env": "^3.972.10", + "@aws-sdk/credential-provider-http": "^3.972.12", + "@aws-sdk/credential-provider-login": "^3.972.10", + "@aws-sdk/credential-provider-process": "^3.972.10", + "@aws-sdk/credential-provider-sso": "^3.972.10", + "@aws-sdk/credential-provider-web-identity": "^3.972.10", + "@aws-sdk/nested-clients": "3.996.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", @@ -588,13 +792,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.8.tgz", - "integrity": "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.10.tgz", + "integrity": "sha512-7Me+/EkY3kQC1nehBjb9ryc558N+a8R4Dg3rSV3zpiB7iQtvXh4gU3rV14h/dIbn2/VkK9sh55YdXamSjfdb/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", - "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/nested-clients": "3.996.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", @@ -607,17 +811,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.9.tgz", - "integrity": "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.11.tgz", + "integrity": "sha512-maPmjL7nOT93a1QdSDzdF/qLbI+jit3oslKp7g+pTbASewkSYax7FwboETdKRxufPfCdrsRzMW2pIJ+QA8e+Bg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.8", - "@aws-sdk/credential-provider-http": "^3.972.10", - "@aws-sdk/credential-provider-ini": "^3.972.8", - "@aws-sdk/credential-provider-process": "^3.972.8", - "@aws-sdk/credential-provider-sso": "^3.972.8", - "@aws-sdk/credential-provider-web-identity": "^3.972.8", + "@aws-sdk/credential-provider-env": "^3.972.10", + "@aws-sdk/credential-provider-http": "^3.972.12", + "@aws-sdk/credential-provider-ini": "^3.972.10", + "@aws-sdk/credential-provider-process": "^3.972.10", + "@aws-sdk/credential-provider-sso": "^3.972.10", + "@aws-sdk/credential-provider-web-identity": "^3.972.10", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", @@ -630,12 +834,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.8.tgz", - "integrity": "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.10.tgz", + "integrity": "sha512-tk/XxFhk37rKviArOIYbJ8crXiN3Mzn7Tb147jH51JTweNgUOwmqN+s027uqc3d8UeAyUcPUH8Bmfj86SzOhBQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -647,14 +851,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.8.tgz", - "integrity": "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.10.tgz", + "integrity": "sha512-tIz/O0yV1s77/FjMTWvvzU2vsztap2POlbetheOyRXq+E3PQtLOzCYopasXP+aeO1oerw3PFd9eycLbiwpgZZA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.990.0", - "@aws-sdk/core": "^3.973.10", - "@aws-sdk/token-providers": "3.990.0", + "@aws-sdk/client-sso": "3.996.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/token-providers": "3.996.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -666,13 +870,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.8.tgz", - "integrity": "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.10.tgz", + "integrity": "sha512-HFlIVx8mm+Au7hkO7Hq/ZkPomjTt26iRj8uWZqEE1cJWMZ2NKvieNiT1ngzWt60Bc2uD51LqQUqiwr5JDgS4iQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", - "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/nested-clients": "3.996.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -683,6 +887,64 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", + "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", + "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", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", + "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", + "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", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.10.tgz", + "integrity": "sha512-7e6NIL+lay71PdKmkCeSJPQ6xkmc170Kc1wynoulh9iBEpu2jnVIL4zJ95pjvOg+njS6Og7Bmw2fiKCuXzPGrw==", + "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.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", @@ -698,6 +960,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", + "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", + "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.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", @@ -729,19 +1005,19 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.10.tgz", - "integrity": "sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.12.tgz", + "integrity": "sha512-knUtPDxuaFDV7/vhKpzuhF1z8rs7ZZoGXPhu6pet/FmRNgi+vsHjO61mhiAH5ygbId7Nk0sM3G1wxUfSVt0QFA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.0", + "@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.3", + "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.8", @@ -770,16 +1046,30 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", + "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "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-user-agent": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.10.tgz", - "integrity": "sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.12.tgz", + "integrity": "sha512-iv9toQZloEJp+dIuOr+1XWGmBMLU9c2qqNtgscfnEBZnUq3qKdBJHmLTKoq3mkLlV+41GrCWn8LrOunc6OlP6g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.990.0", - "@smithy/core": "^3.23.0", + "@aws-sdk/util-endpoints": "3.996.0", + "@smithy/core": "^3.23.2", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" @@ -788,45 +1078,61 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.0.tgz", + "integrity": "sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==", + "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/nested-clients": { - "version": "3.990.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.990.0.tgz", - "integrity": "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==", + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.0.tgz", + "integrity": "sha512-edZwYLgRI0rZlH9Hru9+JvTsR1OAxuCRGEtJohkZneIJ5JIYzvFoMR1gaASjl1aPKRhjkCv8SSAb7hes5a1GGA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.10", + "@aws-sdk/core": "^3.973.12", "@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/middleware-user-agent": "^3.972.12", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-endpoints": "3.996.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.972.11", "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", + "@smithy/core": "^3.23.2", "@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-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.3", + "@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.30", - "@smithy/util-defaults-mode-node": "^4.2.33", + "@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", @@ -837,6 +1143,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.0.tgz", + "integrity": "sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==", + "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/region-config-resolver": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", @@ -871,13 +1193,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.990.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.990.0.tgz", - "integrity": "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==", + "version": "3.996.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.996.0.tgz", + "integrity": "sha512-jzBmlG97hYPdHjFs7G11fBgVArcwUrZX+SbGeQMph7teEWLDqIruKV+N0uzxFJF2GJJJ0UnMaKhv3PcXMltySg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.10", - "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/nested-clients": "3.996.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -954,12 +1276,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.8.tgz", - "integrity": "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.11.tgz", + "integrity": "sha512-pQr35pSZANfUb0mJ9H87pziJQ39jW1D7xFRwh36eWfrEclbKoIqrzpOIVz49o1Jq9ZQzOtjS7rQVvt7V4w5awA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.12", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", @@ -978,13 +1300,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", - "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", + "integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.4", + "fast-xml-parser": "5.3.6", "tslib": "^2.6.2" }, "engines": { @@ -2686,21 +3008,46 @@ "type-detect": "4.0.8" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.9.tgz", + "integrity": "sha512-6YGSygFmck1vMjzSxbjEPKMm1xWUr2+w+F8kWVc8rqKQYd1C5zZftvxGii4ti4Mh5ulIXZtAUoXS88Hhu6fkjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.1.tgz", + "integrity": "sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", - "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.2.tgz", + "integrity": "sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -2708,16 +3055,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", - "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.7.tgz", + "integrity": "sha512-RISbtc12JKdFRYadt2kW12Cp6XCSU00uFaBZPZqInNVSrRdJFPY/S6nd6/sV7+ySTgGPiKrERtnimEFI6sSweQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/types": "^4.12.1", + "@smithy/util-config-provider": "^4.2.1", + "@smithy/util-endpoints": "^3.2.9", + "@smithy/util-middleware": "^4.2.9", "tslib": "^2.6.2" }, "engines": { @@ -2725,20 +3072,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz", - "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==", + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.4.tgz", + "integrity": "sha512-IH7G3hWxUhd2Z6HtvjZ1EiyDBCRYRr2sngOB9KUWf96XQ8JP2O5ascUH6TouW5YCIMFaVnKADEscM/vUfI3TvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@smithy/middleware-serde": "^4.2.10", + "@smithy/protocol-http": "^5.3.9", + "@smithy/types": "^4.12.1", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-middleware": "^4.2.9", + "@smithy/util-stream": "^4.5.14", + "@smithy/util-utf8": "^4.2.1", + "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" }, "engines": { @@ -2746,15 +3093,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.9.tgz", + "integrity": "sha512-Jf723a38EGAzWHxJHzb9DtBq7lrvdJlkCAPWQdN/oiznovx5yWXCFCVspzDe8JU6b+k9hJXYB5duFZpb+3mB6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/property-provider": "^4.2.9", + "@smithy/types": "^4.12.1", + "@smithy/url-parser": "^4.2.9", "tslib": "^2.6.2" }, "engines": { @@ -2832,15 +3179,30 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.10.tgz", + "integrity": "sha512-qF4EcrEtEf2P6f2kGGuSVe1lan26cn7PsWJBC3vZJ6D16Fm5FSN06udOMVoW6hjzQM3W7VDFwtyUG2szQY50dA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/protocol-http": "^5.3.9", + "@smithy/querystring-builder": "^4.2.9", + "@smithy/types": "^4.12.1", + "@smithy/util-base64": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.10.tgz", + "integrity": "sha512-2lZvvcwTaXq6cGOcX72Ej9WU+z3T/C5NOuqIm+zLD3MlExRp9kW/Qa/p66NbBM74X0BdrdvpsMYwlkhtvHrxaQ==", + "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": { @@ -2862,6 +3224,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.9.tgz", + "integrity": "sha512-WFPbY/TysowQuoWR0xOCPT3RH1KMpThUWjx75RAMLkDlTYTANzyPHZiDRslf2e5bTmCYcqCshN7up70Ic/Zqug==", + "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.8", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", @@ -2876,9 +3252,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "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" @@ -2916,18 +3292,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz", - "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==", + "version": "4.4.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.18.tgz", + "integrity": "sha512-4OS3TP3IWZysT8KlSG/UwfKdelJmuQ2CqVNfrkjm2Rsm146/DuSTfXiD1ulgWpp9L6lJmPYfWTp7/m4b4dQSdQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.0", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-middleware": "^4.2.8", + "@smithy/core": "^3.23.4", + "@smithy/middleware-serde": "^4.2.10", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/shared-ini-file-loader": "^4.4.4", + "@smithy/types": "^4.12.1", + "@smithy/url-parser": "^4.2.9", + "@smithy/util-middleware": "^4.2.9", "tslib": "^2.6.2" }, "engines": { @@ -2935,19 +3311,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.31", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz", - "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==", + "version": "4.4.35", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.35.tgz", + "integrity": "sha512-sz+Th9ofKypOtaboPTcyZtIfCs2LNb84bzxEhPffCElyMorVYDBdeGzxYqSLC6gWaZUqpPSbj5F6TIxYUlSCfQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/service-error-classification": "^4.2.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/uuid": "^1.1.0", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/protocol-http": "^5.3.9", + "@smithy/service-error-classification": "^4.2.9", + "@smithy/smithy-client": "^4.11.7", + "@smithy/types": "^4.12.1", + "@smithy/util-middleware": "^4.2.9", + "@smithy/util-retry": "^4.2.9", + "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" }, "engines": { @@ -2955,13 +3331,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", - "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.10.tgz", + "integrity": "sha512-BQsdoi7ma4siJAzD0S6MedNPhiMcTdTLUqEUjrHeT1TJppBKWnwqySg34Oh/uGRhJeBd1sAH2t5tghBvcyD6tw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@smithy/protocol-http": "^5.3.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -2969,12 +3345,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", - "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.9.tgz", + "integrity": "sha512-pid7ksBr7nm0X/3paIlGo9Fh3UK1pQ5yH0007tBmdkVvv+AsBZAOzC2dmLhlzDWKkSB+ZCiiyDArjAW3klkbMg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -2982,14 +3358,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", - "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.9.tgz", + "integrity": "sha512-EjdDTVGnnyJ9y8jXIfkF45UUZs21/Pp8xaMTZySLoC0xI3EhY7jq4co3LQnhh/bB6VVamd9ELpYJWLDw2ANhZA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@smithy/property-provider": "^4.2.9", + "@smithy/shared-ini-file-loader": "^4.4.4", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -2997,15 +3373,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", - "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.11.tgz", + "integrity": "sha512-kQNJFwzYA9y+Fj3h9t1ToXYOJBobwUVEc6/WX45urJXyErgG0WOsres8Se8BAiFCMe8P06OkzRgakv7bQ5S+6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/abort-controller": "^4.2.9", + "@smithy/protocol-http": "^5.3.9", + "@smithy/querystring-builder": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3013,12 +3389,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", - "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.9.tgz", + "integrity": "sha512-ibHwLxq4KlbfueoNxMNrZkG+O7V/5XKrewhDGYn0p9DYKCsdsofuWHKdX3QW4zHlAUfLStqdCUSDi/q/9WSjwA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3026,12 +3402,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", - "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.9.tgz", + "integrity": "sha512-PRy4yZqsKI3Eab8TLc16Dj2NzC4dnw/8E95+++Jc+wwlkjBpAq3tNLqkLHMmSvDfxKQ+X5PmmCYt+rM/GcMKPA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3039,13 +3415,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", - "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.9.tgz", + "integrity": "sha512-/AIDaq0+ehv+QfeyAjCUFShwHIt+FA1IodsV/2AZE5h4PUZcQYv5sjmy9V67UWfsBoTjOPKUFYSRfGoNW9T2UQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-uri-escape": "^4.2.0", + "@smithy/types": "^4.12.1", + "@smithy/util-uri-escape": "^4.2.1", "tslib": "^2.6.2" }, "engines": { @@ -3053,12 +3429,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", - "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.9.tgz", + "integrity": "sha512-kZ9AHhrYTea3UoklXudEnyA4duy9KAWERC28+ft8y8HIhR3yGsjv1PFTgzMpB+5L4tQKXNTwFbVJMeRK20vpHQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3066,24 +3442,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", - "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.9.tgz", + "integrity": "sha512-DYYd4xrm9Ozik+ZT4f5ZqSXdzscVHF/tFCzqieIFcLrjRDxWSgRtvtXOohJGoniLfPcBcy5ltR3tp2Lw4/d9ag==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0" + "@smithy/types": "^4.12.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", - "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.4.tgz", + "integrity": "sha512-tA5Cm11BHQCk/67y6VPIWydLh/pMY90jqOEWIr/2VAzTOoDwGpwp0C/AuHBc3/xWSOA5m5PXLN+lIOrsnTm/PQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3110,17 +3486,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz", - "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==", + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.7.tgz", + "integrity": "sha512-gQP2J3qB/Wmc26gdmB8gA6zq2o2spG5sEU3o7TaTATBJEk29sYGWdEFoGEy91BczSpifTo0DQhVYjZXBEVcrpA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.0", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.12", + "@smithy/core": "^3.23.4", + "@smithy/middleware-endpoint": "^4.4.18", + "@smithy/middleware-stack": "^4.2.9", + "@smithy/protocol-http": "^5.3.9", + "@smithy/types": "^4.12.1", + "@smithy/util-stream": "^4.5.14", "tslib": "^2.6.2" }, "engines": { @@ -3128,9 +3504,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.1.tgz", + "integrity": "sha512-ow30Ze/DD02KH2p0eMyIF2+qJzGyNb0kFrnTRtPpuOkQ4hrgvLdaU4YC6r/K8aOrCML4FH0Cmm0aI4503L1Hwg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3140,13 +3516,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", - "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.9.tgz", + "integrity": "sha512-gYs8FrnwKoIvL+GyPz6VvweCkrXqHeD+KnOAxB+NFy6mLr4l75lFrn3dZ413DG0K2TvFtN7L43x7r8hyyohYdg==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/querystring-parser": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3154,13 +3530,26 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "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.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64/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", "tslib": "^2.6.2" }, "engines": { @@ -3168,9 +3557,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "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" @@ -3205,9 +3594,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "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" @@ -3217,14 +3606,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.30", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz", - "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==", + "version": "4.3.34", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.34.tgz", + "integrity": "sha512-m75CH7xaVG8ErlnfXsIBLrgVrApejrvUpohr41CMdeWNcEu/Ouvj9fbNA7oW9Qpr0Awf+BmDRrYx72hEKgY+FQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", + "@smithy/property-provider": "^4.2.9", + "@smithy/smithy-client": "^4.11.7", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3232,17 +3621,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.33", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz", - "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==", + "version": "4.2.37", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.37.tgz", + "integrity": "sha512-1LcAt0PV1dletxiGwcw2IJ8vLNhfkir02NTi1i/CFCY2ObtM5wDDjn/8V2dbPrbyoh6OTFH+uayI1rSVRBMT3A==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.6", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", + "@smithy/config-resolver": "^4.4.7", + "@smithy/credential-provider-imds": "^4.2.9", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/property-provider": "^4.2.9", + "@smithy/smithy-client": "^4.11.7", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3250,13 +3639,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", - "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.9.tgz", + "integrity": "sha512-9FTqTzKxCFelCKdtHb22BTbrLgw7tTI+D6r/Ci/njI0tzqWLQctS0uEDTzraCR5K6IJItfFp1QmESlBytSpRhQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", + "@smithy/node-config-provider": "^4.3.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3264,9 +3653,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "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" @@ -3276,12 +3665,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", - "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.9.tgz", + "integrity": "sha512-pfnZneJ1S9X3TRmg2l3pG11Pvx2BW9O3NFhUN30llrK/yUKu8WbqMTx4/CzED+qKBYw0//ntUT00hvmaG+nLgA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3289,13 +3678,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", - "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.9.tgz", + "integrity": "sha512-79hfhL/oxP40SCXJGfjfE9pjbUVfHhXZFpCWXTHqXSluzaVy7jwWs9Ui7lLbfDBSp+7i+BIwgeVIRerbIRWN6g==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/service-error-classification": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3303,18 +3692,31 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.12", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", - "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.14.tgz", + "integrity": "sha512-IOBEiJTOltSx6MAfwkx/GSVM8/UCJxdtw13haP5OEL543lb1DN6TAypsxv+qcj4l/rKcpapbS6zK9MQGBOhoaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.10", + "@smithy/node-http-handler": "^4.4.11", + "@smithy/types": "^4.12.1", + "@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", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream/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", "tslib": "^2.6.2" }, "engines": { @@ -3322,9 +3724,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "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" @@ -3334,12 +3736,39 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", + "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8/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", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.9.tgz", + "integrity": "sha512-/PYREwfBaj3fV5V4PfMksYj/WKwrjQ4gW/yo8KLpZSkAdBEkvXd68hovAubrw+n+Q8Rcr9XRn6uzcoQCEhrNFQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -3347,9 +3776,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "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" @@ -4277,7 +4706,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4285,7 +4713,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4949,9 +5376,12 @@ "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", @@ -5017,7 +5447,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5028,7 +5457,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5460,7 +5888,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/entities": { @@ -5683,7 +6110,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6276,8 +6702,6 @@ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -6721,9 +7145,9 @@ "license": "BSD-3-Clause" }, "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==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", "funding": [ { "type": "github", @@ -6949,7 +7373,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7571,7 +7994,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9764,7 +10186,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10226,7 +10647,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10346,7 +10766,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11694,7 +12113,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -11768,7 +12186,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -11781,7 +12198,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -11798,7 +12214,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -11823,15 +12238,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "tests/integration": { "name": "nhs-notify-client-callbacks-integration-tests", "version": "0.0.1", @@ -11847,6 +12253,61 @@ "jest": "^29.7.0", "typescript": "^5.8.2" } + }, + "tools/client-subscriptions-management": { + "version": "0.0.1", + "dependencies": { + "@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" + } + }, + "tools/client-subscriptions-management/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "tools/client-subscriptions-management/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" + } + }, + "tools/client-subscriptions-management/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" + }, + "tools/client-subscriptions-management/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" } } } diff --git a/package.json b/package.json index a3afaac..8f8a242 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.27.0", @@ -17,6 +18,7 @@ "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-prettier": "^5.4.0", "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-security": "^3.0.1", "eslint-plugin-sonarjs": "^3.0.2", "eslint-plugin-sort-destructure-keys": "^2.0.0", @@ -51,6 +53,7 @@ "workspaces": [ "lambdas/client-transform-filter-lambda", "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 6543678..d1071a5 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.* -sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, **/jest.config.ts +sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, 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..ce11776 --- /dev/null +++ b/scripts/deploy_client_subscriptions.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat < \ + [--terraform-apply] \ + [--environment --group --project --tf-region ] \ + -- + +Examples: + ./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 \ + --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/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..88f2d14 --- /dev/null +++ b/tools/client-subscriptions-management/package.json @@ -0,0 +1,28 @@ +{ + "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": { + "@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..b19d687 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -0,0 +1,124 @@ +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("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..f163c4c --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts @@ -0,0 +1,369 @@ +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("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..afa7bd5 --- /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..93d4763 --- /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..ef55a38 --- /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..cb87e2c --- /dev/null +++ b/tools/client-subscriptions-management/src/constants.ts @@ -0,0 +1,41 @@ +export const MESSAGE_STATUSES = [ + "CREATED", + "PENDING_ENRICHMENT", + "ENRICHED", + "SENDING", + "DELIVERED", + "FAILED", +] as const; + +export const CHANNEL_STATUSES = [ + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "SKIPPED", +] as const; + +export const SUPPLIER_STATUSES = [ + "DELIVERED", + "READ", + "NOTIFICATION_ATTEMPTED", + "UNNOTIFIED", + "REJECTED", + "NOTIFIED", + "RECEIVED", + "PERMANENT_FAILURE", + "TEMPORARY_FAILURE", + "TECHNICAL_FAILURE", + "ACCEPTED", + "CANCELLED", + "PENDING_VIRUS_CHECK", + "VALIDATION_FAILED", + "UNKNOWN", +] as const; + +export const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const; + +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]; 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..82f7ea7 --- /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..3888cf4 --- /dev/null +++ b/tools/client-subscriptions-management/src/infra/client-subscription-repository.ts @@ -0,0 +1,221 @@ +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)}`, + ); + } + + 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..e55581b --- /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..9f3a76b --- /dev/null +++ b/tools/client-subscriptions-management/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2024" + ], + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist", + "paths": { + "src/*": [ + "src/*" + ] + }, + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a248acd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "lambdas/*/src/**/*", + "scripts/*/src/**/*", + "tools/*/src/**/*", + "src/**/*", + "tests/**/*" + ] +}