diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf b/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf index 062323a8..3d0756e6 100644 --- a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf @@ -95,7 +95,7 @@ resource "aws_lambda_function_url" "mock_webhook" { resource "aws_lambda_permission" "mock_webhook_function_url" { count = var.deploy_mock_webhook ? 1 : 0 - statement_id = "FunctionURLAllowPublicAccess" + statement_id_prefix = "FunctionURLAllowPublicAccess" action = "lambda:InvokeFunctionUrl" function_name = module.mock_webhook_lambda[0].function_name principal = "*" @@ -103,9 +103,9 @@ resource "aws_lambda_permission" "mock_webhook_function_url" { } resource "aws_lambda_permission" "mock_webhook_function_invoke" { - count = var.deploy_mock_webhook ? 1 : 0 - statement_id = "FunctionURLAllowInvokeAction" - action = "lambda:InvokeFunction" - function_name = module.mock_webhook_lambda[0].function_name - principal = "*" + count = var.deploy_mock_webhook ? 1 : 0 + statement_id_prefix = "FunctionURLAllowInvokeAction" + action = "lambda:InvokeFunction" + function_name = module.mock_webhook_lambda[0].function_name + principal = "*" } diff --git a/jest.config.base.ts b/jest.config.base.ts index f057e3ea..f9ed903d 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -3,6 +3,7 @@ import type { Config } from "jest"; export const baseJestConfig: Config = { preset: "ts-jest", clearMocks: true, + silent: true, collectCoverage: true, coverageDirectory: "./.reports/unit/coverage", coverageProvider: "v8", diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts b/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts new file mode 100644 index 00000000..9491292c --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts @@ -0,0 +1,94 @@ +import type { + CallbackTarget, + Channel, + ChannelStatus, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatus, + MessageStatusSubscriptionConfiguration, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; + +export const DEFAULT_TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +type TargetOverrides = Partial & { + apiKey?: Partial; +}; + +export const createTarget = ( + overrides: TargetOverrides = {}, +): CallbackTarget => ({ + targetId: DEFAULT_TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { + headerName: "x-api-key", + headerValue: "secret", + ...overrides.apiKey, + }, + ...overrides, +}); + +export const createMessageStatusSubscription = ( + statuses: MessageStatus[] = ["DELIVERED"], + overrides: Partial = {}, +): MessageStatusSubscriptionConfiguration => ({ + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: statuses, + targetIds: [DEFAULT_TARGET_ID], + ...overrides, +}); + +export const createChannelStatusSubscription = ( + channelStatuses: ChannelStatus[] = ["DELIVERED"], + supplierStatuses: SupplierStatus[] = ["delivered"], + channelType: Channel = "EMAIL", + overrides: Partial = {}, +): ChannelStatusSubscriptionConfiguration => ({ + subscriptionId: "00000000-0000-0000-0000-000000000002", + subscriptionType: "ChannelStatus", + channelType, + channelStatuses, + supplierStatuses, + targetIds: [DEFAULT_TARGET_ID], + ...overrides, +}); + +export const createClientSubscriptionConfig = ( + clientId = "client-1", + overrides: Partial = {}, +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [], + targets: [], + ...overrides, +}); + +export const createMessageStatusConfig = ( + statuses: MessageStatus[] = ["DELIVERED"], + clientId = "client-1", +): ClientSubscriptionConfiguration => + createClientSubscriptionConfig(clientId, { + subscriptions: [createMessageStatusSubscription(statuses)], + targets: [createTarget()], + }); + +export const createChannelStatusConfig = ( + channelStatuses: ChannelStatus[] = ["DELIVERED"], + supplierStatuses: SupplierStatus[] = ["delivered"], + clientId = "client-1", + channelType: Channel = "EMAIL", +): ClientSubscriptionConfiguration => + createClientSubscriptionConfig(clientId, { + subscriptions: [ + createChannelStatusSubscription( + channelStatuses, + supplierStatuses, + channelType, + ), + ], + targets: [createTarget()], + }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts index dbacd922..c524ef3c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts @@ -53,6 +53,7 @@ import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; import { GetParameterCommand } from "@aws-sdk/client-ssm"; import type { SQSRecord } from "aws-lambda"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { createS3Client } from "services/config-loader-service"; import { applicationsMapService, configLoaderService, handler } from ".."; @@ -73,27 +74,8 @@ const makeSqsRecord = (body: object): SQSRecord => ({ awsRegion: "eu-west-2", }); -const createValidConfig = (clientId: string) => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000001", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: ["DELIVERED", "FAILED"], - }, -]; +const createValidConfig = (clientId: string) => + createMessageStatusConfig(["DELIVERED", "FAILED"], clientId); const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({ specversion: "1.0", 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 b8d02995..2710164e 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -13,63 +13,57 @@ import type { ConfigLoader } from "services/config-loader"; import type { ApplicationsMapService } from "services/ssm-applications-map"; import { ObservabilityService } from "services/observability"; import { ConfigLoaderService } from "services/config-loader-service"; +import { + DEFAULT_TARGET_ID, + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "__tests__/helpers/client-subscription-fixtures"; import { createHandler } from ".."; jest.mock("aws-embedded-metrics"); -const stubTarget = { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000001", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { HeaderName: "x-api-key", HeaderValue: "test-api-key" }, -}; - const createPassthroughConfigLoader = (): ConfigLoader => ({ - loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ - { - SubscriptionType: "MessageStatus", - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [stubTarget], - MessageStatuses: [ - "DELIVERED", - "FAILED", - "PENDING", - "SENDING", - "TECHNICAL_FAILURE", - "PERMANENT_FAILURE", - ], - }, - { - SubscriptionType: "ChannelStatus", - SubscriptionId: "00000000-0000-0000-0000-000000000002", - ClientId: clientId, - Targets: [stubTarget], - ChannelType: "NHSAPP", - ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], - SupplierStatuses: [ - "delivered", - "permanent_failure", - "temporary_failure", + loadClientConfig: jest + .fn() + .mockImplementation(async (clientId: string) => ({ + ...createClientSubscriptionConfig(clientId), + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { + subscriptionId: "00000000-0000-0000-0000-000000000001", + targetIds: [DEFAULT_TARGET_ID], + }), + createChannelStatusSubscription( + ["DELIVERED"], + ["delivered"], + "NHSAPP", + { + subscriptionId: "00000000-0000-0000-0000-000000000002", + targetIds: [DEFAULT_TARGET_ID], + }, + ), + createChannelStatusSubscription( + ["FAILED"], + ["permanent_failure"], + "SMS", + { + subscriptionId: "00000000-0000-0000-0000-000000000003", + targetIds: [DEFAULT_TARGET_ID], + }, + ), ], - }, - { - SubscriptionType: "ChannelStatus", - SubscriptionId: "00000000-0000-0000-0000-000000000003", - ClientId: clientId, - Targets: [stubTarget], - ChannelType: "SMS", - ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], - SupplierStatuses: [ - "delivered", - "permanent_failure", - "temporary_failure", + targets: [ + createTarget({ + invocationEndpoint: "https://example.com/webhook", + apiKey: { + headerName: "x-api-key", + headerValue: "test-api-key", + }, + }), ], - }, - ]), + })), }) as unknown as ConfigLoader; const makeStubConfigLoaderService = (): ConfigLoaderService => { @@ -185,6 +179,59 @@ describe("Lambda handler", () => { ); }); + it("should record filtering-matched with matched subscription target IDs only", async () => { + const customConfigLoader = { + loadClientConfig: jest.fn().mockResolvedValue( + createClientSubscriptionConfig("client-abc-123", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { + targetIds: ["target-match"], + }), + ], + targets: [ + createTarget({ targetId: "target-match" }), + createTarget({ targetId: "target-other" }), + ], + }), + ), + } as unknown as ConfigLoader; + + const handlerWithMultipleTargets = createHandler({ + createObservabilityService: () => + new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: () => + ({ getLoader: () => customConfigLoader }) as ConfigLoaderService, + createApplicationsMapService: makeStubApplicationsMapService, + }); + + const sqsMessage: SQSRecord = { + messageId: "sqs-msg-id-targets", + receiptHandle: "receipt-handle-targets", + body: JSON.stringify(validMessageStatusEvent), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "mock-md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", + awsRegion: "eu-west-2", + }; + + await handlerWithMultipleTargets([sqsMessage]); + + expect(mockLogger.info).toHaveBeenCalledWith( + "Callback lifecycle: filtering-matched", + expect.objectContaining({ + subscriptionType: "MessageStatus", + targetIds: ["target-match"], + }), + ); + }); + it("should handle batch of SQS messages from EventBridge Pipes", async () => { const sqsMessages: SQSRecord[] = [ { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts index cab5893a..6199b92c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -1,23 +1,25 @@ import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, +} from "__tests__/helpers/client-subscription-fixtures"; import { ConfigCache } from "services/config-cache"; +const createConfig = (): ClientSubscriptionConfiguration => + createClientSubscriptionConfig("client-1", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { targetIds: [] }), + ], + }); + describe("ConfigCache", () => { it("stores and retrieves configuration", () => { const cache = new ConfigCache(60_000); - const config: ClientSubscriptionConfiguration = [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [], - SubscriptionType: "MessageStatus" as const, - MessageStatuses: ["DELIVERED"], - }, - ]; + const config = createConfig(); cache.set("client-1", config); - const result = cache.get("client-1"); - expect(result).toEqual(config); + expect(cache.get("client-1")).toEqual(config); }); it("returns undefined for non-existent key", () => { @@ -32,20 +34,12 @@ describe("ConfigCache", () => { jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); const cache = new ConfigCache(1000); // 1 second TTL - const config: ClientSubscriptionConfiguration = [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [], - SubscriptionType: "MessageStatus" as const, - MessageStatuses: ["DELIVERED"], - }, - ]; + const config = createConfig(); cache.set("client-1", config); + expect(cache.get("client-1")).toEqual(config); - // Advance time past expiry - jest.advanceTimersByTime(1500); + jest.advanceTimersByTime(1001); const result = cache.get("client-1"); @@ -56,19 +50,14 @@ describe("ConfigCache", () => { it("clears all entries", () => { const cache = new ConfigCache(60_000); - const config: ClientSubscriptionConfiguration = [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [], - SubscriptionType: "MessageStatus" as const, - MessageStatuses: ["DELIVERED"], - }, - ]; + const config = createConfig(); cache.set("client-1", config); cache.set("client-2", config); + expect(cache.get("client-1")).toEqual(config); + expect(cache.get("client-2")).toEqual(config); + cache.clear(); expect(cache.get("client-1")).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 index 044035df..0f7aafa9 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -1,4 +1,5 @@ import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; import { ConfigValidationError } from "services/validators/config-validator"; @@ -16,27 +17,8 @@ const mockBody = (json: string) => ({ transformToString: jest.fn().mockResolvedValue(json), }); -const createValidConfig = (clientId: string) => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000001", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: ["DELIVERED"], - }, -]; +const createValidConfig = (clientId: string) => + createMessageStatusConfig(["DELIVERED"], clientId); const createLoader = (send: jest.Mock) => new ConfigLoader({ @@ -89,7 +71,7 @@ describe("ConfigLoader", () => { it("throws when configuration fails validation", async () => { const send = jest.fn().mockResolvedValue({ - Body: mockBody(JSON.stringify([{ SubscriptionType: "MessageStatus" }])), + Body: mockBody(JSON.stringify({ subscriptionType: "MessageStatus" })), }); const loader = createLoader(send); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts index 3cf95b9c..81af7f04 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts @@ -1,7 +1,11 @@ import { S3Client } from "@aws-sdk/client-s3"; +import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; +const makeConfig = (messageStatuses: string[]) => + createMessageStatusConfig(messageStatuses as never); + describe("config update component", () => { it("reloads configuration after cache expiry", async () => { jest.useFakeTimers(); @@ -11,56 +15,16 @@ describe("config update component", () => { .fn() .mockResolvedValueOnce({ Body: { - transformToString: jest.fn().mockResolvedValue( - JSON.stringify([ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: ["DELIVERED"], - }, - ]), - ), + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(makeConfig(["DELIVERED"]))), }, }) .mockResolvedValueOnce({ Body: { - transformToString: jest.fn().mockResolvedValue( - JSON.stringify([ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: ["FAILED"], - }, - ]), - ), + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(makeConfig(["FAILED"]))), }, }); @@ -72,18 +36,18 @@ describe("config update component", () => { }); const first = await loader.loadClientConfig("client-1"); - const firstMessage = first?.find( - (subscription) => subscription.SubscriptionType === "MessageStatus", + const firstMessage = first?.subscriptions.find( + (subscription) => subscription.subscriptionType === "MessageStatus", ); - expect(firstMessage?.MessageStatuses).toEqual(["DELIVERED"]); + expect(firstMessage?.messageStatuses).toEqual(["DELIVERED"]); jest.advanceTimersByTime(1500); const second = await loader.loadClientConfig("client-1"); - const secondMessage = second?.find( - (subscription) => subscription.SubscriptionType === "MessageStatus", + const secondMessage = second?.subscriptions.find( + (subscription) => subscription.subscriptionType === "MessageStatus", ); - expect(secondMessage?.MessageStatuses).toEqual(["FAILED"]); + expect(secondMessage?.messageStatuses).toEqual(["FAILED"]); jest.useRealTimers(); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts index 8c6eefab..a6280b57 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts @@ -1,11 +1,9 @@ import type { - ChannelStatus, ChannelStatusData, - ClientSubscriptionConfiguration, StatusPublishEvent, - SupplierStatus, } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { createChannelStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; jest.mock("services/logger", () => ({ @@ -34,34 +32,6 @@ const createBaseEvent = ( data: notifyData, }); -const createChannelStatusConfig = ( - channelStatuses: ChannelStatus[], - supplierStatuses: SupplierStatus[], - clientId = "client-1", -): ClientSubscriptionConfiguration => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: channelStatuses, - SupplierStatuses: supplierStatuses, - }, -]; - const createChannelStatusData = ( overrides: Partial = {}, ): ChannelStatusData => ({ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts index ee3a0707..a4b3b7d9 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts @@ -1,10 +1,9 @@ import type { - ClientSubscriptionConfiguration, - MessageStatus, MessageStatusData, StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; jest.mock("services/logger", () => ({ @@ -33,31 +32,6 @@ const createBaseEvent = ( data: notifyData, }); -const createMessageStatusConfig = ( - statuses: MessageStatus[], - clientId = "client-1", -): ClientSubscriptionConfiguration => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: statuses, - }, -]; - const createMessageStatusData = ( overrides: Partial = {}, ): MessageStatusData => ({ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index 2df22a23..c302fda1 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -2,13 +2,19 @@ import type { Channel, ChannelStatus, ChannelStatusData, - ClientSubscriptionConfiguration, MessageStatus, MessageStatusData, StatusPublishEvent, SupplierStatus, } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { + createChannelStatusConfig, + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusConfig, + createMessageStatusSubscription, +} from "__tests__/helpers/client-subscription-fixtures"; import { TransformationError } from "services/error-handler"; import { evaluateSubscriptionFilters } from "services/subscription-filter"; @@ -83,60 +89,6 @@ const createChannelStatusEvent = ( }, }); -const createMessageStatusConfig = ( - clientId: string, - statuses: MessageStatus[], -): ClientSubscriptionConfiguration => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "MessageStatus", - MessageStatuses: statuses, - }, -]; - -const createChannelStatusConfig = ( - clientId: string, - channelType: Channel, - channelStatuses: ChannelStatus[], - supplierStatuses: SupplierStatus[], -): ClientSubscriptionConfiguration => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000002", - ClientId: clientId, - Targets: [ - { - Type: "API", - TargetId: "target", - 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", () => { @@ -153,19 +105,20 @@ describe("evaluateSubscriptionFilters", () => { 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 config = createMessageStatusConfig(["DELIVERED"], "client-1"); const result = evaluateSubscriptionFilters(event, config); expect(result).toEqual({ matched: true, subscriptionType: "MessageStatus", + targetIds: ["00000000-0000-4000-8000-000000000001"], }); }); it("returns matched false when status does not match subscription", () => { const event = createMessageStatusEvent("client-1", "FAILED"); - const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + const config = createMessageStatusConfig(["DELIVERED"], "client-1"); const result = evaluateSubscriptionFilters(event, config); @@ -174,6 +127,28 @@ describe("evaluateSubscriptionFilters", () => { subscriptionType: "MessageStatus", }); }); + + it("returns only matched subscription target IDs", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + const config = createClientSubscriptionConfig("client-1", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { + targetIds: ["target-a"], + }), + createMessageStatusSubscription(["FAILED"], { + targetIds: ["target-b"], + }), + ], + }); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "MessageStatus", + targetIds: ["target-a"], + }); + }); }); describe("when event is ChannelStatus", () => { @@ -187,10 +162,10 @@ describe("evaluateSubscriptionFilters", () => { "notified", ); const config = createChannelStatusConfig( - "client-1", - "EMAIL", ["DELIVERED"], ["delivered"], + "client-1", + "EMAIL", ); const result = evaluateSubscriptionFilters(event, config); @@ -198,6 +173,7 @@ describe("evaluateSubscriptionFilters", () => { expect(result).toEqual({ matched: true, subscriptionType: "ChannelStatus", + targetIds: ["00000000-0000-4000-8000-000000000001"], }); }); @@ -211,10 +187,10 @@ describe("evaluateSubscriptionFilters", () => { "delivered", // previousSupplierStatus (no change) ); const config = createChannelStatusConfig( - "client-1", - "EMAIL", ["DELIVERED"], ["delivered"], + "client-1", + "EMAIL", ); const result = evaluateSubscriptionFilters(event, config); @@ -224,6 +200,45 @@ describe("evaluateSubscriptionFilters", () => { subscriptionType: "ChannelStatus", }); }); + + it("returns only matched channel subscription target IDs", () => { + const event = createChannelStatusEvent( + "client-1", + "SMS", + "FAILED", + "permanent_failure", + "DELIVERED", + "delivered", + ); + const config = createClientSubscriptionConfig("client-1", { + subscriptions: [ + createChannelStatusSubscription( + ["DELIVERED"], + ["delivered"], + "EMAIL", + { + targetIds: ["target-email"], + }, + ), + createChannelStatusSubscription( + ["FAILED"], + ["permanent_failure"], + "SMS", + { + targetIds: ["target-sms"], + }, + ), + ], + }); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "ChannelStatus", + targetIds: ["target-sms"], + }); + }); }); describe("when event type is unknown", () => { @@ -232,7 +247,7 @@ describe("evaluateSubscriptionFilters", () => { ...createMessageStatusEvent("client-1", "DELIVERED"), type: "unknown-event-type", } as StatusPublishEvent; - const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + const config = createMessageStatusConfig(["DELIVERED"], "client-1"); expect(() => evaluateSubscriptionFilters(event, config)).toThrow( new TransformationError("Unsupported event type: unknown-event-type"), diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts index ad4f680f..ea17d3b3 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -1,51 +1,23 @@ import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "__tests__/helpers/client-subscription-fixtures"; import { ConfigValidationError, validateClientConfig, } from "services/validators/config-validator"; -const createValidConfig = (): ClientSubscriptionConfiguration => [ - { - SubscriptionId: "00000000-0000-0000-0000-000000000001", - ClientId: "client-1", - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, +const createValidConfig = (): ClientSubscriptionConfiguration => + createClientSubscriptionConfig("client-1", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"]), + createChannelStatusSubscription(["DELIVERED"], ["read"]), ], - SubscriptionType: "MessageStatus", - MessageStatuses: ["DELIVERED"], - }, - { - SubscriptionId: "00000000-0000-0000-0000-000000000002", - ClientId: "client-1", - Targets: [ - { - Type: "API", - TargetId: "target", - InvocationEndpoint: "https://example.com", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - SubscriptionType: "ChannelStatus", - ChannelType: "EMAIL", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["read"], - }, -]; + targets: [createTarget()], + }); describe("validateClientConfig", () => { it("returns the config when valid", () => { @@ -54,28 +26,43 @@ describe("validateClientConfig", () => { 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", () => { + it("throws ConfigValidationError with formatted issues when schema parsing fails", () => { const config = createValidConfig(); - config[0].Targets[0].InvocationEndpoint = "http://example.com"; + config.subscriptions[0].targetIds = ["unknown-target-id"]; - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + expect(() => validateClientConfig(config)).toThrow( + new ConfigValidationError([ + { + path: "subscriptions[0].targetIds[0]", + message: 'targetId "unknown-target-id" not found in targets', + }, + ]), + ); }); - it("throws when subscription IDs are not unique", () => { + it("preserves all schema issues on the thrown error", () => { const config = createValidConfig(); - config[1].SubscriptionId = config[0].SubscriptionId; + config.targets[0].invocationEndpoint = "http://example.com"; + config.subscriptions[0].targetIds = ["unknown-target-id"]; - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); + let thrownError: unknown; - it("throws when InvocationEndpoint is not a valid URL", () => { - const config = createValidConfig(); - config[0].Targets[0].InvocationEndpoint = "not-a-url"; + try { + validateClientConfig(config); + } catch (error) { + thrownError = error; + } - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + expect(thrownError).toBeInstanceOf(ConfigValidationError); + expect((thrownError as ConfigValidationError).issues).toEqual([ + { + path: "targets[0].invocationEndpoint", + message: "Expected HTTPS URL", + }, + { + path: "subscriptions[0].targetIds[0]", + message: 'targetId "unknown-target-id" not found in targets', + }, + ]); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 8f38f101..31ff34c0 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -148,7 +148,7 @@ async function signBatch( } const clientConfig = configByClientId.get(clientId); - const apiKey = clientConfig?.[0]?.Targets?.[0]?.APIKey?.HeaderValue; + const apiKey = clientConfig?.targets?.[0]?.apiKey?.headerValue; if (!apiKey) { stats.recordFiltered(); logger.warn( @@ -215,25 +215,25 @@ async function filterBatch( for (const event of transformedEvents) { const { clientId } = event.data; + const correlationId = extractCorrelationId(event); const config = configByClientId.get(clientId); const filterResult = evaluateSubscriptionFilters(event, config); if (filterResult.matched) { filtered.push(event); - const targetIds = config?.flatMap((s) => - s.Targets.map((t) => t.TargetId), - ); observability.recordFilteringMatched({ + correlationId, clientId, eventType: event.type, subscriptionType: filterResult.subscriptionType, - targetIds, + targetIds: filterResult.targetIds, }); } else { stats.recordFiltered(); observability .getLogger() .info("Event filtered out - no matching subscription", { + correlationId, clientId, eventType: event.type, subscriptionType: filterResult.subscriptionType, diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts index d95e141b..567f1ff2 100644 --- a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -63,7 +63,7 @@ export class ConfigLoader { this.options.cache.set(clientId, validated); logger.info("Config loaded successfully from S3", { clientId, - subscriptionCount: validated.length, + subscriptionCount: validated.subscriptions.length, }); return validated; } catch (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 index 23a287f8..042f9116 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -12,9 +12,9 @@ type FilterContext = { }; const isChannelStatusSubscription = ( - subscription: ClientSubscriptionConfiguration[number], + subscription: ClientSubscriptionConfiguration["subscriptions"][number], ): subscription is ChannelStatusSubscriptionConfiguration => - subscription.SubscriptionType === "ChannelStatus"; + subscription.subscriptionType === "ChannelStatus"; export const matchesChannelStatusSubscription = ( config: ClientSubscriptionConfiguration, @@ -22,18 +22,18 @@ export const matchesChannelStatusSubscription = ( ): boolean => { const { notifyData } = context; - const matched = config - .filter((sub) => isChannelStatusSubscription(sub)) - .some((subscription) => { - if (subscription.ClientId !== notifyData.clientId) { - return false; - } + if (config.clientId !== notifyData.clientId) { + return false; + } - if (subscription.ChannelType !== notifyData.channel) { + const matched = config.subscriptions + .filter((subscription) => isChannelStatusSubscription(subscription)) + .some((subscription) => { + if (subscription.channelType !== notifyData.channel) { logger.debug("Channel status filter rejected: channel type mismatch", { clientId: notifyData.clientId, channel: notifyData.channel, - expectedChannel: subscription.ChannelType, + expectedChannel: subscription.channelType, }); return false; } @@ -42,13 +42,13 @@ export const matchesChannelStatusSubscription = ( const supplierStatusChanged = notifyData.previousSupplierStatus !== notifyData.supplierStatus; const clientSubscribedSupplierStatus = - subscription.SupplierStatuses.includes(notifyData.supplierStatus); + 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); + subscription.channelStatuses.includes(notifyData.channelStatus); const statusMatch = (supplierStatusChanged && clientSubscribedSupplierStatus) || @@ -67,8 +67,8 @@ export const matchesChannelStatusSubscription = ( previousSupplierStatus: notifyData.previousSupplierStatus, supplierStatusChanged, clientSubscribedSupplierStatus, - subscribedChannelStatuses: subscription.ChannelStatuses, - subscribedSupplierStatuses: subscription.SupplierStatuses, + subscribedChannelStatuses: subscription.channelStatuses, + subscribedSupplierStatuses: subscription.supplierStatuses, }, ); return false; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts index e79c33a3..a21a4dc3 100644 --- a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -12,9 +12,9 @@ type FilterContext = { }; const isMessageStatusSubscription = ( - subscription: ClientSubscriptionConfiguration[number], + subscription: ClientSubscriptionConfiguration["subscriptions"][number], ): subscription is MessageStatusSubscriptionConfiguration => - subscription.SubscriptionType === "MessageStatus"; + subscription.subscriptionType === "MessageStatus"; export const matchesMessageStatusSubscription = ( config: ClientSubscriptionConfiguration, @@ -22,17 +22,16 @@ export const matchesMessageStatusSubscription = ( ): boolean => { const { notifyData } = context; - const matched = config - .filter((sub) => isMessageStatusSubscription(sub)) - .some((subscription) => { - if (subscription.ClientId !== notifyData.clientId) { - return false; - } + if (config.clientId !== notifyData.clientId) { + return false; + } - // Check if message status changed AND client is subscribed to it + const matched = config.subscriptions + .filter((subscription) => isMessageStatusSubscription(subscription)) + .some((subscription) => { const messageStatusChanged = notifyData.previousMessageStatus !== notifyData.messageStatus; - const clientSubscribedStatus = subscription.MessageStatuses.includes( + const clientSubscribedStatus = subscription.messageStatuses.includes( notifyData.messageStatus, ); @@ -45,7 +44,7 @@ export const matchesMessageStatusSubscription = ( previousMessageStatus: notifyData.previousMessageStatus, messageStatusChanged, clientSubscribedStatus, - expectedStatuses: subscription.MessageStatuses, + expectedStatuses: subscription.messageStatuses, }, ); return false; diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index e1ee13d2..efd55eea 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -45,6 +45,7 @@ export class ObservabilityService { } recordFilteringMatched(context: { + correlationId?: string; clientId: string; eventType: string; subscriptionType: string; diff --git a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts index 33eddf80..5a26ea72 100644 --- a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts @@ -13,8 +13,11 @@ import { logger } from "services/logger"; type FilterResult = { matched: boolean; subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown"; + targetIds?: string[]; }; +const unique = (values: string[]): string[] => [...new Set(values)]; + export const evaluateSubscriptionFilters = ( event: StatusPublishEvent, config: ClientSubscriptionConfiguration | undefined, @@ -28,17 +31,47 @@ export const evaluateSubscriptionFilters = ( if (event.type === EventTypes.MESSAGE_STATUS_PUBLISHED) { const notifyData = event.data as MessageStatusData; + const matchingTargetIds = unique( + config.subscriptions + .filter((subscription) => + matchesMessageStatusSubscription( + { + ...config, + subscriptions: [subscription], + }, + { event, notifyData }, + ), + ) + .flatMap((subscription) => subscription.targetIds), + ); + return { - matched: matchesMessageStatusSubscription(config, { event, notifyData }), + matched: matchingTargetIds.length > 0, subscriptionType: "MessageStatus", + ...(matchingTargetIds.length > 0 ? { targetIds: matchingTargetIds } : {}), }; } if (event.type === EventTypes.CHANNEL_STATUS_PUBLISHED) { const notifyData = event.data as ChannelStatusData; + const matchingTargetIds = unique( + config.subscriptions + .filter((subscription) => + matchesChannelStatusSubscription( + { + ...config, + subscriptions: [subscription], + }, + { event, notifyData }, + ), + ) + .flatMap((subscription) => subscription.targetIds), + ); + return { - matched: matchesChannelStatusSubscription(config, { event, notifyData }), + matched: matchingTargetIds.length > 0, subscriptionType: "ChannelStatus", + ...(matchingTargetIds.length > 0 ? { targetIds: matchingTargetIds } : {}), }; } diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index cf476d5b..4e2b5bfe 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -1,11 +1,5 @@ -import { z } from "zod"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - MESSAGE_STATUSES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; +import { parseClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; import { ConfigValidationError, type ValidationIssue, @@ -14,75 +8,10 @@ import { export { ConfigValidationError } from "services/error-handler"; -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(), - InvocationEndpoint: httpsUrlSchema, - InvocationMethod: z.literal("POST"), - InvocationRateLimit: z.number(), - APIKey: z.object({ - HeaderName: z.string(), - HeaderValue: z.string(), - }), -}); - -const baseSubscriptionSchema = z.object({ - SubscriptionId: z.string().min(1), - ClientId: z.string(), - Targets: z.array(targetSchema).min(1), -}); - -const messageStatusSchema = baseSubscriptionSchema.extend({ - SubscriptionType: z.literal("MessageStatus"), - MessageStatuses: 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 seenSubscriptionIds = new Set(); - - for (const [index, subscription] of config.entries()) { - if (seenSubscriptionIds.has(subscription.SubscriptionId)) { - ctx.addIssue({ - code: "custom", - message: "Expected SubscriptionId to be unique", - path: [index, "SubscriptionId"], - }); - } else { - seenSubscriptionIds.add(subscription.SubscriptionId); - } - } -}); - export const validateClientConfig = ( rawConfig: unknown, ): ClientSubscriptionConfiguration => { - const result = configSchema.safeParse(rawConfig); + const result = parseClientSubscriptionConfiguration(rawConfig); if (!result.success) { const issues: ValidationIssue[] = result.error.issues.map((issue) => { diff --git a/package-lock.json b/package-lock.json index 6fc02487..969125be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,6 @@ "@nhs-notify-client-callbacks/models": "*", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", - "crypto-js": "^4.2.0", "esbuild": "^0.25.0", "p-map": "^4.0.0", "zod": "^4.1.13" @@ -64,7 +63,6 @@ "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.148", - "@types/crypto-js": "^4.2.2", "@types/jest": "^29.5.14", "jest": "^29.7.0", "jest-mock-extended": "^3.0.7", @@ -3477,13 +3475,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/crypto-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", - "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -4758,12 +4749,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, "node_modules/cssom": { "version": "0.5.0", "dev": true, @@ -10955,8 +10940,14 @@ "src/models": { "name": "@nhs-notify-client-callbacks/models", "version": "0.0.1", + "dependencies": { + "zod": "^4.1.13" + }, "devDependencies": { "@tsconfig/node22": "^22.0.2", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "ts-jest": "^29.4.6", "typescript": "^5.8.2" } }, diff --git a/package.json b/package.json index bd24ebd6..68238b47 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,19 @@ "start": "npm run start --workspace frontend", "test:integration": "npm run test:integration --workspace tests/integration", "test:unit": "npm run test:unit --workspaces", + "test:unit:silent": "LOG_LEVEL=silent npm run test:unit --workspaces", "typecheck": "npm run typecheck --workspaces", "verify": "npm run lint && npm run typecheck && npm run test:unit", - "subscriptions:get": "npm run get-by-client-id --workspace tools/client-subscriptions-management --", - "subscriptions:put-channel-status": "npm run put-channel-status --workspace tools/client-subscriptions-management --", - "subscriptions:put-message-status": "npm run put-message-status --workspace tools/client-subscriptions-management --" + "clients:list": "npm run --silent clients-list --workspace tools/client-subscriptions-management --", + "clients:get": "npm run --silent clients-get --workspace tools/client-subscriptions-management --", + "clients:put": "npm run --silent clients-put --workspace tools/client-subscriptions-management --", + "subscriptions:list": "npm run --silent subscriptions-list --workspace tools/client-subscriptions-management --", + "subscriptions:add": "npm run --silent subscriptions-add --workspace tools/client-subscriptions-management --", + "subscriptions:del": "npm run --silent subscriptions-del --workspace tools/client-subscriptions-management --", + "subscriptions:set-states": "npm run --silent subscriptions-set-states --workspace tools/client-subscriptions-management --", + "targets:list": "npm run --silent targets-list --workspace tools/client-subscriptions-management --", + "targets:add": "npm run --silent targets-add --workspace tools/client-subscriptions-management --", + "targets:del": "npm run --silent targets-del --workspace tools/client-subscriptions-management --" }, "workspaces": [ "lambdas/client-transform-filter-lambda", diff --git a/scripts/tests/unit.sh b/scripts/tests/unit.sh index 8b3021f1..6c191542 100755 --- a/scripts/tests/unit.sh +++ b/scripts/tests/unit.sh @@ -19,7 +19,7 @@ cd "$(git rev-parse --show-toplevel)" # run tests npm ci -npm run test:unit --workspaces +npm run test:unit:silent # merge coverage reports mkdir -p .reports diff --git a/src/logger/src/index.ts b/src/logger/src/index.ts index 1315ba0a..ae78600c 100644 --- a/src/logger/src/index.ts +++ b/src/logger/src/index.ts @@ -12,9 +12,11 @@ export interface LogContext { [key: string]: any; } +const resolveLogLevel = (level = "info"): string => level; + const basePinoLogger = pino( { - level: process.env.LOG_LEVEL || "info", + level: resolveLogLevel(process.env.LOG_LEVEL), formatters: { level: (label: string) => { return { level: label.toUpperCase() }; diff --git a/src/models/jest.config.ts b/src/models/jest.config.ts new file mode 100644 index 00000000..579fe7be --- /dev/null +++ b/src/models/jest.config.ts @@ -0,0 +1,14 @@ +import { nodeJestConfig } from "../../jest.config.base"; + +export default { + ...nodeJestConfig, + coverageThreshold: { + global: { + ...nodeJestConfig.coverageThreshold?.global, + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}; diff --git a/src/models/package.json b/src/models/package.json index bee532cc..4dc7c067 100644 --- a/src/models/package.json +++ b/src/models/package.json @@ -6,15 +6,21 @@ } }, "devDependencies": { + "@types/jest": "^29.5.14", "@tsconfig/node22": "^22.0.2", + "jest": "^29.7.0", + "ts-jest": "^29.4.6", "typescript": "^5.8.2" }, + "dependencies": { + "zod": "^4.1.13" + }, "name": "@nhs-notify-client-callbacks/models", "private": true, "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", - "test:unit": "echo \"No unit tests for @nhs-notify-client-callbacks/models\"", + "test:unit": "jest", "typecheck": "tsc --noEmit" }, "version": "0.0.1" diff --git a/src/models/src/__tests__/client-config-schema.test.ts b/src/models/src/__tests__/client-config-schema.test.ts new file mode 100644 index 00000000..da1e5429 --- /dev/null +++ b/src/models/src/__tests__/client-config-schema.test.ts @@ -0,0 +1,150 @@ +import type { ClientSubscriptionConfiguration } from "../client-config"; +import { parseClientSubscriptionConfiguration } from "../client-config-schema"; + +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +type ClientConfigParseResult = ReturnType< + typeof parseClientSubscriptionConfiguration +>; + +const expectFailedParse = ( + result: ClientConfigParseResult, +): Exclude => { + expect(result.success).toBe(false); + + if (result.success) { + throw new Error("Expected parseClientSubscriptionConfiguration to fail"); + } + + return result; +}; + +const createValidConfig = (): ClientSubscriptionConfiguration => ({ + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [TARGET_ID], + }, + { + subscriptionId: "00000000-0000-0000-0000-000000000002", + subscriptionType: "ChannelStatus", + channelType: "EMAIL", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["read"], + targetIds: [TARGET_ID], + }, + ], + targets: [ + { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); + +describe("parseClientSubscriptionConfiguration", () => { + it("returns a successful parse result when valid", () => { + const config = createValidConfig(); + + expect(parseClientSubscriptionConfiguration(config)).toEqual({ + success: true, + data: config, + }); + }); + + it("returns a failed parse result when config is not an object", () => { + const result = parseClientSubscriptionConfiguration([]); + + expect(result.success).toBe(false); + }); + + it("returns a failed parse result when invocation endpoint is not https", () => { + const config = createValidConfig(); + config.targets[0].invocationEndpoint = "http://example.com"; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual([ + expect.objectContaining({ + message: "Expected HTTPS URL", + path: ["targets", 0, "invocationEndpoint"], + }), + ]); + }); + + it("returns a failed parse result when subscription IDs are not unique", () => { + const config = createValidConfig(); + config.subscriptions[1].subscriptionId = + config.subscriptions[0].subscriptionId; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual([ + expect.objectContaining({ + message: "Expected subscriptionId to be unique", + path: ["subscriptions", 1, "subscriptionId"], + }), + ]); + }); + + it("returns a failed parse result when invocationEndpoint is not a valid URL", () => { + const config = createValidConfig(); + config.targets[0].invocationEndpoint = "not-a-url"; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual([ + expect.objectContaining({ + message: "Expected HTTPS URL", + path: ["targets", 0, "invocationEndpoint"], + }), + ]); + }); + + it("returns a failed parse result when target IDs are not unique", () => { + const config = createValidConfig(); + config.targets.push({ + ...config.targets[0], + }); + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual([ + expect.objectContaining({ + message: "Expected targetId to be unique", + path: ["targets", 1, "targetId"], + }), + ]); + }); + + it("returns a failed parse result when a subscription references an unknown targetId", () => { + const config = createValidConfig(); + config.subscriptions[0].targetIds = ["unknown-target-id"]; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual([ + expect.objectContaining({ + message: 'targetId "unknown-target-id" not found in targets', + path: ["subscriptions", 0, "targetIds", 0], + }), + ]); + }); +}); diff --git a/src/models/src/client-config-schema.ts b/src/models/src/client-config-schema.ts new file mode 100644 index 00000000..b56a9439 --- /dev/null +++ b/src/models/src/client-config-schema.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { CHANNEL_TYPES } from "./channel-types"; +import type { ClientSubscriptionConfiguration } from "./client-config"; +import { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "./status-types"; + +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({ + targetId: z.string(), + type: z.literal("API"), + invocationEndpoint: httpsUrlSchema, + invocationMethod: z.literal("POST"), + invocationRateLimit: z.number(), + apiKey: z.object({ + headerName: z.string(), + headerValue: z.string(), + }), +}); + +const baseSubscriptionSchema = z.object({ + subscriptionId: z.string().min(1), + targetIds: z.array(z.string()).min(1), +}); + +const messageStatusSchema = baseSubscriptionSchema.extend({ + subscriptionType: z.literal("MessageStatus"), + messageStatuses: 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, +]); + +export const clientSubscriptionConfigurationSchema = z + .object({ + clientId: z.string().min(1), + subscriptions: z.array(subscriptionSchema), + targets: z.array(targetSchema), + }) + .superRefine((config, ctx) => { + const seenSubscriptionIds = new Set(); + + for (const [index, subscription] of config.subscriptions.entries()) { + if (seenSubscriptionIds.has(subscription.subscriptionId)) { + ctx.addIssue({ + code: "custom", + message: "Expected subscriptionId to be unique", + path: ["subscriptions", index, "subscriptionId"], + }); + } else { + seenSubscriptionIds.add(subscription.subscriptionId); + } + } + + const validTargetIds = new Set(); + for (const [index, target] of config.targets.entries()) { + if (validTargetIds.has(target.targetId)) { + ctx.addIssue({ + code: "custom", + message: "Expected targetId to be unique", + path: ["targets", index, "targetId"], + }); + } else { + validTargetIds.add(target.targetId); + } + } + + for (const [ + subscriptionIndex, + subscription, + ] of config.subscriptions.entries()) { + for (const [targetIndex, targetId] of subscription.targetIds.entries()) { + if (!validTargetIds.has(targetId)) { + ctx.addIssue({ + code: "custom", + message: `targetId "${targetId}" not found in targets`, + path: [ + "subscriptions", + subscriptionIndex, + "targetIds", + targetIndex, + ], + }); + } + } + } + }); + +export const parseClientSubscriptionConfiguration = ( + rawConfig: unknown, +): z.ZodSafeParseResult => + clientSubscriptionConfigurationSchema.safeParse(rawConfig); diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts index 1afcc2c0..84116353 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -5,37 +5,43 @@ import type { SupplierStatus, } from "./status-types"; -export type ClientSubscriptionConfiguration = ( - | MessageStatusSubscriptionConfiguration - | ChannelStatusSubscriptionConfiguration -)[]; +export type CallbackTarget = { + targetId: string; + type: "API"; + invocationEndpoint: string; + invocationMethod: "POST"; + invocationRateLimit: number; + apiKey: { + headerName: string; + headerValue: string; + }; +}; + +type SubscriptionConfigurationBase = { + subscriptionId: string; + targetIds: string[]; +}; -interface SubscriptionConfigurationBase { - SubscriptionId: string; - ClientId: string; - Targets: { - Type: "API"; - TargetId: string; - InvocationEndpoint: string; - InvocationMethod: "POST"; - InvocationRateLimit: number; - APIKey: { - HeaderName: string; - HeaderValue: string; - }; - }[]; -} +export type MessageStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + subscriptionType: "MessageStatus"; + messageStatuses: MessageStatus[]; + }; -export interface MessageStatusSubscriptionConfiguration - extends SubscriptionConfigurationBase { - SubscriptionType: "MessageStatus"; - MessageStatuses: MessageStatus[]; -} +export type ChannelStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + subscriptionType: "ChannelStatus"; + channelType: Channel; + channelStatuses: ChannelStatus[]; + supplierStatuses: SupplierStatus[]; + }; + +export type SubscriptionConfiguration = + | MessageStatusSubscriptionConfiguration + | ChannelStatusSubscriptionConfiguration; -export interface ChannelStatusSubscriptionConfiguration - extends SubscriptionConfigurationBase { - SubscriptionType: "ChannelStatus"; - ChannelType: Channel; - ChannelStatuses: ChannelStatus[]; - SupplierStatuses: SupplierStatus[]; -} +export type ClientSubscriptionConfiguration = { + clientId: string; + subscriptions: SubscriptionConfiguration[]; + targets: CallbackTarget[]; +}; diff --git a/src/models/src/index.ts b/src/models/src/index.ts index c01a8687..b037c6db 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -12,10 +12,13 @@ export type { MessageStatusAttributes, } from "./client-callback-payload"; export type { + CallbackTarget, ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, + SubscriptionConfiguration, } from "./client-config"; +export { parseClientSubscriptionConfiguration } from "./client-config-schema"; export type { MessageStatusData } from "./message-status-data"; export type { RoutingPlan } from "./routing-plan"; export { EventTypes } from "./status-publish-event"; diff --git a/tests/integration/jest.global-setup.ts b/tests/integration/jest.global-setup.ts index 7c5007ba..7588d309 100644 --- a/tests/integration/jest.global-setup.ts +++ b/tests/integration/jest.global-setup.ts @@ -9,48 +9,38 @@ import { const mockClientSubscriptionKey = "client_subscriptions/mock-client.json"; -const mockClientSubscriptionBody = JSON.stringify([ - { - SubscriptionId: "mock-client", - SubscriptionType: "MessageStatus", - ClientId: "mock-client", - MessageStatuses: ["DELIVERED"], - Targets: [ - { - Type: "API", - TargetId: "445527ff-277b-43a4-a4b0-15eedbd71597", - InvocationEndpoint: "https://some-mock-client.endpoint/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "some-api-key", - }, +const mockClientSubscriptionBody = JSON.stringify({ + clientId: "mock-client", + subscriptions: [ + { + subscriptionId: "mock-client", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: ["445527ff-277b-43a4-a4b0-15eedbd71597"], + }, + { + subscriptionId: "mock-client-channel", + subscriptionType: "ChannelStatus", + channelStatuses: ["DELIVERED"], + channelType: "NHSAPP", + supplierStatuses: ["delivered"], + targetIds: ["445527ff-277b-43a4-a4b0-15eedbd71597"], + }, + ], + targets: [ + { + type: "API", + targetId: "445527ff-277b-43a4-a4b0-15eedbd71597", + invocationEndpoint: "https://some-mock-client.endpoint/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { + headerName: "x-api-key", + headerValue: "some-api-key", }, - ], - }, - { - SubscriptionId: "mock-client-channel", - SubscriptionType: "ChannelStatus", - ClientId: "mock-client", - ChannelStatuses: ["DELIVERED"], - ChannelType: "NHSAPP", - SupplierStatuses: ["delivered"], - Targets: [ - { - Type: "API", - TargetId: "445527ff-277b-43a4-a4b0-15eedbd71597", - InvocationEndpoint: "https://some-mock-client.endpoint/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "some-api-key", - }, - }, - ], - }, -]); + }, + ], +}); export default async function globalSetup() { const deploymentDetails = getDeploymentDetails(); diff --git a/tools/client-subscriptions-management/jest.config.ts b/tools/client-subscriptions-management/jest.config.ts index 679cd1c9..913e947b 100644 --- a/tools/client-subscriptions-management/jest.config.ts +++ b/tools/client-subscriptions-management/jest.config.ts @@ -3,6 +3,7 @@ import type { Config } from "jest"; const jestConfig: Config = { preset: "ts-jest", clearMocks: true, + silent: true, collectCoverage: true, coverageDirectory: "./.reports/unit/coverage", coverageProvider: "babel", diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index ef4857d3..b675916e 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -2,12 +2,17 @@ "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", - "deploy": "tsx ./src/entrypoint/cli/deploy.ts", + "clients-list": "tsx ./src/entrypoint/cli/index.ts clients-list", + "clients-get": "tsx ./src/entrypoint/cli/index.ts clients-get", + "clients-put": "tsx ./src/entrypoint/cli/index.ts clients-put", + "subscriptions-list": "tsx ./src/entrypoint/cli/index.ts subscriptions-list", + "subscriptions-add": "tsx ./src/entrypoint/cli/index.ts subscriptions-add", + "subscriptions-del": "tsx ./src/entrypoint/cli/index.ts subscriptions-del", + "subscriptions-set-states": "tsx ./src/entrypoint/cli/index.ts subscriptions-set-states", + "targets-list": "tsx ./src/entrypoint/cli/index.ts targets-list", + "targets-add": "tsx ./src/entrypoint/cli/index.ts targets-add", + "targets-del": "tsx ./src/entrypoint/cli/index.ts targets-del", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", diff --git a/tools/client-subscriptions-management/src/__tests__/aws.test.ts b/tools/client-subscriptions-management/src/__tests__/aws.test.ts new file mode 100644 index 00000000..501d755f --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/aws.test.ts @@ -0,0 +1,85 @@ +import { + deriveBucketName, + resolveBucketName, + resolveProfile, + resolveRegion, +} from "src/aws"; + +jest.mock("@aws-sdk/client-sts", () => ({ + STSClient: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockResolvedValue({ Account: "123456789012" }), + })), + GetCallerIdentityCommand: jest.fn(), +})); + +describe("aws", () => { + it("resolves bucket name from explicit argument", async () => { + await expect(resolveBucketName("bucket-1")).resolves.toBe("bucket-1"); + }); + + it("derives bucket name from environment using STS account ID", async () => { + await expect( + resolveBucketName(undefined, "dev", "eu-west-2"), + ).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("uses default region eu-west-2 when region is not provided", async () => { + await expect(resolveBucketName(undefined, "dev")).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("throws when neither bucket name nor environment provided", async () => { + await expect(resolveBucketName()).rejects.toThrow( + "Bucket name is required: use --bucket-name to specify directly, or --environment", + ); + }); + + it("derives bucket name correctly", () => { + expect(deriveBucketName("123456789012", "dev", "eu-west-2")).toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("resolves profile from argument", () => { + expect(resolveProfile("my-profile")).toBe("my-profile"); + }); + + it("resolves profile from AWS_PROFILE env", () => { + expect( + resolveProfile(undefined, { + AWS_PROFILE: "env-profile", + } as NodeJS.ProcessEnv), + ).toBe("env-profile"); + }); + + it("returns undefined when profile is not set", () => { + expect(resolveProfile(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); + }); + + 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__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts index d8214e61..74acdea3 100644 --- a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -1,4 +1,4 @@ -import { createS3Client } from "src/container"; +import { createS3Client } from "src/aws"; const mockFromIni = jest.fn().mockReturnValue({ accessKeyId: "from-ini" }); jest.mock("@aws-sdk/credential-providers", () => ({ diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts index f264e1e9..1838175f 100644 --- a/tools/client-subscriptions-management/src/__tests__/container.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -1,32 +1,24 @@ import { S3Client } from "@aws-sdk/client-s3"; const mockS3Repository = jest.fn(); -const mockBuilderObject = { - messageStatus: jest.fn(), - channelStatus: jest.fn(), -}; const mockRepository = jest.fn(); jest.mock("src/repository/s3", () => ({ S3Repository: mockS3Repository, })); -jest.mock("src/domain/client-subscription-builder", () => ({ - clientSubscriptionBuilder: mockBuilderObject, -})); - jest.mock("src/repository/client-subscriptions", () => ({ ClientSubscriptionRepository: mockRepository, })); -import { createClientSubscriptionRepository } from "src/container"; +import { createRepository } from "src/aws"; -describe("createClientSubscriptionRepository", () => { +describe("createRepository", () => { it("creates repository with provided options", () => { const repoInstance = { repo: true }; mockRepository.mockReturnValue(repoInstance); - const result = createClientSubscriptionRepository({ + const result = createRepository({ bucketName: "bucket-1", region: "eu-west-2", }); @@ -37,7 +29,6 @@ describe("createClientSubscriptionRepository", () => { ); expect(mockRepository).toHaveBeenCalledWith( mockS3Repository.mock.instances[0], - mockBuilderObject, ); expect(result).toBe(repoInstance); }); diff --git a/tools/client-subscriptions-management/src/__tests__/domain/client-config-validator.test.ts b/tools/client-subscriptions-management/src/__tests__/domain/client-config-validator.test.ts new file mode 100644 index 00000000..7df07252 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/domain/client-config-validator.test.ts @@ -0,0 +1,26 @@ +import { validateClientConfig } from "src/domain/client-config-validator"; +import { createPopulatedClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const createValidConfig = () => createPopulatedClientSubscriptionConfig(); + +describe("validateClientConfig", () => { + it("returns the config unchanged when parsing succeeds", () => { + const config = createValidConfig(); + + expect(validateClientConfig(config)).toEqual(config); + }); + + it("throws a tool-level validation error when parsing fails", () => { + expect(() => validateClientConfig([])).toThrow(/Config validation failed/); + }); + + it("includes multiple schema issues in the thrown error message", () => { + const config = createValidConfig(); + config.targets[0].invocationEndpoint = "http://example.com/webhook"; + config.subscriptions[0].targetIds = ["unknown-target-id"]; + + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed:[\s\S]*Expected HTTPS URL[\s\S]*targetId "unknown-target-id" not found in targets/, + ); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts index 1ff192ef..10fcb111 100644 --- a/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts @@ -1,72 +1,87 @@ import { buildChannelStatusSubscription, buildMessageStatusSubscription, + buildTarget, } from "src/domain/client-subscription-builder"; -describe("buildMessageStatusSubscription", () => { - it("builds message status subscription", () => { - const result = buildMessageStatusSubscription({ +const UUID_REGEX = + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i; + +describe("buildTarget", () => { + it("builds a target with required fields", () => { + const result = buildTarget({ 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({ - SubscriptionId: "client-one", - SubscriptionType: "MessageStatus", - ClientId: "client-1", - MessageStatuses: ["DELIVERED"], + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }); + expect(result.targetId).toMatch(UUID_REGEX); + }); + + it("defaults apiKeyHeaderName to x-api-key when not provided", () => { + const result = buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 5, + }); + + expect(result.apiKey.headerName).toBe("x-api-key"); + }); +}); + +describe("buildMessageStatusSubscription", () => { + it("builds message status subscription", () => { + const result = buildMessageStatusSubscription({ + subscriptionId: "sub-001", + targetIds: ["target-001"], + messageStatuses: ["DELIVERED"], + }); + + expect(result).toEqual({ + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + targetIds: ["target-001"], + messageStatuses: ["DELIVERED"], }); - expect(result.Targets[0].TargetId).toMatch( - /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, - ); }); }); describe("buildChannelStatusSubscription", () => { - it("builds channel status subscription", () => { + it("builds channel status subscription with all fields", () => { const result = buildChannelStatusSubscription({ - apiEndpoint: "https://example.com/webhook", - apiKey: "secret", - clientId: "client-1", - clientName: "Client One", + subscriptionId: "sub-002", + targetIds: ["target-001"], + channelType: "SMS", channelStatuses: ["DELIVERED"], supplierStatuses: ["delivered"], - channelType: "SMS", - rateLimit: 20, - dryRun: false, }); - expect(result).toMatchObject({ - SubscriptionId: "client-one-SMS", - SubscriptionType: "ChannelStatus", - ClientId: "client-1", - ChannelType: "SMS", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["delivered"], + expect(result).toEqual({ + subscriptionId: "sub-002", + subscriptionType: "ChannelStatus", + targetIds: ["target-001"], + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], }); - expect(result.Targets[0].TargetId).toMatch( - /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, - ); }); it("defaults channelStatuses and supplierStatuses to [] when not provided", () => { const result = buildChannelStatusSubscription({ - apiEndpoint: "https://example.com/webhook", - apiKey: "secret", - clientId: "client-1", - clientName: "Client One", + subscriptionId: "sub-003", + targetIds: ["target-001"], channelType: "SMS", - rateLimit: 10, - dryRun: false, }); - expect(result.ChannelStatuses).toEqual([]); - expect(result.SupplierStatuses).toEqual([]); + expect(result.channelStatuses).toEqual([]); + expect(result.supplierStatuses).toEqual([]); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts new file mode 100644 index 00000000..97e07835 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts @@ -0,0 +1,84 @@ +import * as cli from "src/entrypoint/cli/clients-get"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); + +const validConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("clients-get CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + beforeEach(() => { + mockGetClientConfig.mockReset(); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("prints JSON config when it exists", async () => { + mockGetClientConfig.mockResolvedValue(validConfig); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.log).toHaveBeenCalledWith( + JSON.stringify(validConfig, null, 2), + ); + }); + + it("prints message when no config exists", async () => { + mockGetClientConfig.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 wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts new file mode 100644 index 00000000..59d6239c --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts @@ -0,0 +1,62 @@ +const mockListClientIds = jest.fn(); +import * as cli from "src/entrypoint/cli/clients-list"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); + +const mockCreateRepository = getMockCreateRepository(); + +describe("clients-list CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + beforeEach(() => { + mockListClientIds.mockReset(); + resetMockCreateRepository({ + listClientIds: mockListClientIds, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("prints each client ID on its own line", async () => { + mockListClientIds.mockResolvedValue(["client-a", "client-b"]); + + await cli.main(["node", "script", "--bucket-name", "bucket-1"]); + + expect(console.log).toHaveBeenCalledWith("client-a"); + expect(console.log).toHaveBeenCalledWith("client-b"); + }); + + it("prints nothing when no client IDs found", async () => { + mockListClientIds.mockResolvedValue([]); + + await cli.main(["node", "script", "--bucket-name", "bucket-1"]); + + expect(console.log).toHaveBeenCalledWith("No client IDs found"); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + "node", + "script", + "--bucket-name", + "bucket-1", + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts new file mode 100644 index 00000000..64a4d1ea --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts @@ -0,0 +1,233 @@ +import { tmpdir } from "node:os"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import * as cli from "src/entrypoint/cli/clients-put"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockPutClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/terraform", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("node:fs", () => ({ + ...jest.requireActual("node:fs"), + readFileSync: jest.fn(), +})); + +const validConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("clients-put CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + beforeEach(() => { + mockPutClientConfig.mockReset(); + resetMockCreateRepository({ + putClientConfig: mockPutClientConfig, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("rejects when neither --json nor --file provided", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: one of --json or --file is required", + ); + expect(process.exitCode).toBe(1); + expect(mockPutClientConfig).not.toHaveBeenCalled(); + }); + + it("rejects when both --json and --file are provided", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + "--file", + "config.json", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: --json and --file are mutually exclusive", + ); + expect(process.exitCode).toBe(1); + expect(mockPutClientConfig).not.toHaveBeenCalled(); + }); + + it("rejects when JSON is malformed", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + "not-valid-json", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: failed to parse JSON input", + ); + expect(process.exitCode).toBe(1); + expect(mockPutClientConfig).not.toHaveBeenCalled(); + }); + + it("rejects when clientId in config does not match --client-id", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify({ clientId: "different-client" }), + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("does not match --client-id"), + ); + expect(process.exitCode).toBe(1); + expect(mockPutClientConfig).not.toHaveBeenCalled(); + }); + + it("writes config from --json input", async () => { + mockPutClientConfig.mockResolvedValue(validConfig); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + ]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + validConfig, + false, + ); + expect(console.log).toHaveBeenCalledWith( + "Config written for client: client-1", + ); + }); + + it("reads config from --file input", async () => { + (readFileSync as jest.Mock).mockReturnValue(JSON.stringify(validConfig)); + mockPutClientConfig.mockResolvedValue(validConfig); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--file", + path.join(tmpdir(), "config.json"), + ]); + + expect(readFileSync).toHaveBeenCalledWith( + path.join(tmpdir(), "config.json"), + "utf8", + ); + expect(mockPutClientConfig).toHaveBeenCalledTimes(1); + }); + + it("rejects non-json --file path", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--file", + "config.txt", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: --file must be a .json path", + ); + expect(process.exitCode).toBe(1); + expect(readFileSync).not.toHaveBeenCalled(); + expect(mockPutClientConfig).not.toHaveBeenCalled(); + }); + + it("prints dry-run output and does not log success message", async () => { + mockPutClientConfig.mockResolvedValue(validConfig); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + "--dry-run", + "true", + ]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + validConfig, + true, + ); + expect(console.log).toHaveBeenCalledWith("Dry run: config is valid"); + expect(console.log).toHaveBeenCalledWith( + JSON.stringify(validConfig, null, 2), + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts deleted file mode 100644 index a0993abe..00000000 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -const mockGetClientSubscriptions = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ - getClientSubscriptions: mockGetClientSubscriptions, -}); -const mockFormatSubscriptionFileResponse = jest.fn(); -const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -const mockResolveProfile = jest.fn().mockReturnValue(undefined); -const mockResolveRegion = jest.fn().mockReturnValue("region"); - -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: mockCreateRepository, -})); - -jest.mock("src/entrypoint/cli/helper", () => ({ - formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - 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 () => { - 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 () => { - 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", - ]; - 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", - ]; - mockGetClientSubscriptions.mockResolvedValue(undefined); - - await cli.main(); - - expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-2"); - }); -}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts index 2c3c291d..44030f7b 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts @@ -1,166 +1,63 @@ -import type { - ChannelStatusSubscriptionConfiguration, - ClientSubscriptionConfiguration, - MessageStatusSubscriptionConfiguration, -} from "@nhs-notify-client-callbacks/models"; -import { - deriveBucketName, - formatSubscriptionFileResponse, - normalizeClientName, - resolveBucketName, - resolveProfile, - resolveRegion, -} from "src/entrypoint/cli/helper"; - -jest.mock("@aws-sdk/client-sts", () => ({ - STSClient: jest.fn().mockImplementation(() => ({ - send: jest.fn().mockResolvedValue({ Account: "123456789012" }), - })), - GetCallerIdentityCommand: jest.fn(), +const mockCreateRepositoryFromOptions = jest.fn(); +const mockResolveBucketName = jest.fn(); +const mockResolveProfile = jest.fn(); +const mockResolveRegion = jest.fn(); + +jest.mock("src/aws", () => ({ + createRepository: mockCreateRepositoryFromOptions, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, })); -describe("cli helper", () => { - const messageSubscription: MessageStatusSubscriptionConfiguration = { - SubscriptionId: "client-a", - SubscriptionType: "MessageStatus", - ClientId: "client-a", - MessageStatuses: ["DELIVERED"], - Targets: [ - { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000001", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - }; - - const channelSubscription: ChannelStatusSubscriptionConfiguration = { - SubscriptionId: "client-a-sms", - SubscriptionType: "ChannelStatus", - ClientId: "client-a", - ChannelType: "SMS", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["delivered"], - Targets: [ - { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000002", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 20, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }, - ], - }; - - it("formats subscription output as a table string", () => { - const config: ClientSubscriptionConfiguration = [ - messageSubscription, - channelSubscription, - ]; - - const result = formatSubscriptionFileResponse(config); - - expect(typeof result).toBe("string"); - // message status row - expect(result).toContain("client-a"); - expect(result).toContain("MessageStatus"); - expect(result).toContain("DELIVERED"); - expect(result).toContain("https://example.com/webhook"); - expect(result).toContain("POST"); - expect(result).toContain("x-api-key"); - expect(result).toContain("secret"); - // channel status row - expect(result).toContain("ChannelStatus"); - expect(result).toContain("SMS"); - }); - - it("normalizes client name", () => { - expect(normalizeClientName("My Client Name")).toBe("my-client-name"); - }); - - it("resolves bucket name from explicit argument", async () => { - await expect(resolveBucketName("bucket-1")).resolves.toBe("bucket-1"); - }); - - it("derives bucket name from environment using STS account ID", async () => { - await expect( - resolveBucketName(undefined, "dev", "eu-west-2"), - ).resolves.toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", - ); - }); - - it("uses default region eu-west-2 when region is not provided", async () => { - await expect(resolveBucketName(undefined, "dev")).resolves.toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", - ); - }); - - it("throws when neither bucket name nor environment provided", async () => { - await expect(resolveBucketName()).rejects.toThrow( - "Bucket name is required: use --bucket-name to specify directly, or --environment", - ); - }); +import { + type AnyCliCommand, + createRepository, + runCommands, +} from "src/entrypoint/cli/helper"; - it("derives bucket name correctly", () => { - expect(deriveBucketName("123456789012", "dev", "eu-west-2")).toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", +describe("createRepository", () => { + it("resolves region, profile and bucket then delegates to createRepository from aws", async () => { + const fakeRepo = { listClientIds: jest.fn() }; + mockResolveRegion.mockReturnValue("eu-west-2"); + mockResolveProfile.mockReturnValue("my-profile"); + mockResolveBucketName.mockResolvedValue("my-bucket"); + mockCreateRepositoryFromOptions.mockReturnValue(fakeRepo); + + const result = await createRepository({ + "bucket-name": "my-bucket", + region: "eu-west-2", + profile: "my-profile", + environment: "my-env", + }); + + expect(mockResolveRegion).toHaveBeenCalledWith("eu-west-2"); + expect(mockResolveProfile).toHaveBeenCalledWith("my-profile"); + expect(mockResolveBucketName).toHaveBeenCalledWith( + "my-bucket", + "my-env", + "eu-west-2", + "my-profile", ); + expect(mockCreateRepositoryFromOptions).toHaveBeenCalledWith({ + bucketName: "my-bucket", + region: "eu-west-2", + profile: "my-profile", + }); + expect(result).toBe(fakeRepo); }); +}); - it("derives bucket name with custom project and component", () => { - expect( - deriveBucketName("123456789012", "prod", "eu-west-2", "myproj", "mycomp"), - ).toBe("myproj-123456789012-eu-west-2-prod-mycomp-subscription-config"); - }); - - it("resolves profile from argument", () => { - expect(resolveProfile("my-profile")).toBe("my-profile"); - }); - - it("resolves profile from AWS_PROFILE env", () => { - expect( - resolveProfile(undefined, { - AWS_PROFILE: "env-profile", - } as NodeJS.ProcessEnv), - ).toBe("env-profile"); - }); - - it("returns undefined when profile is not set", () => { - expect(resolveProfile(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); - }); - - 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"); - }); +describe("runCommands", () => { + it("dispatches to the matching command handler", async () => { + const mockHandler = jest.fn().mockResolvedValue(undefined); + const command: AnyCliCommand = { + command: "test-cmd", + handler: mockHandler, + }; - it("resolves region from AWS_DEFAULT_REGION", () => { - expect( - resolveRegion(undefined, { - AWS_DEFAULT_REGION: "eu-west-3", - } as NodeJS.ProcessEnv), - ).toBe("eu-west-3"); - }); + await runCommands([command], ["node", "script", "test-cmd"]); - it("returns undefined when region is not set", () => { - expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); + expect(mockHandler).toHaveBeenCalled(); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts deleted file mode 100644 index 92b3a2b0..00000000 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -const mockPutChannelStatusSubscription = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ - putChannelStatusSubscription: mockPutChannelStatusSubscription, -}); -const mockFormatSubscriptionFileResponse = jest.fn(); -const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -const mockResolveProfile = jest.fn().mockReturnValue(undefined); -const mockResolveRegion = jest.fn().mockReturnValue("region"); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: mockCreateRepository, -})); - -jest.mock("src/entrypoint/cli/helper", () => ({ - formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - 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", - "--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, - }); - 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, - }); - expect(console.log).toHaveBeenCalledWith(["formatted"]); - }); -}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts deleted file mode 100644 index afdb8bf6..00000000 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -const mockPutMessageStatusSubscription = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ - putMessageStatusSubscription: mockPutMessageStatusSubscription, -}); -const mockFormatSubscriptionFileResponse = jest.fn(); -const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); -const mockResolveProfile = jest.fn().mockReturnValue(undefined); -const mockResolveRegion = jest.fn().mockReturnValue("region"); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: mockCreateRepository, -})); - -jest.mock("src/entrypoint/cli/helper", () => ({ - formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - 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", - "--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, - }); - 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, - }); - expect(console.log).toHaveBeenCalledWith(["formatted"]); - }); -}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts new file mode 100644 index 00000000..be098c59 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts @@ -0,0 +1,190 @@ +import * as cli from "src/entrypoint/cli/subscriptions-add"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockAddSubscription = jest.fn(); +const mockBuildMessageStatusSubscription = jest.fn(); +const mockBuildChannelStatusSubscription = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildMessageStatusSubscription: (...args: unknown[]) => + mockBuildMessageStatusSubscription(...args), + buildChannelStatusSubscription: (...args: unknown[]) => + mockBuildChannelStatusSubscription(...args), +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const resultConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("subscriptions-add CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseMessageArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--subscription-type", + "MessageStatus", + "--target-id", + "target-001", + "--message-statuses", + "DELIVERED", + ]; + + const baseChannelArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--subscription-type", + "ChannelStatus", + "--target-id", + "target-001", + "--channel-type", + "SMS", + "--channel-statuses", + "DELIVERED", + ]; + + beforeEach(() => { + mockAddSubscription.mockReset(); + mockAddSubscription.mockResolvedValue(resultConfig); + mockBuildMessageStatusSubscription.mockReset(); + mockBuildMessageStatusSubscription.mockReturnValue({ + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: ["target-001"], + }); + mockBuildChannelStatusSubscription.mockReset(); + mockBuildChannelStatusSubscription.mockReturnValue({ + subscriptionId: "sub-002", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: [], + targetIds: ["target-001"], + }); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + addSubscription: mockAddSubscription, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("adds MessageStatus subscription and logs config", async () => { + await cli.main(baseMessageArgs); + + expect(mockBuildMessageStatusSubscription).toHaveBeenCalled(); + expect(mockAddSubscription).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("rejects MessageStatus without --message-statuses", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--subscription-type", + "MessageStatus", + "--target-id", + "target-001", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: --message-statuses is required for MessageStatus subscriptions", + ); + expect(process.exitCode).toBe(1); + expect(mockAddSubscription).not.toHaveBeenCalled(); + }); + + it("adds ChannelStatus subscription and logs config", async () => { + await cli.main(baseChannelArgs); + + expect(mockBuildChannelStatusSubscription).toHaveBeenCalled(); + expect(mockAddSubscription).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("rejects ChannelStatus without --channel-type", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--subscription-type", + "ChannelStatus", + "--target-id", + "target-001", + "--channel-statuses", + "DELIVERED", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: --channel-type is required for ChannelStatus subscriptions", + ); + expect(process.exitCode).toBe(1); + expect(mockAddSubscription).not.toHaveBeenCalled(); + }); + + it("rejects ChannelStatus without channel or supplier statuses", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--subscription-type", + "ChannelStatus", + "--target-id", + "target-001", + "--channel-type", + "SMS", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + expect(process.exitCode).toBe(1); + expect(mockAddSubscription).not.toHaveBeenCalled(); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, baseMessageArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts new file mode 100644 index 00000000..6847a9ac --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts @@ -0,0 +1,82 @@ +import * as cli from "src/entrypoint/cli/subscriptions-del"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockDeleteSubscription = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const resultConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("subscriptions-del CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--subscription-id", + "sub-001", + "--bucket-name", + "bucket-1", + ]; + + beforeEach(() => { + mockDeleteSubscription.mockReset(); + mockDeleteSubscription.mockResolvedValue(resultConfig); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + deleteSubscription: mockDeleteSubscription, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("deletes subscription and logs updated config", async () => { + await cli.main(baseArgs); + + expect(mockDeleteSubscription).toHaveBeenCalledWith( + "client-1", + "sub-001", + false, + ); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("passes dry-run flag to repository", async () => { + await cli.main([...baseArgs, "--dry-run", "true"]); + + expect(mockDeleteSubscription).toHaveBeenCalledWith( + "client-1", + "sub-001", + true, + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, baseArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts new file mode 100644 index 00000000..938529e2 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts @@ -0,0 +1,121 @@ +import * as cli from "src/entrypoint/cli/subscriptions-list"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); +const mockFormatSubscriptionsTable = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatSubscriptionsTable: (...args: unknown[]) => + mockFormatSubscriptionsTable(...args), +})); + +const validConfig = createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ + targetIds: ["target-001"], + }), + ], +}); +const mockCreateRepository = getMockCreateRepository(); + +describe("subscriptions-list CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockFormatSubscriptionsTable.mockReset(); + mockFormatSubscriptionsTable.mockReturnValue("table-output"); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("prints subscriptions table when config has subscriptions", async () => { + mockGetClientConfig.mockResolvedValue(validConfig); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(mockFormatSubscriptionsTable).toHaveBeenCalledWith( + validConfig.subscriptions, + ); + expect(console.log).toHaveBeenCalledWith("table-output"); + }); + + it("prints message when no config exists", async () => { + mockGetClientConfig.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("prints message when subscriptions is empty", async () => { + mockGetClientConfig.mockResolvedValue({ + ...validConfig, + subscriptions: [], + }); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.log).toHaveBeenCalledWith( + "No subscriptions found for client: client-1", + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts new file mode 100644 index 00000000..00604b90 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts @@ -0,0 +1,124 @@ +import * as cli from "src/entrypoint/cli/subscriptions-set-states"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockSetSubscriptionStates = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const resultConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("subscriptions-set-states CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--subscription-id", + "sub-001", + "--bucket-name", + "bucket-1", + ]; + + beforeEach(() => { + mockSetSubscriptionStates.mockReset(); + mockSetSubscriptionStates.mockResolvedValue(resultConfig); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + setSubscriptionStates: mockSetSubscriptionStates, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("rejects when no statuses provided", async () => { + await cli.main(baseArgs); + + expect(console.error).toHaveBeenCalledWith( + "Error: at least one of --message-statuses, --channel-statuses, or --supplier-statuses must be provided", + ); + expect(process.exitCode).toBe(1); + expect(mockSetSubscriptionStates).not.toHaveBeenCalled(); + }); + + it("updates message statuses and logs config", async () => { + await cli.main([...baseArgs, "--message-statuses", "DELIVERED", "FAILED"]); + + expect(mockSetSubscriptionStates).toHaveBeenCalledWith( + "client-1", + "sub-001", + expect.objectContaining({ messageStatuses: ["DELIVERED", "FAILED"] }), + false, + ); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("updates channel and supplier statuses", async () => { + await cli.main([ + ...baseArgs, + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "read", + ]); + + expect(mockSetSubscriptionStates).toHaveBeenCalledWith( + "client-1", + "sub-001", + expect.objectContaining({ + channelStatuses: ["DELIVERED"], + supplierStatuses: ["read"], + }), + false, + ); + }); + + it("passes dry-run flag to repository", async () => { + await cli.main([ + ...baseArgs, + "--message-statuses", + "DELIVERED", + "--dry-run", + "true", + ]); + + expect(mockSetSubscriptionStates).toHaveBeenCalledWith( + "client-1", + "sub-001", + expect.any(Object), + true, + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + ...baseArgs, + "--message-statuses", + "DELIVERED", + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts new file mode 100644 index 00000000..5e222e0f --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts @@ -0,0 +1,100 @@ +import * as cli from "src/entrypoint/cli/targets-add"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockAddTarget = jest.fn(); +const mockBuildTarget = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildTarget: (...args: unknown[]) => mockBuildTarget(...args), +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const builtTarget = createTarget(); + +const resultConfig = createClientSubscriptionConfig({ + targets: [builtTarget], +}); +const mockCreateRepository = getMockCreateRepository(); + +describe("targets-add CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--api-endpoint", + "https://example.com/webhook", + "--api-key", + "secret", + "--rate-limit", + "10", + ]; + + beforeEach(() => { + mockAddTarget.mockReset(); + mockAddTarget.mockResolvedValue(resultConfig); + mockBuildTarget.mockReset(); + mockBuildTarget.mockReturnValue(builtTarget); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ addTarget: mockAddTarget }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("adds target and logs config", async () => { + await cli.main(baseArgs); + + expect(mockBuildTarget).toHaveBeenCalledWith( + expect.objectContaining({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + }), + ); + expect(mockAddTarget).toHaveBeenCalledWith("client-1", builtTarget, false); + expect(console.log).toHaveBeenCalledWith( + `Target added with ID: ${builtTarget.targetId}`, + ); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("passes dry-run to repository", async () => { + await cli.main([...baseArgs, "--dry-run", "true"]); + + expect(mockAddTarget).toHaveBeenCalledWith("client-1", builtTarget, true); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, baseArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts new file mode 100644 index 00000000..ecec6f9d --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts @@ -0,0 +1,80 @@ +import * as cli from "src/entrypoint/cli/targets-del"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockDeleteTarget = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const resultConfig = createClientSubscriptionConfig(); +const mockCreateRepository = getMockCreateRepository(); + +describe("targets-del CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--target-id", + "00000000-0000-4000-8000-000000000001", + "--bucket-name", + "bucket-1", + ]; + + beforeEach(() => { + mockDeleteTarget.mockReset(); + mockDeleteTarget.mockResolvedValue(resultConfig); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ deleteTarget: mockDeleteTarget }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("deletes target and logs updated config", async () => { + await cli.main(baseArgs); + + expect(mockDeleteTarget).toHaveBeenCalledWith( + "client-1", + "00000000-0000-4000-8000-000000000001", + false, + ); + expect(console.log).toHaveBeenCalledWith("formatted-output"); + }); + + it("passes dry-run flag to repository", async () => { + await cli.main([...baseArgs, "--dry-run", "true"]); + + expect(mockDeleteTarget).toHaveBeenCalledWith( + "client-1", + "00000000-0000-4000-8000-000000000001", + true, + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, baseArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts new file mode 100644 index 00000000..c7488cf6 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts @@ -0,0 +1,111 @@ +import * as cli from "src/entrypoint/cli/targets-list"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); +const mockFormatTargetsTable = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatTargetsTable: (...args: unknown[]) => mockFormatTargetsTable(...args), +})); + +const target = createTarget(); +const mockCreateRepository = getMockCreateRepository(); + +describe("targets-list CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockFormatTargetsTable.mockReset(); + mockFormatTargetsTable.mockReturnValue("targets-table"); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("prints targets table when config has targets", async () => { + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ targets: [target] }), + ); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(mockFormatTargetsTable).toHaveBeenCalledWith([target]); + expect(console.log).toHaveBeenCalledWith("targets-table"); + }); + + it("prints message when no config exists", async () => { + mockGetClientConfig.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("prints message when targets is empty", async () => { + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.log).toHaveBeenCalledWith( + "No targets found for client: client-1", + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/test-utils.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/test-utils.ts new file mode 100644 index 00000000..4e55436b --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/test-utils.ts @@ -0,0 +1,49 @@ +import * as helper from "src/entrypoint/cli/helper"; +import { wrapCli } from "src/entrypoint/cli/helper"; + +type CliConsoleState = { + error: typeof console.error; + exitCode: typeof process.exitCode; + log: typeof console.log; +}; + +export const captureCliConsoleState = (): CliConsoleState => ({ + error: console.error, + exitCode: process.exitCode, + log: console.log, +}); + +export const resetCliConsoleState = (): void => { + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; +}; + +export const restoreCliConsoleState = (state: CliConsoleState): void => { + console.log = state.log; + console.error = state.error; + process.exitCode = state.exitCode; +}; + +export const expectWrappedCliError = async ( + mainFn: (args?: string[]) => Promise, + args: string[], + message = "Boom", +): Promise => { + await wrapCli(mainFn)(args); + + expect(console.error).toHaveBeenCalledWith(message); + expect(process.exitCode).toBe(1); +}; + +export const getMockCreateRepository = (): jest.Mock => + helper.createRepository as jest.Mock; + +export const resetMockCreateRepository = ( + repository: Record, +): jest.Mock => { + const mockCreateRepository = getMockCreateRepository(); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue(repository); + return mockCreateRepository; +}; diff --git a/tools/client-subscriptions-management/src/__tests__/format.test.ts b/tools/client-subscriptions-management/src/__tests__/format.test.ts new file mode 100644 index 00000000..ef0b80cf --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/format.test.ts @@ -0,0 +1,76 @@ +import { + formatClientConfig, + formatSubscriptionsTable, + formatTargetsTable, + normalizeClientName, +} from "src/format"; +import { + DEFAULT_TARGET_ID as TARGET_ID, + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +describe("format", () => { + const target = createTarget(); + const messageSubscription = createMessageStatusSubscription(); + const channelSubscription = createChannelStatusSubscription(); + + const config = createClientSubscriptionConfig({ + clientId: "client-a", + subscriptions: [messageSubscription, channelSubscription], + targets: [target], + }); + + it("formats subscriptions as a table string", () => { + const result = formatSubscriptionsTable(config.subscriptions); + + expect(typeof result).toBe("string"); + expect(result).toContain("sub-001"); + expect(result).toContain("MessageStatus"); + expect(result).toContain("DELIVERED"); + expect(result).toContain("sub-002"); + expect(result).toContain("ChannelStatus"); + expect(result).toContain("SMS"); + }); + + it("formats targets as a table string", () => { + const result = formatTargetsTable(config.targets); + + expect(typeof result).toBe("string"); + expect(result).toContain(TARGET_ID); + expect(result).toContain("https://example.com/webhook"); + expect(result).toContain("x-api-key"); + }); + + it("formats full client config including header and both tables", () => { + const result = formatClientConfig(config); + + expect(result).toContain("Client: client-a"); + expect(result).toContain("Subscriptions:"); + expect(result).toContain("Targets:"); + }); + + it("shows (none) when subscriptions is empty", () => { + const empty = createClientSubscriptionConfig({ + clientId: "empty-client", + targets: [target], + }); + + expect(formatClientConfig(empty)).toContain("Subscriptions: (none)"); + }); + + it("shows (none) when targets is empty", () => { + const empty = createClientSubscriptionConfig({ + clientId: "empty-client", + subscriptions: [messageSubscription], + }); + + expect(formatClientConfig(empty)).toContain("Targets: (none)"); + }); + + it("normalizes client name", () => { + expect(normalizeClientName("My Client Name")).toBe("my-client-name"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts b/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts new file mode 100644 index 00000000..de12586e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts @@ -0,0 +1,71 @@ +import type { + CallbackTarget, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; + +export const DEFAULT_TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +type TargetOverrides = Partial & { + apiKey?: Partial; +}; + +export const createTarget = ( + overrides: TargetOverrides = {}, +): CallbackTarget => ({ + targetId: DEFAULT_TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { + headerName: "x-api-key", + headerValue: "secret", + ...overrides.apiKey, + }, + ...overrides, +}); + +export const createMessageStatusSubscription = ( + overrides: Partial = {}, +): MessageStatusSubscriptionConfiguration => ({ + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [DEFAULT_TARGET_ID], + ...overrides, +}); + +export const createChannelStatusSubscription = ( + overrides: Partial = {}, +): ChannelStatusSubscriptionConfiguration => ({ + subscriptionId: "sub-002", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + targetIds: [DEFAULT_TARGET_ID], + ...overrides, +}); + +export const createClientSubscriptionConfig = ( + overrides: Partial = {}, +): ClientSubscriptionConfiguration => ({ + clientId: "client-1", + subscriptions: [], + targets: [], + ...overrides, +}); + +export const createPopulatedClientSubscriptionConfig = ( + clientId = "client-1", +): ClientSubscriptionConfiguration => + createClientSubscriptionConfig({ + clientId, + subscriptions: [ + createMessageStatusSubscription(), + createChannelStatusSubscription(), + ], + targets: [createTarget()], + }); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts index 93fa6f5a..1eb491ee 100644 --- a/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts @@ -1,372 +1,356 @@ -import { z } from "zod"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import type { - ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import type { S3Repository } from "src/repository/s3"; -import type { SubscriptionBuilder } from "src/domain/client-subscription-builder"; - -const createRepository = ( - overrides?: Partial<{ - getObject: jest.Mock; - putRawData: jest.Mock; - messageStatus: jest.Mock; - channelStatus: jest.Mock; - }>, -) => { +import { + DEFAULT_TARGET_ID as TARGET_ID, + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusSubscription, + createPopulatedClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const createRepository = (overrides?: { + getObject?: jest.Mock; + putRawData?: jest.Mock; + listObjectKeys?: jest.Mock; +}) => { const s3Repository = { getObject: overrides?.getObject ?? jest.fn(), putRawData: overrides?.putRawData ?? jest.fn(), + listObjectKeys: overrides?.listObjectKeys ?? jest.fn(), } as unknown as S3Repository; - const configurationBuilder = { - messageStatus: overrides?.messageStatus ?? jest.fn(), - channelStatus: overrides?.channelStatus ?? jest.fn(), - } as unknown as SubscriptionBuilder; - - const repository = new ClientSubscriptionRepository( + return { + repository: new ClientSubscriptionRepository(s3Repository), s3Repository, - configurationBuilder, - ); - - return { repository, s3Repository, configurationBuilder }; + }; }; -describe("ClientSubscriptionRepository", () => { - const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] = - { - Type: "API", - TargetId: "00000000-0000-4000-8000-000000000001", - InvocationEndpoint: "https://example.com/webhook", - InvocationMethod: "POST", - InvocationRateLimit: 10, - APIKey: { - HeaderName: "x-api-key", - HeaderValue: "secret", - }, - }; - - const messageSubscription: MessageStatusSubscriptionConfiguration = { - SubscriptionId: "client-1", - SubscriptionType: "MessageStatus", - ClientId: "client-1", - MessageStatuses: ["DELIVERED"], - Targets: [baseTarget], - }; +const baseTarget = createTarget(); +const messageSubscription = createMessageStatusSubscription(); +const channelSubscription = createChannelStatusSubscription(); - const channelSubscription: ChannelStatusSubscriptionConfiguration = { - SubscriptionId: "client-1-SMS", - SubscriptionType: "ChannelStatus", - ClientId: "client-1", - ChannelType: "SMS", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["delivered"], - Targets: [baseTarget], - }; +const baseConfig = (clientId = "client-1"): ClientSubscriptionConfiguration => + createPopulatedClientSubscriptionConfig(clientId); - it("returns parsed subscriptions when file exists", async () => { - const storedConfig: ClientSubscriptionConfiguration = [messageSubscription]; - const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); - const { repository } = createRepository({ getObject }); +describe("ClientSubscriptionRepository", () => { + describe("listClientIds", () => { + it("returns client IDs extracted from S3 object keys", async () => { + const listObjectKeys = jest + .fn() + .mockResolvedValue([ + "client_subscriptions/client-a.json", + "client_subscriptions/client-b.json", + ]); + const { repository } = createRepository({ listObjectKeys }); + + await expect(repository.listClientIds()).resolves.toEqual([ + "client-a", + "client-b", + ]); + }); - const result = await repository.getClientSubscriptions("client-1"); + it("returns empty array when no objects found", async () => { + const listObjectKeys = jest.fn().mockResolvedValue([]); + const { repository } = createRepository({ listObjectKeys }); - expect(result).toEqual(storedConfig); + await expect(repository.listClientIds()).resolves.toEqual([]); + }); }); - it("returns undefined when config file is missing", async () => { - const getObject = jest.fn().mockResolvedValue(undefined); - const { repository } = createRepository({ getObject }); + describe("getClientConfig", () => { + it("returns parsed config when file exists", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const { repository } = createRepository({ getObject }); - await expect( - repository.getClientSubscriptions("client-1"), - ).resolves.toBeUndefined(); - }); + await expect(repository.getClientConfig("client-1")).resolves.toEqual( + config, + ); + }); + + it("returns undefined when config file is missing", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); - 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, - MessageStatuses: ["FAILED"], - }; - const messageStatus = jest.fn().mockReturnValue(newMessage); - - const { repository } = createRepository({ - getObject, - putRawData, - messageStatus, + await expect( + repository.getClientConfig("client-1"), + ).resolves.toBeUndefined(); }); - const result = await repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["FAILED"], - rateLimit: 10, - dryRun: false, + it("throws when stored config is invalid", async () => { + const getObject = jest.fn().mockResolvedValue( + JSON.stringify( + createClientSubscriptionConfig({ + subscriptions: [messageSubscription], + }), + ), + ); + const { repository } = createRepository({ getObject }); + + await expect(repository.getClientConfig("client-1")).rejects.toThrow( + /Config validation failed/, + ); }); - expect(result).toEqual([channelSubscription, newMessage]); - expect(putRawData).toHaveBeenCalledWith( - JSON.stringify([channelSubscription, newMessage]), - "client_subscriptions/client-1.json", - ); + it("throws when stored config JSON cannot be parsed", async () => { + const getObject = jest.fn().mockResolvedValue("{ not valid json }"); + const { repository } = createRepository({ getObject }); + + await expect(repository.getClientConfig("client-1")).rejects.toThrow( + "Failed to parse stored config for client client-1", + ); + }); }); - it("skips S3 write when dry run is enabled", async () => { - const getObject = jest.fn().mockResolvedValue(undefined); - const putRawData = jest.fn(); - const messageStatus = jest.fn().mockReturnValue(messageSubscription); + describe("putClientConfig", () => { + it("writes config to S3 and returns it", async () => { + const putRawData = jest.fn(); + const config = baseConfig(); + const { repository } = createRepository({ putRawData }); - const { repository } = createRepository({ - getObject, - putRawData, - messageStatus, - }); + const result = await repository.putClientConfig( + "client-1", + config, + false, + ); - await repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["DELIVERED"], - rateLimit: 10, - dryRun: true, + expect(result).toEqual(config); + expect(putRawData).toHaveBeenCalledWith( + expect.any(String), + "client_subscriptions/client-1.json", + ); + expect(JSON.parse(putRawData.mock.calls[0][0] as string)).toEqual(config); }); - expect(putRawData).not.toHaveBeenCalled(); - }); + it("skips S3 write on dry run", async () => { + const putRawData = jest.fn(); + const config = baseConfig(); + const { repository } = createRepository({ putRawData }); - 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, - }); + await repository.putClientConfig("client-1", config, true); - 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(putRawData).not.toHaveBeenCalled(); }); - expect(result).toEqual([messageSubscription, newChannel]); - expect(putRawData).toHaveBeenCalledWith( - JSON.stringify([messageSubscription, newChannel]), - "client_subscriptions/client-1.json", - ); - }); + it("throws when config is invalid and does not write to S3", async () => { + const putRawData = jest.fn(); + const invalidConfig = createClientSubscriptionConfig({ + subscriptions: [messageSubscription], + }) as unknown as ClientSubscriptionConfiguration; + const { repository } = createRepository({ putRawData }); - it("skips S3 write for channel status dry run", async () => { - const getObject = jest.fn().mockResolvedValue(undefined); - const putRawData = jest.fn(); - const channelStatus = jest.fn().mockReturnValue(channelSubscription); + await expect( + repository.putClientConfig("client-1", invalidConfig, false), + ).rejects.toThrow(/Config validation failed/); - const { repository } = createRepository({ - getObject, - putRawData, - channelStatus, + expect(putRawData).not.toHaveBeenCalled(); }); + }); + + describe("addSubscription", () => { + it("appends subscription to existing config", async () => { + const existing = createClientSubscriptionConfig({ + subscriptions: [messageSubscription], + targets: [baseTarget], + }); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(existing)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + const result = await repository.addSubscription( + "client-1", + channelSubscription, + false, + ); - 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(result.subscriptions).toHaveLength(2); + expect(result.subscriptions[1]).toEqual(channelSubscription); + expect(putRawData).toHaveBeenCalledTimes(1); }); - expect(putRawData).not.toHaveBeenCalled(); + it("throws when resulting config would be invalid", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + await expect( + repository.addSubscription("client-1", messageSubscription, false), + ).rejects.toThrow(/Config validation failed/); + + expect(putRawData).not.toHaveBeenCalled(); + }); }); - describe("validation", () => { - it("throws validation error for invalid message status", async () => { - const { repository } = createRepository(); + describe("deleteSubscription", () => { + it("removes subscription by ID", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + const result = await repository.deleteSubscription( + "client-1", + "sub-001", + false, + ); - 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(z.ZodError); + expect(result.subscriptions).toHaveLength(1); + expect(result.subscriptions[0].subscriptionId).toBe("sub-002"); }); - it("throws validation error for missing required fields in message subscription", async () => { - const { repository } = createRepository(); + it("throws when config not found", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); 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(z.ZodError); + repository.deleteSubscription("client-1", "sub-001", false), + ).rejects.toThrow("No configuration found for client: client-1"); }); + }); - it("throws validation error for invalid channel type", async () => { - const { repository } = createRepository(); + describe("setSubscriptionStates", () => { + it("updates messageStatuses for a MessageStatus subscription", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + const result = await repository.setSubscriptionStates( + "client-1", + "sub-001", + { messageStatuses: ["FAILED"] }, + false, + ); - 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(z.ZodError); + const updated = result.subscriptions.find( + (s) => s.subscriptionId === "sub-001", + ) as MessageStatusSubscriptionConfiguration | undefined; + expect(updated?.messageStatuses).toEqual(["FAILED"]); }); - it("throws validation error for invalid channel status", async () => { - const { repository } = createRepository(); + it("throws when config not found", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); 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(z.ZodError); + repository.setSubscriptionStates("client-1", "sub-001", {}, false), + ).rejects.toThrow("No configuration found for client: client-1"); }); - it("throws validation error for invalid supplier status", async () => { - const { repository } = createRepository(); + it("updates channel and supplier statuses for a ChannelStatus subscription", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); - await expect( - repository.putChannelStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", + const result = await repository.setSubscriptionStates( + "client-1", + "sub-002", + { channelStatuses: ["DELIVERED"], - supplierStatuses: ["INVALID_STATUS" as never], - channelType: "SMS", - rateLimit: 10, - dryRun: false, - }), - ).rejects.toThrow(z.ZodError); - }); + supplierStatuses: ["read"], + }, + false, + ); - it("throws validation error when neither channelStatuses nor supplierStatuses are provided", async () => { - const { repository } = createRepository(); + const updated = result.subscriptions.find( + (s) => s.subscriptionId === "sub-002", + ); - await expect( - repository.putChannelStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - channelType: "SMS", - rateLimit: 10, - dryRun: false, + expect(updated).toEqual( + expect.objectContaining({ + subscriptionType: "ChannelStatus", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["read"], }), - ).rejects.toThrow( - /at least one of channelStatuses or supplierStatuses must be provided/, ); }); - it("applies default value for apiKeyHeaderName on message subscription", async () => { - const getObject = jest.fn().mockResolvedValue(undefined as never); - const messageStatus = jest.fn().mockReturnValue(messageSubscription); + it("leaves subscriptions unchanged when subscription ID does not exist", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + const result = await repository.setSubscriptionStates( + "client-1", + "missing-subscription-id", + { channelStatuses: ["FAILED"] }, + false, + ); - const { configurationBuilder, repository } = createRepository({ - getObject, - messageStatus, - }); + expect(result.subscriptions).toEqual(config.subscriptions); + }); + }); - await repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["DELIVERED"], - rateLimit: 10, - dryRun: false, - }); + describe("addTarget", () => { + it("appends target to existing config", async () => { + const existing = createClientSubscriptionConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(existing)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); - expect(configurationBuilder.messageStatus).toHaveBeenCalledWith( - expect.objectContaining({ - apiKeyHeaderName: "x-api-key", - }), - ); + const result = await repository.addTarget("client-1", baseTarget, false); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toEqual(baseTarget); }); - it("applies default value for apiKeyHeaderName on channel subscription", async () => { - const getObject = jest.fn().mockResolvedValue(undefined as never); - const channelStatus = jest.fn().mockReturnValue(channelSubscription); + it("creates new config when none exists", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); - const { configurationBuilder, repository } = createRepository({ - getObject, - channelStatus, - }); + const result = await repository.addTarget("client-1", baseTarget, false); - 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(result.clientId).toBe("client-1"); + expect(result.targets).toEqual([baseTarget]); + }); + }); - expect(configurationBuilder.channelStatus).toHaveBeenCalledWith( - expect.objectContaining({ - apiKeyHeaderName: "x-api-key", - }), + describe("deleteTarget", () => { + it("removes target by ID when it is not referenced", async () => { + const config = createClientSubscriptionConfig({ targets: [baseTarget] }); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + const result = await repository.deleteTarget( + "client-1", + TARGET_ID, + false, + ); + + expect(result.targets).toHaveLength(0); + }); + + it("throws when removing a referenced target would invalidate the config", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + await expect( + repository.deleteTarget("client-1", TARGET_ID, false), + ).rejects.toThrow( + `Cannot delete target ${TARGET_ID}: still referenced by subscriptions sub-001, sub-002`, ); + + expect(putRawData).not.toHaveBeenCalled(); + }); + + it("throws when config not found", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); + + await expect( + repository.deleteTarget("client-1", TARGET_ID, false), + ).rejects.toThrow("No configuration found for client: client-1"); }); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts index 04a90370..30a2ad43 100644 --- a/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts @@ -1,5 +1,6 @@ import { GetObjectCommand, + ListObjectsV2Command, NoSuchKey, PutObjectCommand, S3Client, @@ -65,4 +66,30 @@ describe("S3Repository", () => { expect(send).toHaveBeenCalledTimes(1); expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); }); + + it("lists keys across paginated responses and ignores missing keys", async () => { + const send = jest + .fn() + .mockResolvedValueOnce({ + Contents: [{ Key: "client_subscriptions/a.json" }, {}], + NextContinuationToken: "token-1", + }) + .mockResolvedValueOnce({ + Contents: [{ Key: "client_subscriptions/b.json" }], + NextContinuationToken: undefined, + }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const keys = await repository.listObjectKeys("client_subscriptions/"); + + expect(keys).toEqual([ + "client_subscriptions/a.json", + "client_subscriptions/b.json", + ]); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[0][0]).toBeInstanceOf(ListObjectsV2Command); + expect(send.mock.calls[1][0]).toBeInstanceOf(ListObjectsV2Command); + }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/terraform.test.ts b/tools/client-subscriptions-management/src/__tests__/terraform.test.ts new file mode 100644 index 00000000..7b36def9 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/terraform.test.ts @@ -0,0 +1,153 @@ +import runTerraformApply from "src/terraform"; + +jest.mock("node:child_process", () => ({ + spawnSync: jest.fn().mockReturnValue({ status: 0 }), +})); + +jest.mock("node:readline/promises", () => ({ + createInterface: jest.fn(), +})); + +describe("runTerraformApply", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, security/detect-child-process + const { spawnSync } = require("node:child_process") as { + spawnSync: jest.Mock; + }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createInterface } = require("node:readline/promises") as { + createInterface: jest.Mock; + }; + + const originalExitCode = process.exitCode; + + beforeEach(() => { + spawnSync.mockReturnValue({ status: 0 }); + createInterface.mockReset(); + delete process.exitCode; + }); + + afterAll(() => { + process.exitCode = originalExitCode; + }); + + it("returns false and sets exitCode when environment is missing", async () => { + const result = await runTerraformApply({ group: "dev" }); + expect(result).toBe(false); + expect(process.exitCode).toBe(1); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it("returns false and sets exitCode when group is missing", async () => { + const result = await runTerraformApply({ environment: "dev" }); + expect(result).toBe(false); + expect(process.exitCode).toBe(1); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it("runs terraform plan first and returns false when plan fails", async () => { + spawnSync.mockReturnValueOnce({ status: 1 }); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + }); + + expect(result).toBe(false); + expect(createInterface).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spawnSync).toHaveBeenCalledWith( + "make", + expect.arrayContaining(["terraform-plan"]), + expect.anything(), + ); + }); + + it("returns false without applying when user declines confirmation", async () => { + const close = jest.fn(); + createInterface.mockReturnValue({ + close, + question: jest.fn().mockResolvedValue("n"), + }); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + }); + + expect(result).toBe(false); + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spawnSync.mock.calls[0][1]).toContain("terraform-plan"); + }); + + it("runs apply and returns true when plan succeeds and user confirms", async () => { + createInterface.mockReturnValue({ + close: jest.fn(), + question: jest.fn().mockResolvedValue("y"), + }); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + }); + + expect(result).toBe(true); + expect(spawnSync).toHaveBeenCalledTimes(2); + expect(spawnSync.mock.calls[0][1]).toContain("terraform-plan"); + expect(spawnSync.mock.calls[1][1]).toContain("terraform-apply"); + expect(spawnSync.mock.calls[1][1]).toContain("environment=dev"); + expect(spawnSync.mock.calls[1][1]).toContain("group=mygroup"); + expect(spawnSync.mock.calls[1][1]).toContain("project=nhs"); + }); + + it("includes optional region arg when tfRegion is provided", async () => { + createInterface.mockReturnValue({ + close: jest.fn(), + question: jest.fn().mockResolvedValue("y"), + }); + + await runTerraformApply({ + environment: "dev", + group: "mygroup", + tfRegion: "eu-west-1", + }); + + expect(spawnSync.mock.calls[1][1]).toContain("region=eu-west-1"); + }); + + it("returns false and sets exitCode when apply fails", async () => { + spawnSync + .mockReturnValueOnce({ status: 0 }) // plan succeeds + .mockReturnValueOnce({ status: 2 }); // apply fails + createInterface.mockReturnValue({ + close: jest.fn(), + question: jest.fn().mockResolvedValue("y"), + }); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + }); + + expect(result).toBe(false); + expect(process.exitCode).toBe(2); + }); + + it("prompts via readline and always uses interactive confirmation", async () => { + const close = jest.fn(); + const question = jest.fn().mockResolvedValue("y"); + createInterface.mockReturnValue({ close, question }); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + }); + + expect(result).toBe(true); + expect(createInterface).toHaveBeenCalledWith({ + input: process.stdin, + output: process.stdout, + }); + expect(question).toHaveBeenCalledWith("\nApply these changes? [y/N] "); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/client-subscriptions-management/src/aws.ts b/tools/client-subscriptions-management/src/aws.ts new file mode 100644 index 00000000..9e889096 --- /dev/null +++ b/tools/client-subscriptions-management/src/aws.ts @@ -0,0 +1,77 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; +import { fromIni } from "@aws-sdk/credential-providers"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { S3Repository } from "src/repository/s3"; + +export const resolveProfile = ( + profileArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => profileArg ?? env.AWS_PROFILE; + +export const resolveAccountId = async ( + profile?: string, + region?: string, +): Promise => { + const credentials = profile ? fromIni({ profile }) : undefined; + const client = new STSClient({ region, credentials }); + const { Account } = await client.send(new GetCallerIdentityCommand({})); + if (!Account) { + throw new Error("Unable to determine AWS account ID from STS"); + } + return Account; +}; + +export const deriveBucketName = ( + accountId: string, + environment: string, + region: string, +): string => + `nhs-${accountId}-${region}-${environment}-callbacks-subscription-config`; + +export const resolveRegion = ( + regionArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; + +export const resolveBucketName = async ( + bucketArg?: string, + environment?: string, + region?: string, + profile?: string, +): Promise => { + if (bucketArg) { + return bucketArg; + } + if (!environment) { + throw new Error( + "Bucket name is required: use --bucket-name to specify directly, or --environment (with --region and optionally --profile) to determine this automatically", + ); + } + const resolvedRegion = resolveRegion(region) ?? "eu-west-2"; + const accountId = await resolveAccountId(profile, resolvedRegion); + return deriveBucketName(accountId, environment, resolvedRegion); +}; + +export const createS3Client = ( + region?: string, + profile?: string, + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + const credentials = profile ? fromIni({ profile }) : undefined; + return new S3Client({ region, endpoint, forcePathStyle, credentials }); +}; + +export const createRepository = (options: { + bucketName: string; + region?: string; + profile?: string; +}): ClientSubscriptionRepository => { + const s3Repository = new S3Repository( + options.bucketName, + createS3Client(options.region, options.profile), + ); + return new ClientSubscriptionRepository(s3Repository); +}; diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts deleted file mode 100644 index ddac5009..00000000 --- a/tools/client-subscriptions-management/src/container.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { S3Client } from "@aws-sdk/client-s3"; -import { fromIni } from "@aws-sdk/credential-providers"; -import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; -import { S3Repository } from "src/repository/s3"; -import { clientSubscriptionBuilder } from "src/domain/client-subscription-builder"; - -type RepositoryOptions = { - bucketName: string; - region?: string; - profile?: string; -}; - -export const createS3Client = ( - region?: string, - profile?: string, - env: NodeJS.ProcessEnv = process.env, -): S3Client => { - const endpoint = env.AWS_ENDPOINT_URL; - const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; - const credentials = profile ? fromIni({ profile }) : undefined; - return new S3Client({ region, endpoint, forcePathStyle, credentials }); -}; - -export const createClientSubscriptionRepository = ( - options: RepositoryOptions, -): ClientSubscriptionRepository => { - const s3Repository = new S3Repository( - options.bucketName, - createS3Client(options.region, options.profile), - ); - return new ClientSubscriptionRepository( - s3Repository, - clientSubscriptionBuilder, - ); -}; diff --git a/tools/client-subscriptions-management/src/domain/client-config-validator.ts b/tools/client-subscriptions-management/src/domain/client-config-validator.ts new file mode 100644 index 00000000..c2155fa9 --- /dev/null +++ b/tools/client-subscriptions-management/src/domain/client-config-validator.ts @@ -0,0 +1,21 @@ +import { + type ClientSubscriptionConfiguration, + parseClientSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import { prettifyError } from "zod"; + +export const validateClientConfig = ( + rawConfig: unknown, +): ClientSubscriptionConfiguration => { + const result = parseClientSubscriptionConfiguration(rawConfig); + + if (!result.success) { + const messages = prettifyError(result.error); + + throw new Error(`Config validation failed:\n${messages}`); + } + + return result.data; +}; + +export default validateClientConfig; diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts index 11602f99..f91ee5a4 100644 --- a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -1,97 +1,68 @@ -import { normalizeClientName } from "src/entrypoint/cli/helper"; -import type { - ChannelStatusSubscriptionArgs, - MessageStatusSubscriptionArgs, -} from "src/repository/client-subscriptions"; import type { + CallbackTarget, + Channel, + ChannelStatus, ChannelStatusSubscriptionConfiguration, + MessageStatus, MessageStatusSubscriptionConfiguration, + SupplierStatus, } from "@nhs-notify-client-callbacks/models"; -export type SubscriptionBuilder = { - messageStatus( - args: MessageStatusSubscriptionArgs, - ): MessageStatusSubscriptionConfiguration; - channelStatus( - args: ChannelStatusSubscriptionArgs, - ): ChannelStatusSubscriptionConfiguration; +export type BuildTargetArgs = { + apiEndpoint: string; + apiKey: string; + apiKeyHeaderName?: string; + rateLimit: number; +}; + +export type BuildMessageStatusSubscriptionArgs = { + subscriptionId: string; + targetIds: string[]; + messageStatuses: MessageStatus[]; }; +export type BuildChannelStatusSubscriptionArgs = { + subscriptionId: string; + targetIds: string[]; + channelType: Channel; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; +}; + +export function buildTarget(args: BuildTargetArgs): CallbackTarget { + return { + targetId: crypto.randomUUID(), + type: "API", + invocationEndpoint: args.apiEndpoint, + invocationMethod: "POST", + invocationRateLimit: args.rateLimit, + apiKey: { + headerName: args.apiKeyHeaderName ?? "x-api-key", + headerValue: args.apiKey, + }, + }; +} + export function buildMessageStatusSubscription( - args: MessageStatusSubscriptionArgs, + args: BuildMessageStatusSubscriptionArgs, ): MessageStatusSubscriptionConfiguration { - const { - apiEndpoint, - apiKey, - apiKeyHeaderName = "x-api-key", - clientId, - clientName, - rateLimit, - statuses, - } = args; - const normalizedClientName = normalizeClientName(clientName); - const subscriptionId = normalizedClientName; return { - SubscriptionId: subscriptionId, - SubscriptionType: "MessageStatus", - ClientId: clientId, - MessageStatuses: statuses, - Targets: [ - { - Type: "API", - TargetId: crypto.randomUUID(), - InvocationEndpoint: apiEndpoint, - InvocationMethod: "POST", - InvocationRateLimit: rateLimit, - APIKey: { - HeaderName: apiKeyHeaderName, - HeaderValue: apiKey, - }, - }, - ], + subscriptionId: args.subscriptionId, + subscriptionType: "MessageStatus", + targetIds: args.targetIds, + messageStatuses: args.messageStatuses, }; } export function buildChannelStatusSubscription( - args: ChannelStatusSubscriptionArgs, + args: BuildChannelStatusSubscriptionArgs, ): ChannelStatusSubscriptionConfiguration { - const { - apiEndpoint, - apiKey, - apiKeyHeaderName = "x-api-key", - channelStatuses, - channelType, - clientId, - clientName, - rateLimit, - supplierStatuses, - } = args; - const normalizedClientName = normalizeClientName(clientName); - const subscriptionId = `${normalizedClientName}-${channelType}`; return { - SubscriptionId: subscriptionId, - SubscriptionType: "ChannelStatus", - ClientId: clientId, - ChannelType: channelType, - ChannelStatuses: channelStatuses ?? [], - SupplierStatuses: supplierStatuses ?? [], - Targets: [ - { - Type: "API", - TargetId: crypto.randomUUID(), - InvocationEndpoint: apiEndpoint, - InvocationMethod: "POST", - InvocationRateLimit: rateLimit, - APIKey: { - HeaderName: apiKeyHeaderName, - HeaderValue: apiKey, - }, - }, - ], + subscriptionId: args.subscriptionId, + subscriptionType: "ChannelStatus", + targetIds: args.targetIds, + channelType: args.channelType, + channelStatuses: args.channelStatuses ?? [], + supplierStatuses: args.supplierStatuses ?? [], }; } - -export const clientSubscriptionBuilder: SubscriptionBuilder = { - messageStatus: buildMessageStatusSubscription, - channelStatus: buildChannelStatusSubscription, -}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts new file mode 100644 index 00000000..723b445d --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -0,0 +1,38 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, +} from "src/entrypoint/cli/helper"; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const repository = await createRepository(argv); + + const config = await repository.getClientConfig(argv["client-id"]); + + if (config) { + console.log(JSON.stringify(config, null, 2)); + } else { + console.log(`No configuration exists for client: ${argv["client-id"]}`); + } +}; + +export const command: CliCommand = { + command: "clients-get", + describe: "Get a client configuration", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts new file mode 100644 index 00000000..06f35535 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -0,0 +1,38 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type CommonCliArgs, + commonOptions, + createRepository, + runCommand, +} from "src/entrypoint/cli/helper"; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const repository = await createRepository(argv); + + const clientIds = await repository.listClientIds(); + if (clientIds.length === 0) { + console.log("No client IDs found"); + return; + } + + for (const id of clientIds) { + console.log(id); + } +}; + +export const command: CliCommand = { + command: "clients-list", + describe: "List configured client IDs", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts new file mode 100644 index 00000000..f15df9ed --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -0,0 +1,134 @@ +import { readFileSync } from "node:fs"; +import type { Argv } from "yargs"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import runTerraformApply from "src/terraform"; + +type ClientsPutArgs = ClientCliArgs & + WriteCliArgs & { + file?: string; + group?: string; + json?: string; + "terraform-apply": boolean; + "tf-region"?: string; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + json: { + type: "string", + demandOption: false, + description: + "JSON string of the full client config (mutually exclusive with --file)", + }, + file: { + type: "string", + demandOption: false, + description: + "Path to a JSON file containing the full client config (mutually exclusive with --json)", + }, + "terraform-apply": { + type: "boolean", + default: false, + demandOption: false, + description: "Run terraform apply after uploading config", + }, + group: { + type: "string", + demandOption: false, + description: "Group name (required when --terraform-apply is set)", + }, + "tf-region": { + type: "string", + demandOption: false, + description: "AWS region override for terraform", + }, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + if (!argv.json && !argv.file) { + console.error("Error: one of --json or --file is required"); + process.exitCode = 1; + return; + } + + if (argv.json && argv.file) { + console.error("Error: --json and --file are mutually exclusive"); + process.exitCode = 1; + return; + } + + if (argv.file && !argv.file.trim().toLowerCase().endsWith(".json")) { + console.error("Error: --file must be a .json path"); + process.exitCode = 1; + return; + } + + // Safe as this is an internal tool and this CLI option we are expecting the user will run locally and manually + // eslint-disable-next-line security/detect-non-literal-fs-filename + const rawJson = argv.json ?? readFileSync(argv.file!, "utf8"); + + let config: ClientSubscriptionConfiguration; + try { + config = JSON.parse(rawJson) as ClientSubscriptionConfiguration; + } catch { + console.error("Error: failed to parse JSON input"); + process.exitCode = 1; + return; + } + + if (config.clientId !== argv["client-id"]) { + console.error( + `Error: clientId in config ("${config.clientId}") does not match --client-id ("${argv["client-id"]}")`, + ); + process.exitCode = 1; + return; + } + + const repository = await createRepository(argv); + + const result = await repository.putClientConfig( + argv["client-id"], + config, + argv["dry-run"], + ); + + console.log(`Config written for client: ${argv["client-id"]}`); + + if (argv["dry-run"]) { + console.log("Dry run: config is valid"); + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (argv["terraform-apply"]) { + await runTerraformApply({ + environment: argv.environment, + group: argv.group, + tfRegion: argv["tf-region"], + }); + } +}; + +export const command: CliCommand = { + command: "clients-put", + describe: "Write a full client configuration", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts deleted file mode 100644 index c1f4665f..00000000 --- a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { spawnSync } from "node:child_process"; -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - MESSAGE_STATUSES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; -import { - formatSubscriptionFileResponse, - resolveBucketName, - resolveProfile, - resolveRegion, -} from "src/entrypoint/cli/helper"; - -const sharedOptions = { - "bucket-name": { - type: "string" as const, - demandOption: false, - description: "Explicit S3 bucket name (overrides derived name)", - }, - "client-name": { - type: "string" as const, - demandOption: false, - description: "Display name for the client (defaults to client-id)", - }, - "client-id": { - type: "string" as const, - demandOption: true, - description: "Client identifier", - }, - "api-endpoint": { - type: "string" as const, - demandOption: true, - description: "Webhook endpoint URL (must start with https://)", - }, - "api-key": { - type: "string" as const, - demandOption: true, - description: "API key value for authenticating webhook calls", - }, - "api-key-header-name": { - type: "string" as const, - default: "x-api-key", - demandOption: false, - description: "HTTP header name for the API key", - }, - "rate-limit": { - type: "number" as const, - demandOption: true, - description: "Maximum number of webhook calls per second", - }, - "dry-run": { - type: "boolean" as const, - demandOption: true, - description: "Validate config without writing to S3", - }, - region: { - type: "string" as const, - demandOption: false, - description: "AWS region (defaults to AWS_REGION or eu-west-2)", - }, - "terraform-apply": { - type: "boolean" as const, - default: false, - demandOption: false, - description: "Run terraform apply after uploading config", - }, - environment: { - type: "string" as const, - demandOption: false, - description: - "Environment name, used to derive infrastructure resource names when not explicitly provided", - }, - group: { - type: "string" as const, - demandOption: false, - description: "Group name (required when --terraform-apply is set)", - }, - project: { - type: "string" as const, - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, - "tf-region": { - type: "string" as const, - demandOption: false, - description: "AWS region override for terraform", - }, - profile: { - type: "string" as const, - demandOption: false, - description: "AWS profile to use (overrides AWS_PROFILE)", - }, -} as const; - -function runTerraformApply(argv: { - environment?: string; - group?: string; - project?: string; - "tf-region"?: string; -}) { - const { environment, group, project = "nhs", "tf-region": tfRegion } = argv; - if (!environment || !group) { - console.error( - "Error: --environment and --group are required when --terraform-apply is set", - ); - process.exitCode = 1; - return false; - } - - console.log( - "[deploy-client-subscriptions] Running terraform apply for callbacks component...", - ); - - const makeArgs = [ - "terraform-apply", - `component=callbacks`, - `environment=${environment}`, - `group=${group}`, - `project=${project}`, - ]; - if (tfRegion) { - makeArgs.push(`region=${tfRegion}`); - } - - // eslint-disable-next-line sonarjs/no-os-command-from-path - const result = spawnSync("make", makeArgs, { stdio: "inherit" }); - if (result.status !== 0) { - console.error( - `Error: terraform apply failed with exit code ${result.status}`, - ); - process.exitCode = result.status ?? 1; - return false; - } - return true; -} - -export async function main(args: string[] = process.argv) { - await yargs(hideBin(args)) - .command( - "message", - "Deploy a message status subscription", - { - ...sharedOptions, - "message-statuses": { - string: true, - type: "array" as const, - demandOption: true, - choices: MESSAGE_STATUSES, - }, - }, - async (argv) => { - const apiEndpoint = argv["api-endpoint"]; - if (!/^https:\/\//.test(apiEndpoint)) { - console.error("Error: api-endpoint must start with https://"); - process.exitCode = 1; - return; - } - - console.log( - "[deploy-client-subscriptions] Uploading message status subscription config...", - ); - - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - argv.project, - ); - const clientSubscriptionRepository = createClientSubscriptionRepository( - { - bucketName, - region, - profile, - }, - ); - - 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"], - }); - - console.log(formatSubscriptionFileResponse(result)); - - if (argv["terraform-apply"]) { - runTerraformApply(argv); - } - }, - ) - .command( - "channel", - "Deploy a channel status subscription", - { - ...sharedOptions, - "channel-type": { - type: "string" as const, - demandOption: true, - choices: CHANNEL_TYPES, - }, - "channel-statuses": { - string: true, - type: "array" as const, - demandOption: false, - choices: CHANNEL_STATUSES, - }, - "supplier-statuses": { - string: true, - type: "array" as const, - demandOption: false, - choices: SUPPLIER_STATUSES, - }, - }, - async (argv) => { - const apiEndpoint = argv["api-endpoint"]; - if (!/^https:\/\//.test(apiEndpoint)) { - console.error("Error: api-endpoint must start with https://"); - process.exitCode = 1; - return; - } - - const channelStatuses = argv["channel-statuses"]; - const supplierStatuses = argv["supplier-statuses"]; - if (!channelStatuses?.length && !supplierStatuses?.length) { - console.error( - "Error: at least one of --channel-statuses or --supplier-statuses must be provided", - ); - process.exitCode = 1; - return; - } - - console.log( - "[deploy-client-subscriptions] Uploading channel status subscription config...", - ); - - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - argv.project, - ); - const clientSubscriptionRepository = createClientSubscriptionRepository( - { - bucketName, - region, - profile, - }, - ); - - 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"], - }); - - console.log(formatSubscriptionFileResponse(result)); - - if (argv["terraform-apply"]) { - runTerraformApply(argv); - } - }, - ) - .demandCommand(1, "Please specify a command: message or channel") - .strict() - .parseAsync(); -} - -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; - -(async () => { - if (require.main === module) { - await runCli(); - } -})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts deleted file mode 100644 index f9ce855c..00000000 --- a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts +++ /dev/null @@ -1,90 +0,0 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; -import { - formatSubscriptionFileResponse, - resolveBucketName, - resolveProfile, - resolveRegion, -} from "src/entrypoint/cli/helper"; - -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - "bucket-name": { - type: "string", - demandOption: false, - description: "Explicit S3 bucket name (overrides derived name)", - }, - environment: { - type: "string", - demandOption: false, - description: - "Environment name, used to derive infrastructure resource names when not explicitly provided", - }, - "client-id": { - type: "string", - demandOption: true, - description: "Client identifier", - }, - region: { - type: "string", - demandOption: false, - description: "AWS region (defaults to AWS_REGION or eu-west-2)", - }, - profile: { - type: "string", - demandOption: false, - description: "AWS profile to use (overrides AWS_PROFILE)", - }, - }) - .parseSync(); - -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); - - const result = await clientSubscriptionRepository.getClientSubscriptions( - argv["client-id"], - ); - - if (result) { - console.log(formatSubscriptionFileResponse(result)); - } else { - console.log(`No configuration exists for client: ${argv["client-id"]}`); - } -} - -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - 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 index d060417b..2d1ce289 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,103 +1,132 @@ -import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; -import { fromIni } from "@aws-sdk/credential-providers"; -import { table } from "table"; -import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + createRepository as createRepositoryFromOptions, + resolveBucketName, + resolveProfile, + resolveRegion, +} from "src/aws"; +import { hideBin } from "yargs/helpers"; +import yargs from "yargs/yargs"; +import type { Argv, CommandModule } from "yargs"; -const SUBSCRIPTION_TABLE_HEADER = [ - "Client ID", - "Subscription Type", - "Statuses", - "Target ID", - "Endpoint", - "Method", - "Rate Limit", - "API Key Header", - "API Key Value", -]; +export const wrapCli = + (mainFn: (args: string[]) => Promise) => + async (args: string[] = process.argv): Promise => { + try { + await mainFn(args); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } + }; -const subscriptionStatuses = ( - subscription: ClientSubscriptionConfiguration[number], -): string => { - if (subscription.SubscriptionType === "MessageStatus") { - return subscription.MessageStatuses.join(", "); - } - const statuses = [ - ...subscription.ChannelStatuses, - ...subscription.SupplierStatuses, - ]; - return `${subscription.ChannelType}: ${statuses.join(", ")}`; +export type CommonCliArgs = { + "bucket-name"?: string; + environment?: string; + profile?: string; + region?: string; +}; + +export type ClientCliArgs = CommonCliArgs & { + "client-id": string; }; -export const formatSubscriptionFileResponse = ( - subscriptions: ClientSubscriptionConfiguration, -): string => { - const rows = subscriptions.flatMap((subscription) => - subscription.Targets.map((target) => [ - subscription.ClientId, - subscription.SubscriptionType, - subscriptionStatuses(subscription), - target.TargetId, - target.InvocationEndpoint, - target.InvocationMethod, - String(target.InvocationRateLimit), - target.APIKey.HeaderName, - target.APIKey.HeaderValue, - ]), +export type WriteCliArgs = { + "dry-run": boolean; +}; + +export const createRepository = async (argv: CommonCliArgs) => { + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, ); - return table([SUBSCRIPTION_TABLE_HEADER, ...rows]); + return createRepositoryFromOptions({ bucketName, region, profile }); }; -export const normalizeClientName = (name: string): string => - name.replaceAll(/\s+/g, "-").toLowerCase(); +type BaseArgs = Record; + +export type CliCommand = CommandModule; + +export type AnyCliCommand = CliCommand; -export const resolveProfile = ( - profileArg?: string, - env: NodeJS.ProcessEnv = process.env, -): string | undefined => profileArg ?? env.AWS_PROFILE; +const configureParser = (parser: Argv) => + parser + .strict() + .recommendCommands() + .demandCommand(1) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .help(); -export const resolveAccountId = async ( - profile?: string, - region?: string, -): Promise => { - const credentials = profile ? fromIni({ profile }) : undefined; - const client = new STSClient({ region, credentials }); - const { Account } = await client.send(new GetCallerIdentityCommand({})); - if (!Account) { - throw new Error("Unable to determine AWS account ID from STS"); +export const runCommand = async ( + command: CliCommand, + args: string[] = process.argv, +): Promise => { + const commandArgs = [ + args[0] ?? "node", + args[1] ?? "script", + String(command.command).split(/\s+/)[0], + ...args.slice(2), + ]; + + await configureParser(yargs(hideBin(commandArgs))) + .command(command) + .parseAsync(); +}; + +export const runCommands = async ( + commands: AnyCliCommand[], + args: string[] = process.argv, +): Promise => { + let parser = configureParser(yargs(hideBin(args))); + for (const command of commands) { + parser = parser.command(command); } - return Account; + await parser.parseAsync(); }; -export const deriveBucketName = ( - accountId: string, - environment: string, - region: string, - project = "nhs", - component = "callbacks", -): string => - `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; +export const commonOptions = { + "bucket-name": { + type: "string" as const, + demandOption: false as const, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string" as const, + demandOption: false as const, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + region: { + type: "string" as const, + demandOption: false as const, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string" as const, + demandOption: false as const, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, +}; -export const resolveBucketName = async ( - bucketArg?: string, - environment?: string, - region?: string, - profile?: string, - project?: string, -): Promise => { - if (bucketArg) { - return bucketArg; - } - if (!environment) { - throw new Error( - "Bucket name is required: use --bucket-name to specify directly, or --environment (with --region and optionally --profile) to determine this automatically", - ); - } - const resolvedRegion = region ?? "eu-west-2"; - const accountId = await resolveAccountId(profile, resolvedRegion); - return deriveBucketName(accountId, environment, resolvedRegion, project); +export const clientIdOption = { + "client-id": { + type: "string" as const, + demandOption: true as const, + description: "Client identifier", + }, }; -export const resolveRegion = ( - regionArg?: string, - env: NodeJS.ProcessEnv = process.env, -): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; +export const writeOptions = { + "dry-run": { + type: "boolean" as const, + default: false, + demandOption: false as const, + description: "Validate config without writing to S3", + }, +}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/index.ts b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts new file mode 100644 index 00000000..88d1a733 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts @@ -0,0 +1,33 @@ +import { command as clientsGetCommand } from "src/entrypoint/cli/clients-get"; +import { command as clientsListCommand } from "src/entrypoint/cli/clients-list"; +import { command as clientsPutCommand } from "src/entrypoint/cli/clients-put"; +import type { AnyCliCommand } from "src/entrypoint/cli/helper"; +import { runCommands, wrapCli } from "src/entrypoint/cli/helper"; +import { command as subscriptionsAddCommand } from "src/entrypoint/cli/subscriptions-add"; +import { command as subscriptionsDelCommand } from "src/entrypoint/cli/subscriptions-del"; +import { command as subscriptionsListCommand } from "src/entrypoint/cli/subscriptions-list"; +import { command as subscriptionsSetStatesCommand } from "src/entrypoint/cli/subscriptions-set-states"; +import { command as targetsAddCommand } from "src/entrypoint/cli/targets-add"; +import { command as targetsDelCommand } from "src/entrypoint/cli/targets-del"; +import { command as targetsListCommand } from "src/entrypoint/cli/targets-list"; + +export const commands: AnyCliCommand[] = [ + clientsListCommand, + clientsGetCommand, + clientsPutCommand, + subscriptionsListCommand, + subscriptionsAddCommand, + subscriptionsDelCommand, + subscriptionsSetStatesCommand, + targetsListCommand, + targetsAddCommand, + targetsDelCommand, +]; + +export async function main(args: string[] = process.argv) { + await runCommands(commands, args); +} + +export const runCli = wrapCli(main); + +runCli(); 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 deleted file mode 100644 index 4097dd91..00000000 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts +++ /dev/null @@ -1,169 +0,0 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; -import { - formatSubscriptionFileResponse, - resolveBucketName, - resolveProfile, - resolveRegion, -} from "src/entrypoint/cli/helper"; - -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - "bucket-name": { - type: "string", - demandOption: false, - description: "Explicit S3 bucket name (overrides derived name)", - }, - environment: { - type: "string", - demandOption: false, - description: - "Environment name, used to derive infrastructure resource names when not explicitly provided", - }, - "client-name": { - type: "string", - demandOption: false, - description: "Display name for the client (defaults to client-id)", - }, - "client-id": { - type: "string", - demandOption: true, - description: "Client identifier", - }, - "api-endpoint": { - type: "string", - demandOption: true, - description: "Webhook endpoint URL (must start with https://)", - }, - "api-key-header-name": { - type: "string", - default: "x-api-key", - demandOption: false, - description: "HTTP header name for the API key", - }, - "api-key": { - type: "string", - demandOption: true, - description: "API key value for authenticating webhook calls", - }, - "channel-statuses": { - string: true, - type: "array", - demandOption: false, - choices: CHANNEL_STATUSES, - description: "Channel statuses to subscribe to", - }, - "supplier-statuses": { - string: true, - type: "array", - demandOption: false, - choices: SUPPLIER_STATUSES, - description: "Supplier statuses to subscribe to", - }, - "channel-type": { - type: "string", - demandOption: true, - choices: CHANNEL_TYPES, - description: "Channel type", - }, - "rate-limit": { - type: "number", - demandOption: true, - description: "Maximum number of webhook calls per second", - }, - "dry-run": { - type: "boolean", - demandOption: true, - description: "Validate config without writing to S3", - }, - region: { - type: "string", - demandOption: false, - description: "AWS region (defaults to AWS_REGION or eu-west-2)", - }, - profile: { - type: "string", - demandOption: false, - description: "AWS profile to use (overrides AWS_PROFILE)", - }, - }) - .parseSync(); - -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); - const apiEndpoint = argv["api-endpoint"]; - if (!/^https:\/\//.test(apiEndpoint)) { - console.error("Error: api-endpoint must start with https://"); - process.exitCode = 1; - return; - } - - const channelStatuses = argv["channel-statuses"]; - const supplierStatuses = argv["supplier-statuses"]; - if (!channelStatuses?.length && !supplierStatuses?.length) { - console.error( - "Error: at least one of --channel-statuses or --supplier-statuses must be provided", - ); - process.exitCode = 1; - return; - } - - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); - - 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"], - }); - - console.log(formatSubscriptionFileResponse(result)); -} - -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - 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 deleted file mode 100644 index 8dcdb356..00000000 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts +++ /dev/null @@ -1,140 +0,0 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; -import { MESSAGE_STATUSES } from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; -import { - formatSubscriptionFileResponse, - resolveBucketName, - resolveProfile, - resolveRegion, -} from "src/entrypoint/cli/helper"; - -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - "bucket-name": { - type: "string", - demandOption: false, - description: "Explicit S3 bucket name (overrides derived name)", - }, - environment: { - type: "string", - demandOption: false, - description: - "Environment name, used to derive infrastructure resource names when not explicitly provided", - }, - "client-name": { - type: "string", - demandOption: false, - description: "Display name for the client (defaults to client-id)", - }, - "client-id": { - type: "string", - demandOption: true, - description: "Client identifier", - }, - "api-endpoint": { - type: "string", - demandOption: true, - description: "Webhook endpoint URL (must start with https://)", - }, - "api-key": { - type: "string", - demandOption: true, - description: "API key value for authenticating webhook calls", - }, - "api-key-header-name": { - type: "string", - default: "x-api-key", - demandOption: false, - description: "HTTP header name for the API key", - }, - "message-statuses": { - string: true, - type: "array", - demandOption: true, - choices: MESSAGE_STATUSES, - description: "Message statuses to subscribe to", - }, - "rate-limit": { - type: "number", - demandOption: true, - description: "Maximum number of webhook calls per second", - }, - "dry-run": { - type: "boolean", - demandOption: true, - description: "Validate config without writing to S3", - }, - region: { - type: "string", - demandOption: false, - description: "AWS region (defaults to AWS_REGION or eu-west-2)", - }, - profile: { - type: "string", - demandOption: false, - description: "AWS profile to use (overrides AWS_PROFILE)", - }, - }) - .parseSync(); - -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); - const apiEndpoint = argv["api-endpoint"]; - if (!/^https:\/\//.test(apiEndpoint)) { - console.error("Error: api-endpoint must start with https://"); - process.exitCode = 1; - return; - } - - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); - - 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"], - }); - - console.log(formatSubscriptionFileResponse(result)); -} - -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - 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/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts new file mode 100644 index 00000000..fc34cd2b --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -0,0 +1,161 @@ +import type { Argv } from "yargs"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { + buildChannelStatusSubscription, + buildMessageStatusSubscription, +} from "src/domain/client-subscription-builder"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type SubscriptionsAddArgs = ClientCliArgs & + WriteCliArgs & { + "channel-statuses"?: ChannelStatus[]; + "channel-type"?: (typeof CHANNEL_TYPES)[number]; + "message-statuses"?: MessageStatus[]; + "subscription-id"?: string; + "subscription-type": "MessageStatus" | "ChannelStatus"; + "supplier-statuses"?: SupplierStatus[]; + "target-id": string[]; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "subscription-type": { + type: "string", + demandOption: true, + choices: ["MessageStatus", "ChannelStatus"] as const, + description: "Subscription type", + }, + "target-id": { + string: true, + type: "array", + demandOption: true, + description: "Target ID(s) to link this subscription to", + }, + "message-statuses": { + string: true, + type: "array", + demandOption: false, + choices: MESSAGE_STATUSES, + description: "Message statuses (required for MessageStatus type)", + }, + "channel-type": { + type: "string", + demandOption: false, + choices: CHANNEL_TYPES, + description: "Channel type (required for ChannelStatus type)", + }, + "channel-statuses": { + string: true, + type: "array", + demandOption: false, + choices: CHANNEL_STATUSES, + description: "Channel statuses (for ChannelStatus type)", + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: false, + choices: SUPPLIER_STATUSES, + description: "Supplier statuses (for ChannelStatus type)", + }, + "subscription-id": { + type: "string", + demandOption: false, + description: "Explicit subscription ID (defaults to a generated UUID v4)", + }, + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const subscriptionType = argv["subscription-type"]; + const subscriptionId = argv["subscription-id"] ?? crypto.randomUUID(); + const targetIds = argv["target-id"]; + + let subscription; + + if (subscriptionType === "MessageStatus") { + const messageStatuses = argv["message-statuses"]; + if (!messageStatuses?.length) { + console.error( + "Error: --message-statuses is required for MessageStatus subscriptions", + ); + process.exitCode = 1; + return; + } + subscription = buildMessageStatusSubscription({ + subscriptionId, + targetIds, + messageStatuses, + }); + } else { + const channelType = argv["channel-type"]; + if (!channelType) { + console.error( + "Error: --channel-type is required for ChannelStatus subscriptions", + ); + process.exitCode = 1; + return; + } + const channelStatuses = argv["channel-statuses"]; + const supplierStatuses = argv["supplier-statuses"]; + if (!channelStatuses?.length && !supplierStatuses?.length) { + console.error( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + process.exitCode = 1; + return; + } + subscription = buildChannelStatusSubscription({ + subscriptionId, + targetIds, + channelType, + channelStatuses, + supplierStatuses, + }); + } + + const repository = await createRepository(argv); + + const result = await repository.addSubscription( + argv["client-id"], + subscription, + argv["dry-run"], + ); + + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "subscriptions-add", + describe: "Add a subscription to a client", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts new file mode 100644 index 00000000..74c07da0 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -0,0 +1,54 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type SubscriptionsDelArgs = ClientCliArgs & + WriteCliArgs & { + "subscription-id": string; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "subscription-id": { + type: "string", + demandOption: true, + description: "Subscription ID to delete", + }, + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const repository = await createRepository(argv); + + const result = await repository.deleteSubscription( + argv["client-id"], + argv["subscription-id"], + argv["dry-run"], + ); + + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "subscriptions-del", + describe: "Delete a subscription from a client", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts new file mode 100644 index 00000000..be2a11b3 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -0,0 +1,45 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, +} from "src/entrypoint/cli/helper"; +import { formatSubscriptionsTable } from "src/format"; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const repository = await createRepository(argv); + + const config = await repository.getClientConfig(argv["client-id"]); + + if (!config) { + console.log(`No configuration exists for client: ${argv["client-id"]}`); + return; + } + + if (config.subscriptions.length === 0) { + console.log(`No subscriptions found for client: ${argv["client-id"]}`); + return; + } + + console.log(formatSubscriptionsTable(config.subscriptions)); +}; + +export const command: CliCommand = { + command: "subscriptions-list", + describe: "List a client's subscriptions", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts new file mode 100644 index 00000000..ee17a979 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -0,0 +1,104 @@ +import type { Argv } from "yargs"; +import { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type SubscriptionsSetStatesArgs = ClientCliArgs & + WriteCliArgs & { + "channel-statuses"?: ChannelStatus[]; + "message-statuses"?: MessageStatus[]; + "subscription-id": string; + "supplier-statuses"?: SupplierStatus[]; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "subscription-id": { + type: "string", + demandOption: true, + description: "Subscription ID to update", + }, + "message-statuses": { + string: true, + type: "array", + demandOption: false, + choices: MESSAGE_STATUSES, + description: "New message statuses (for MessageStatus subscriptions)", + }, + "channel-statuses": { + string: true, + type: "array", + demandOption: false, + choices: CHANNEL_STATUSES, + description: "New channel statuses (for ChannelStatus subscriptions)", + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: false, + choices: SUPPLIER_STATUSES, + description: "New supplier statuses (for ChannelStatus subscriptions)", + }, + }); + +export const handler: CliCommand["handler"] = + async (argv) => { + const messageStatuses = argv["message-statuses"]; + const channelStatuses = argv["channel-statuses"]; + const supplierStatuses = argv["supplier-statuses"]; + + if ( + !messageStatuses?.length && + !channelStatuses?.length && + !supplierStatuses?.length + ) { + console.error( + "Error: at least one of --message-statuses, --channel-statuses, or --supplier-statuses must be provided", + ); + process.exitCode = 1; + return; + } + + const repository = await createRepository(argv); + + const result = await repository.setSubscriptionStates( + argv["client-id"], + argv["subscription-id"], + { messageStatuses, channelStatuses, supplierStatuses }, + argv["dry-run"], + ); + + console.log(formatClientConfig(result)); + }; + +export const command: CliCommand = { + command: "subscriptions-set-states", + describe: "Update the states on an existing subscription", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts new file mode 100644 index 00000000..524d51d7 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -0,0 +1,81 @@ +import type { Argv } from "yargs"; +import { buildTarget } from "src/domain/client-subscription-builder"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type TargetsAddArgs = ClientCliArgs & + WriteCliArgs & { + "api-endpoint": string; + "api-key": string; + "api-key-header-name": string; + "rate-limit": number; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "api-endpoint": { + type: "string", + demandOption: true, + description: "Webhook endpoint URL (must start with https://)", + }, + "api-key": { + type: "string", + demandOption: true, + description: "API key value for authenticating webhook calls", + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + description: "HTTP header name for the API key", + }, + "rate-limit": { + type: "number", + demandOption: true, + description: "Maximum number of webhook calls per second", + }, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const apiEndpoint = argv["api-endpoint"]; + + const target = buildTarget({ + apiEndpoint, + apiKey: argv["api-key"], + apiKeyHeaderName: argv["api-key-header-name"], + rateLimit: argv["rate-limit"], + }); + + const repository = await createRepository(argv); + + const result = await repository.addTarget( + argv["client-id"], + target, + argv["dry-run"], + ); + console.log(`Target added with ID: ${target.targetId}`); + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "targets-add", + describe: "Add a callback target to a client", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts new file mode 100644 index 00000000..6fe56ac2 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -0,0 +1,52 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type TargetsDelArgs = ClientCliArgs & + WriteCliArgs & { + "target-id": string; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "target-id": { + type: "string", + demandOption: true, + description: "Target identifier to delete", + }, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const repository = await createRepository(argv); + + const result = await repository.deleteTarget( + argv["client-id"], + argv["target-id"], + argv["dry-run"], + ); + + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "targets-del", + describe: "Delete a callback target from a client", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts new file mode 100644 index 00000000..65941a98 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -0,0 +1,45 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, +} from "src/entrypoint/cli/helper"; +import { formatTargetsTable } from "src/format"; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); + +export const handler: CliCommand["handler"] = async (argv) => { + const repository = await createRepository(argv); + + const config = await repository.getClientConfig(argv["client-id"]); + + if (!config) { + console.log(`No configuration exists for client: ${argv["client-id"]}`); + return; + } + + if (config.targets.length === 0) { + console.log(`No targets found for client: ${argv["client-id"]}`); + return; + } + + console.log(formatTargetsTable(config.targets)); +}; + +export const command: CliCommand = { + command: "targets-list", + describe: "List a client's callback targets", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/format.ts b/tools/client-subscriptions-management/src/format.ts new file mode 100644 index 00000000..1c944c06 --- /dev/null +++ b/tools/client-subscriptions-management/src/format.ts @@ -0,0 +1,76 @@ +import { table } from "table"; +import type { + CallbackTarget, + ClientSubscriptionConfiguration, + SubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; + +const SUBSCRIPTION_TABLE_HEADER = [ + "Subscription ID", + "Type", + "Statuses", + "Target IDs", +]; + +const TARGET_TABLE_HEADER = [ + "Target ID", + "Endpoint", + "Method", + "Rate Limit", + "API Key Header", +]; + +const subscriptionStatuses = ( + subscription: SubscriptionConfiguration, +): string => { + if (subscription.subscriptionType === "MessageStatus") { + return subscription.messageStatuses.join(", "); + } + const statuses = [ + ...subscription.channelStatuses, + ...subscription.supplierStatuses, + ]; + return `${subscription.channelType}: ${statuses.join(", ")}`; +}; + +export const formatSubscriptionsTable = ( + subscriptions: SubscriptionConfiguration[], +): string => + table([ + SUBSCRIPTION_TABLE_HEADER, + ...subscriptions.map((sub) => [ + sub.subscriptionId, + sub.subscriptionType, + subscriptionStatuses(sub), + sub.targetIds.join(", "), + ]), + ]); + +export const formatTargetsTable = (targets: CallbackTarget[]): string => + table([ + TARGET_TABLE_HEADER, + ...targets.map((t) => [ + t.targetId, + t.invocationEndpoint, + t.invocationMethod, + String(t.invocationRateLimit), + t.apiKey.headerName, + ]), + ]); + +export const formatClientConfig = ( + config: ClientSubscriptionConfiguration, +): string => { + const subscriptionsTable = + config.subscriptions.length > 0 + ? `Subscriptions:\n${formatSubscriptionsTable(config.subscriptions)}` + : "Subscriptions: (none)"; + const targetsTable = + config.targets.length > 0 + ? `Targets:\n${formatTargetsTable(config.targets)}` + : "Targets: (none)"; + return `Client: ${config.clientId}\n\n${subscriptionsTable}\n${targetsTable}`; +}; + +export const normalizeClientName = (name: string): string => + name.replaceAll(/\s+/g, "-").toLowerCase(); diff --git a/tools/client-subscriptions-management/src/index.ts b/tools/client-subscriptions-management/src/index.ts deleted file mode 100644 index bec05b87..00000000 --- a/tools/client-subscriptions-management/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import-x/prefer-default-export -export { createClientSubscriptionRepository } from "src/container"; diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 48c56290..8cac33fe 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -1,165 +1,189 @@ -import { z } from "zod"; import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - type Channel, + type CallbackTarget, type ChannelStatus, type ClientSubscriptionConfiguration, - MESSAGE_STATUSES, type MessageStatus, - SUPPLIER_STATUSES, + type SubscriptionConfiguration, type SupplierStatus, } from "@nhs-notify-client-callbacks/models"; -import type { SubscriptionBuilder } from "src/domain/client-subscription-builder"; +import { validateClientConfig } from "src/domain/client-config-validator"; import { S3Repository } from "src/repository/s3"; -export type MessageStatusSubscriptionArgs = { - clientName: string; - clientId: string; - apiKey: string; - apiEndpoint: string; - statuses: MessageStatus[]; - rateLimit: number; - dryRun: boolean; - apiKeyHeaderName?: string; -}; +const CLIENT_SUBSCRIPTIONS_PREFIX = "client_subscriptions/"; -const messageStatusSubscriptionArgsSchema = z.object({ - clientName: z.string(), - clientId: z.string(), - apiKey: z.string(), - apiEndpoint: z.string(), - statuses: z.array(z.enum(MESSAGE_STATUSES)), - rateLimit: z.number(), - dryRun: z.boolean(), - apiKeyHeaderName: z.string().optional().default("x-api-key"), -}); - -export type ChannelStatusSubscriptionArgs = { - clientName: string; - clientId: string; - apiKey: string; - apiEndpoint: string; - channelStatuses?: ChannelStatus[]; - supplierStatuses?: SupplierStatus[]; - channelType: Channel; - rateLimit: number; - dryRun: boolean; - apiKeyHeaderName?: string; -}; +const parseStoredConfig = ( + clientId: string, + rawFile: string, +): ClientSubscriptionConfiguration => { + let parsedConfig: unknown; + + try { + parsedConfig = JSON.parse(rawFile) as unknown; + } catch (error) { + throw new Error( + `Failed to parse stored config for client ${clientId}: ${String(error)}`, + ); + } -const channelStatusSubscriptionArgsSchema = z.object({ - clientName: z.string(), - clientId: z.string(), - apiKey: z.string(), - apiEndpoint: z.string(), - channelStatuses: z.array(z.enum(CHANNEL_STATUSES)).min(1).optional(), - supplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)).min(1).optional(), - channelType: z.enum(CHANNEL_TYPES), - rateLimit: z.number(), - dryRun: z.boolean(), - apiKeyHeaderName: z.string().optional().default("x-api-key"), -}); + return validateClientConfig(parsedConfig); +}; export class ClientSubscriptionRepository { - constructor( - private readonly s3Repository: S3Repository, - private readonly configurationBuilder: SubscriptionBuilder, - ) {} + constructor(private readonly s3Repository: S3Repository) {} - async getClientSubscriptions( + async listClientIds(): Promise { + const keys = await this.s3Repository.listObjectKeys( + CLIENT_SUBSCRIPTIONS_PREFIX, + ); + return keys + .map((key) => + key.replace(CLIENT_SUBSCRIPTIONS_PREFIX, "").replace(/\.json$/, ""), + ) + .filter(Boolean); + } + + async getClientConfig( clientId: string, ): Promise { const rawFile = await this.s3Repository.getObject( - `client_subscriptions/${clientId}.json`, + `${CLIENT_SUBSCRIPTIONS_PREFIX}${clientId}.json`, ); if (rawFile !== undefined) { - return JSON.parse(rawFile) as unknown as ClientSubscriptionConfiguration; + return parseStoredConfig(clientId, rawFile); } return undefined; } - async putMessageStatusSubscription( - subscriptionArgs: MessageStatusSubscriptionArgs, - ) { - const parsedSubscriptionArgs = - messageStatusSubscriptionArgsSchema.parse(subscriptionArgs); - - 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, - ]; + async putClientConfig( + clientId: string, + config: ClientSubscriptionConfiguration, + dryRun: boolean, + ): Promise { + const validatedConfig = validateClientConfig(config); - if (!parsedSubscriptionArgs.dryRun) { + if (!dryRun) { await this.s3Repository.putRawData( - JSON.stringify(newConfigFile), - `client_subscriptions/${clientId}.json`, + JSON.stringify(validatedConfig), + `${CLIENT_SUBSCRIPTIONS_PREFIX}${clientId}.json`, ); } - - return newConfigFile; + return validatedConfig; } - async putChannelStatusSubscription( - subscriptionArgs: ChannelStatusSubscriptionArgs, + async addSubscription( + clientId: string, + subscription: SubscriptionConfiguration, + dryRun: boolean, ): Promise { - const parsedSubscriptionArgs = - channelStatusSubscriptionArgsSchema.parse(subscriptionArgs); + const config = (await this.getClientConfig(clientId)) ?? { + clientId, + subscriptions: [], + targets: [], + }; + config.subscriptions.push(subscription); + return this.putClientConfig(clientId, config, dryRun); + } - if ( - !parsedSubscriptionArgs.channelStatuses?.length && - !parsedSubscriptionArgs.supplierStatuses?.length - ) { - throw new Error( - "Validation failed: at least one of channelStatuses or supplierStatuses must be provided", - ); + async deleteSubscription( + clientId: string, + subscriptionId: string, + dryRun: boolean, + ): Promise { + const config = await this.getClientConfig(clientId); + if (!config) { + throw new Error(`No configuration found for client: ${clientId}`); } + const updated: ClientSubscriptionConfiguration = { + ...config, + subscriptions: config.subscriptions.filter( + (s) => s.subscriptionId !== subscriptionId, + ), + }; + return this.putClientConfig(clientId, updated, dryRun); + } - const { clientId } = parsedSubscriptionArgs; - const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; + async setSubscriptionStates( + clientId: string, + subscriptionId: string, + states: { + messageStatuses?: MessageStatus[]; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; + }, + dryRun: boolean, + ): Promise { + const config = await this.getClientConfig(clientId); + if (!config) { + throw new Error(`No configuration found for client: ${clientId}`); + } + const updated: ClientSubscriptionConfiguration = { + ...config, + subscriptions: config.subscriptions.map((sub) => { + if (sub.subscriptionId !== subscriptionId) return sub; + if ( + sub.subscriptionType === "MessageStatus" && + states.messageStatuses + ) { + return { ...sub, messageStatuses: states.messageStatuses }; + } + if (sub.subscriptionType === "ChannelStatus") { + return { + ...sub, + ...(states.channelStatuses && { + channelStatuses: states.channelStatuses, + }), + ...(states.supplierStatuses && { + supplierStatuses: states.supplierStatuses, + }), + }; + } + return sub; + }), + }; + return this.putClientConfig(clientId, updated, dryRun); + } - const indexOfChannelStatusSubscription = subscriptions.findIndex( - (subscription) => - subscription.SubscriptionType === "ChannelStatus" && - subscription.ChannelType === parsedSubscriptionArgs.channelType, - ); + async addTarget( + clientId: string, + target: CallbackTarget, + dryRun: boolean, + ): Promise { + const config = (await this.getClientConfig(clientId)) ?? { + clientId, + subscriptions: [], + targets: [], + }; + config.targets.push(target); + return this.putClientConfig(clientId, config, dryRun); + } - if (indexOfChannelStatusSubscription !== -1) { - subscriptions.splice(indexOfChannelStatusSubscription, 1); + async deleteTarget( + clientId: string, + targetId: string, + dryRun: boolean, + ): Promise { + const config = await this.getClientConfig(clientId); + if (!config) { + throw new Error(`No configuration found for client: ${clientId}`); } - const channelStatusConfig = this.configurationBuilder.channelStatus( - parsedSubscriptionArgs, - ); - - const newConfigFile: ClientSubscriptionConfiguration = [ - ...subscriptions, - channelStatusConfig, - ]; + const referencingSubscriptionIds = config.subscriptions + .filter((subscription) => subscription.targetIds.includes(targetId)) + .map((subscription) => subscription.subscriptionId); - if (!parsedSubscriptionArgs.dryRun) { - await this.s3Repository.putRawData( - JSON.stringify(newConfigFile), - `client_subscriptions/${clientId}.json`, + if (referencingSubscriptionIds.length > 0) { + throw new Error( + `Cannot delete target ${targetId}: still referenced by subscriptions ${referencingSubscriptionIds.join(", ")}`, ); } - return newConfigFile; + const updated: ClientSubscriptionConfiguration = { + ...config, + targets: config.targets.filter((t) => t.targetId !== targetId), + }; + return this.putClientConfig(clientId, updated, dryRun); } } + +export default ClientSubscriptionRepository; diff --git a/tools/client-subscriptions-management/src/repository/s3.ts b/tools/client-subscriptions-management/src/repository/s3.ts index a3062983..75ffde9c 100644 --- a/tools/client-subscriptions-management/src/repository/s3.ts +++ b/tools/client-subscriptions-management/src/repository/s3.ts @@ -1,5 +1,6 @@ import { GetObjectCommand, + ListObjectsV2Command, NoSuchKey, PutObjectCommand, PutObjectCommandInput, @@ -46,4 +47,27 @@ export class S3Repository { await this.s3Client.send(new PutObjectCommand(params)); } + + async listObjectKeys(prefix: string): Promise { + const keys: string[] = []; + let continuationToken: string | undefined; + + do { + const { Contents, NextContinuationToken } = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + for (const obj of Contents ?? []) { + if (obj.Key) { + keys.push(obj.Key); + } + } + continuationToken = NextContinuationToken; + } while (continuationToken); + + return keys; + } } diff --git a/tools/client-subscriptions-management/src/terraform.ts b/tools/client-subscriptions-management/src/terraform.ts new file mode 100644 index 00000000..64236ca0 --- /dev/null +++ b/tools/client-subscriptions-management/src/terraform.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; + +const runTerraformApply = async (opts: { + environment?: string; + group?: string; + tfRegion?: string; +}): Promise => { + const { environment, group, tfRegion } = opts; + if (!environment || !group) { + console.error( + "Error: --environment and --group are required when --terraform-apply is set", + ); + process.exitCode = 1; + return false; + } + + const makeArgs = [ + `component=callbacks`, + `environment=${environment}`, + `group=${group}`, + `project=nhs`, + ]; + if (tfRegion) { + makeArgs.push(`region=${tfRegion}`); + } + + console.log( + "[deploy-client-subscriptions] Running terraform plan for callbacks component...", + ); + // eslint-disable-next-line sonarjs/no-os-command-from-path + const planResult = spawnSync("make", ["terraform-plan", ...makeArgs], { + stdio: "inherit", + }); + if (planResult.status !== 0) { + console.error( + `Error: terraform plan failed with exit code ${planResult.status}`, + ); + process.exitCode = planResult.status ?? 1; + return false; + } + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await rl.question("\nApply these changes? [y/N] "); + rl.close(); + const confirmed = answer.toLowerCase() === "y"; + + if (!confirmed) { + console.log("Terraform apply cancelled."); + return false; + } + + console.log( + "[deploy-client-subscriptions] Running terraform apply for callbacks component...", + ); + // eslint-disable-next-line sonarjs/no-os-command-from-path + const applyResult = spawnSync("make", ["terraform-apply", ...makeArgs], { + stdio: "inherit", + }); + if (applyResult.status !== 0) { + console.error( + `Error: terraform apply failed with exit code ${applyResult.status}`, + ); + process.exitCode = applyResult.status ?? 1; + return false; + } + return true; +}; + +export default runTerraformApply; diff --git a/tsconfig.base.json b/tsconfig.base.json index fcbb3d06..8050167b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,4 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "isolatedModules": true + }, "extends": "@tsconfig/node22/tsconfig.json" }