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..a6db76fd 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", @@ -2099,6 +2097,382 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "dev": true, @@ -3477,13 +3851,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, @@ -3551,7 +3918,7 @@ }, "node_modules/@types/node": { "version": "24.0.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -4552,6 +4919,12 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/ci-info": { "version": "4.2.0", "dev": true, @@ -4597,6 +4970,15 @@ "node": ">=6" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-subscriptions-management": { "resolved": "tools/client-subscriptions-management", "link": true @@ -4758,12 +5140,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, @@ -8487,6 +8863,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/napi-postinstall": { "version": "0.2.4", "dev": true, @@ -9438,7 +9823,6 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -10480,7 +10864,7 @@ }, "node_modules/undici-types": { "version": "7.8.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -10930,6 +11314,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "license": "MIT", @@ -10988,6 +11384,7 @@ "@aws-sdk/client-s3": "^3.821.0", "@aws-sdk/client-sts": "^3.1004.0", "@aws-sdk/credential-providers": "^3.1004.0", + "@inquirer/prompts": "^7.10.1", "@nhs-notify-client-callbacks/models": "*", "table": "^6.9.0", "yargs": "^17.7.2", diff --git a/package.json b/package.json index bd24ebd6..26903c9f 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 clients-list --workspace tools/client-subscriptions-management --", + "clients:get": "npm run clients-get --workspace tools/client-subscriptions-management --", + "clients:put": "npm run clients-put --workspace tools/client-subscriptions-management --", + "subscriptions:list": "npm run subscriptions-list --workspace tools/client-subscriptions-management --", + "subscriptions:add": "npm run subscriptions-add --workspace tools/client-subscriptions-management --", + "subscriptions:del": "npm run subscriptions-del --workspace tools/client-subscriptions-management --", + "subscriptions:set-states": "npm run subscriptions-set-states --workspace tools/client-subscriptions-management --", + "targets:list": "npm run targets-list --workspace tools/client-subscriptions-management --", + "targets:add": "npm run targets-add --workspace tools/client-subscriptions-management --", + "targets:del": "npm run 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..6ba68a22 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -2,12 +2,18 @@ "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", + "start": "tsx ./src/entrypoint/interactive/index.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", @@ -17,6 +23,7 @@ "@aws-sdk/client-s3": "^3.821.0", "@aws-sdk/client-sts": "^3.1004.0", "@aws-sdk/credential-providers": "^3.1004.0", + "@inquirer/prompts": "^7.10.1", "@nhs-notify-client-callbacks/models": "*", "table": "^6.9.0", "yargs": "^17.7.2", 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..14a35f15 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts @@ -0,0 +1,88 @@ +const mockGetClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + getClientConfig: mockGetClientConfig, +}); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); + +import * as cli from "src/entrypoint/cli/clients-get"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const validConfig = createClientSubscriptionConfig(); + +describe("clients-get CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(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..75c09cc3 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts @@ -0,0 +1,61 @@ +const mockListClientIds = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + listClientIds: mockListClientIds, +}); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); + +import * as cli from "src/entrypoint/cli/clients-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; + +describe("clients-list CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockListClientIds.mockReset(); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + listClientIds: mockListClientIds, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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).not.toHaveBeenCalled(); + }); + + it("handles errors in wrapped CLI", async () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)(["node", "script", "--bucket-name", "bucket-1"]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(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..04deead4 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts @@ -0,0 +1,218 @@ +import { tmpdir } from "node:os"; +import path from "node:path"; +import { readFileSync } from "node:fs"; + +const mockPutClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + putClientConfig: mockPutClientConfig, +}); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/terraform", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("node:fs", () => ({ + ...jest.requireActual("node:fs"), + readFileSync: jest.fn(), +})); + +import * as cli from "src/entrypoint/cli/clients-put"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const validConfig = createClientSubscriptionConfig(); + +describe("clients-put CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockPutClientConfig.mockReset(); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + putClientConfig: mockPutClientConfig, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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("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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..898b2974 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,61 @@ -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(undefined); + mockResolveBucketName.mockResolvedValue("my-bucket"); + mockCreateRepositoryFromOptions.mockReturnValue(fakeRepo); + + const result = await createRepository({ + "bucket-name": "my-bucket", + region: "eu-west-2", + }); + + expect(mockResolveRegion).toHaveBeenCalledWith("eu-west-2"); + expect(mockResolveProfile).toHaveBeenCalledWith(undefined); + expect(mockResolveBucketName).toHaveBeenCalledWith( + "my-bucket", + undefined, + "eu-west-2", + undefined, ); + expect(mockCreateRepositoryFromOptions).toHaveBeenCalledWith({ + bucketName: "my-bucket", + region: "eu-west-2", + profile: undefined, + }); + 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..0d9a8ff3 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts @@ -0,0 +1,191 @@ +const mockAddSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + addSubscription: mockAddSubscription, +}); +const mockBuildMessageStatusSubscription = jest.fn(); +const mockBuildChannelStatusSubscription = jest.fn(); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildMessageStatusSubscription: mockBuildMessageStatusSubscription, + buildChannelStatusSubscription: mockBuildChannelStatusSubscription, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatClientConfig: mockFormatClientConfig, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-add"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const resultConfig = createClientSubscriptionConfig(); + +describe("subscriptions-add CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + 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.mockReturnValue("formatted-output"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + addSubscription: mockAddSubscription, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)(baseMessageArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..4656d964 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts @@ -0,0 +1,86 @@ +const mockDeleteSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + deleteSubscription: mockDeleteSubscription, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatClientConfig: mockFormatClientConfig, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-del"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const resultConfig = createClientSubscriptionConfig(); + +describe("subscriptions-del CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + 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"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + deleteSubscription: mockDeleteSubscription, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..77d87aea --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts @@ -0,0 +1,124 @@ +const mockGetClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + getClientConfig: mockGetClientConfig, +}); +const mockFormatSubscriptionsTable = jest.fn().mockReturnValue("table-output"); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatSubscriptionsTable: mockFormatSubscriptionsTable, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const validConfig = createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ + targetIds: ["target-001"], + }), + ], +}); + +describe("subscriptions-list CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockFormatSubscriptionsTable.mockReset(); + mockFormatSubscriptionsTable.mockReturnValue("table-output"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(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..fef855d9 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts @@ -0,0 +1,124 @@ +const mockSetSubscriptionStates = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + setSubscriptionStates: mockSetSubscriptionStates, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatClientConfig: mockFormatClientConfig, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-set-states"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const resultConfig = createClientSubscriptionConfig(); + +describe("subscriptions-set-states CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + 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"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + setSubscriptionStates: mockSetSubscriptionStates, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)([...baseArgs, "--message-statuses", "DELIVERED"]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..1ac76a12 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts @@ -0,0 +1,127 @@ +const mockAddTarget = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + addTarget: mockAddTarget, +}); +const mockBuildTarget = jest.fn(); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildTarget: mockBuildTarget, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatClientConfig: mockFormatClientConfig, +})); + +import * as cli from "src/entrypoint/cli/targets-add"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const builtTarget = createTarget(); + +const resultConfig = createClientSubscriptionConfig({ + targets: [builtTarget], +}); + +describe("targets-add CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + 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"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ addTarget: mockAddTarget }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + it("rejects non-https api-endpoint", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--rate-limit", + "10", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockAddTarget).not.toHaveBeenCalled(); + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..ce5d9a98 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts @@ -0,0 +1,84 @@ +const mockDeleteTarget = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + deleteTarget: mockDeleteTarget, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatClientConfig: mockFormatClientConfig, +})); + +import * as cli from "src/entrypoint/cli/targets-del"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; + +const resultConfig = createClientSubscriptionConfig(); + +describe("targets-del CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + 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"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ deleteTarget: mockDeleteTarget }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); 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..630f9c4f --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts @@ -0,0 +1,115 @@ +const mockGetClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockResolvedValue({ + getClientConfig: mockGetClientConfig, +}); +const mockFormatTargetsTable = jest.fn().mockReturnValue("targets-table"); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ + formatTargetsTable: mockFormatTargetsTable, +})); + +import * as cli from "src/entrypoint/cli/targets-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const target = createTarget(); + +describe("targets-list CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockFormatTargetsTable.mockReset(); + mockFormatTargetsTable.mockReturnValue("targets-table"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + }); + + 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 () => { + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await wrapCli(cli.main)([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts new file mode 100644 index 00000000..a41c60f3 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts @@ -0,0 +1,148 @@ +import { tmpdir } from "node:os"; +import path from "node:path"; + +const mockSelect = jest.fn(); +const mockInput = jest.fn(); +const mockConfirm = jest.fn(); +const mockPassword = jest.fn(); +const mockSeparator = jest + .fn() + .mockImplementation(() => ({ isSeparator: true })); + +jest.mock("@inquirer/prompts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + input: (...args: unknown[]) => mockInput(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), + password: (...args: unknown[]) => mockPassword(...args), + Separator: mockSeparator, +})); + +const mockClientsListMain = jest.fn().mockResolvedValue(undefined); +const mockClientsGetMain = jest.fn().mockResolvedValue(undefined); +const mockClientsPutMain = jest.fn().mockResolvedValue(undefined); +const mockRunTerraformApply = jest.fn().mockResolvedValue(true); +const mockListClientIds = jest.fn().mockResolvedValue([]); +const mockCreateRepo = jest.fn().mockReturnValue({ + listClientIds: mockListClientIds, +}); + +jest.mock("src/entrypoint/cli/clients-list", () => ({ + main: (...args: unknown[]) => mockClientsListMain(...args), +})); +jest.mock("src/entrypoint/cli/clients-get", () => ({ + main: (...args: unknown[]) => mockClientsGetMain(...args), +})); +jest.mock("src/entrypoint/cli/clients-put", () => ({ + main: (...args: unknown[]) => mockClientsPutMain(...args), +})); +jest.mock("src/terraform", () => ({ + __esModule: true, + default: (...args: unknown[]) => mockRunTerraformApply(...args), +})); +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), +})); +jest.mock("src/entrypoint/interactive/shared", () => ({ + buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), + promptClientId: jest.fn().mockResolvedValue("client-1"), + promptDryRun: jest.fn().mockResolvedValue(false), +})); + +import { + interactiveClientsGet, + interactiveClientsList, + interactiveClientsPut, +} from "src/entrypoint/interactive/clients"; +import { promptDryRun } from "src/entrypoint/interactive/shared"; + +const connection = { bucketName: "bucket" }; + +describe("interactiveClientsList", () => { + it("calls clientsList main with connection args", async () => { + await interactiveClientsList(connection); + expect(mockClientsListMain).toHaveBeenCalledWith( + expect.arrayContaining(["--bucket-name", "bucket"]), + ); + }); +}); + +describe("interactiveClientsGet", () => { + it("prompts for client and calls clientsGet main", async () => { + await interactiveClientsGet(connection); + expect(mockClientsGetMain).toHaveBeenCalledWith( + expect.arrayContaining(["--client-id", "client-1"]), + ); + }); +}); + +describe("interactiveClientsPut", () => { + beforeEach(() => { + mockInput.mockReset(); + mockConfirm.mockReset(); + mockClientsPutMain.mockReset(); + mockClientsPutMain.mockResolvedValue(undefined); + }); + + it("calls clientsPut main with file path when provided", async () => { + mockInput.mockResolvedValueOnce(path.join(tmpdir(), "config.json")); // file path + mockConfirm.mockResolvedValue(false); // no terraform + + await interactiveClientsPut(connection); + + expect(mockClientsPutMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--file", + path.join(tmpdir(), "config.json"), + "--client-id", + "client-1", + ]), + ); + }); + + it("calls clientsPut main with inline JSON when no file path", async () => { + mockInput + .mockResolvedValueOnce("") // no file path + .mockResolvedValueOnce( + '{"clientId":"client-1","subscriptions":[],"targets":[]}', + ); // inline json + mockConfirm.mockResolvedValue(false); // no terraform + + await interactiveClientsPut(connection); + + expect(mockClientsPutMain).toHaveBeenCalledWith( + expect.arrayContaining(["--json"]), + ); + }); + + it("calls runTerraformApply when user requests it", async () => { + mockInput + .mockResolvedValueOnce(path.join(tmpdir(), "config.json")) // file path + .mockResolvedValueOnce("mygroup") // group + .mockResolvedValueOnce(""); // no tf-region override + mockConfirm + .mockResolvedValueOnce(true) // run terraform + .mockResolvedValueOnce(true); // confirm apply + + const connWithEnv = { ...connection, environment: "dev" }; + await interactiveClientsPut(connWithEnv); + + expect(mockRunTerraformApply).toHaveBeenCalledWith( + expect.objectContaining({ + environment: "dev", + group: "mygroup", + }), + ); + }); + + it("adds --dry-run when dry run is selected", async () => { + jest.mocked(promptDryRun).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce(path.join(tmpdir(), "config.json")); + + await interactiveClientsPut(connection); + + expect(mockClientsPutMain).toHaveBeenCalledWith( + expect.arrayContaining(["--dry-run"]), + ); + expect(mockRunTerraformApply).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/index.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/index.test.ts new file mode 100644 index 00000000..fc14489a --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/index.test.ts @@ -0,0 +1,125 @@ +const mockSelect = jest.fn(); +const mockSeparator = jest + .fn() + .mockImplementation(() => ({ isSeparator: true })); + +jest.mock("@inquirer/prompts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + Separator: mockSeparator, +})); + +const mockPromptConnection = jest.fn().mockResolvedValue({ + bucketName: "bucket", + project: "nhs", +}); +const mockInteractiveClientsList = jest.fn().mockResolvedValue(undefined); +const mockInteractiveClientsGet = jest.fn().mockResolvedValue(undefined); +const mockInteractiveClientsPut = jest.fn().mockResolvedValue(undefined); +const mockInteractiveSubscriptionsList = jest.fn().mockResolvedValue(undefined); +const mockInteractiveSubscriptionsAdd = jest.fn().mockResolvedValue(undefined); +const mockInteractiveSubscriptionsDel = jest.fn().mockResolvedValue(undefined); +const mockInteractiveSubscriptionsSetStates = jest + .fn() + .mockResolvedValue(undefined); +const mockInteractiveTargetsList = jest.fn().mockResolvedValue(undefined); +const mockInteractiveTargetsAdd = jest.fn().mockResolvedValue(undefined); +const mockInteractiveTargetsDel = jest.fn().mockResolvedValue(undefined); + +jest.mock("src/entrypoint/interactive/shared", () => ({ + promptConnection: (...args: unknown[]) => mockPromptConnection(...args), +})); +jest.mock("src/entrypoint/interactive/clients", () => ({ + interactiveClientsList: (...args: unknown[]) => + mockInteractiveClientsList(...args), + interactiveClientsGet: (...args: unknown[]) => + mockInteractiveClientsGet(...args), + interactiveClientsPut: (...args: unknown[]) => + mockInteractiveClientsPut(...args), +})); +jest.mock("src/entrypoint/interactive/subscriptions", () => ({ + interactiveSubscriptionsList: (...args: unknown[]) => + mockInteractiveSubscriptionsList(...args), + interactiveSubscriptionsAdd: (...args: unknown[]) => + mockInteractiveSubscriptionsAdd(...args), + interactiveSubscriptionsDel: (...args: unknown[]) => + mockInteractiveSubscriptionsDel(...args), + interactiveSubscriptionsSetStates: (...args: unknown[]) => + mockInteractiveSubscriptionsSetStates(...args), +})); +jest.mock("src/entrypoint/interactive/targets", () => ({ + interactiveTargetsList: (...args: unknown[]) => + mockInteractiveTargetsList(...args), + interactiveTargetsAdd: (...args: unknown[]) => + mockInteractiveTargetsAdd(...args), + interactiveTargetsDel: (...args: unknown[]) => + mockInteractiveTargetsDel(...args), +})); + +import { runIfMain, runInteractive } from "src/entrypoint/interactive/index"; + +describe("runInteractive", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockInteractiveClientsList.mockReset(); + mockInteractiveClientsList.mockResolvedValue(undefined); + }); + + it("exits cleanly when user selects exit", async () => { + mockSelect.mockResolvedValue("exit"); + await expect(runInteractive()).resolves.toBeUndefined(); + }); + + it("dispatches to clients:list and then exits", async () => { + mockSelect + .mockResolvedValueOnce("clients:list") + .mockResolvedValueOnce("exit"); + + await runInteractive(); + + expect(mockInteractiveClientsList).toHaveBeenCalledTimes(1); + }); + + it("dispatches to subscriptions:add and then exits", async () => { + mockSelect + .mockResolvedValueOnce("subscriptions:add") + .mockResolvedValueOnce("exit"); + + await runInteractive(); + + expect(mockInteractiveSubscriptionsAdd).toHaveBeenCalledTimes(1); + }); + + it("handles errors from sub-commands gracefully without exiting the loop", async () => { + mockInteractiveClientsList.mockRejectedValueOnce(new Error("S3 error")); + mockSelect + .mockResolvedValueOnce("clients:list") + .mockResolvedValueOnce("exit"); + + await expect(runInteractive()).resolves.toBeUndefined(); + }); + + it("handles user force-close gracefully", async () => { + mockInteractiveClientsGet.mockRejectedValueOnce( + new Error("User force closed the prompt"), + ); + mockSelect + .mockResolvedValueOnce("clients:get") + .mockResolvedValueOnce("exit"); + + await expect(runInteractive()).resolves.toBeUndefined(); + }); +}); + +describe("runIfMain", () => { + it("calls runInteractive when isMain is true", async () => { + mockSelect.mockResolvedValue("exit"); + await runIfMain(true); + expect(mockPromptConnection).toHaveBeenCalled(); + }); + + it("does not call runInteractive when isMain is false", async () => { + mockPromptConnection.mockClear(); + await runIfMain(false); + expect(mockPromptConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts new file mode 100644 index 00000000..fff7ebb6 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts @@ -0,0 +1,168 @@ +const mockSelect = jest.fn(); +const mockInput = jest.fn(); +const mockConfirm = jest.fn(); +const mockSeparator = jest + .fn() + .mockImplementation(() => ({ isSeparator: true })); + +jest.mock("@inquirer/prompts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + input: (...args: unknown[]) => mockInput(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), + Separator: mockSeparator, +})); + +const mockResolveBucketName = jest + .fn() + .mockResolvedValue("nhs-123-eu-west-2-dev-callbacks-subscription-config"); + +jest.mock("src/aws", () => ({ + resolveBucketName: (...args: unknown[]) => mockResolveBucketName(...args), +})); + +import { + type ConnectionConfig, + buildConnectionArgs, + promptClientId, + promptConnection, + promptDryRun, +} from "src/entrypoint/interactive/shared"; + +describe("buildConnectionArgs", () => { + const base: ConnectionConfig = { + bucketName: "my-bucket", + }; + + it("returns bucket-name arg", () => { + expect(buildConnectionArgs(base)).toEqual(["--bucket-name", "my-bucket"]); + }); + + it("includes region and profile when present", () => { + const result = buildConnectionArgs({ + ...base, + region: "eu-west-1", + profile: "myprofile", + }); + expect(result).toContain("--region"); + expect(result).toContain("eu-west-1"); + expect(result).toContain("--profile"); + expect(result).toContain("myprofile"); + }); +}); + +describe("promptConnection", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockInput.mockReset(); + mockConfirm.mockReset(); + mockResolveBucketName.mockResolvedValue("resolved-bucket"); + }); + + it("returns connection config with direct bucket name", async () => { + mockSelect.mockResolvedValue("eu-west-2"); + mockInput + .mockResolvedValueOnce("") // profile + .mockResolvedValueOnce("my-direct-bucket"); // bucket name + + const result = await promptConnection(); + + expect(result.bucketName).toBe("my-direct-bucket"); + expect(result.region).toBe("eu-west-2"); + expect(mockResolveBucketName).not.toHaveBeenCalled(); + }); + + it("resolves bucket from environment when no direct bucket given", async () => { + mockSelect.mockResolvedValue("eu-west-2"); + mockInput + .mockResolvedValueOnce("") // profile + .mockResolvedValueOnce("") // bucket name (blank) + .mockResolvedValueOnce("dev"); // environment + + const result = await promptConnection(); + + expect(mockResolveBucketName).toHaveBeenCalledWith( + undefined, + "dev", + "eu-west-2", + undefined, + ); + expect(result.environment).toBe("dev"); + expect(result.bucketName).toBe("resolved-bucket"); + }); + + it("prompts for custom region when 'custom' selected", async () => { + mockSelect.mockResolvedValue("custom"); + mockInput + .mockResolvedValueOnce("ap-southeast-1") // custom region + .mockResolvedValueOnce("") // profile + .mockResolvedValueOnce("my-bucket"); // bucket + + const result = await promptConnection(); + + expect(result.region).toBe("ap-southeast-1"); + }); +}); + +describe("promptClientId", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockInput.mockReset(); + }); + + it("shows input when no repo is provided", async () => { + mockInput.mockResolvedValue("client-abc"); + const result = await promptClientId(); + expect(result).toBe("client-abc"); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it("shows select when repo has client IDs", async () => { + const repo = { + listClientIds: jest.fn().mockResolvedValue(["client-1", "client-2"]), + }; + mockSelect.mockResolvedValue("client-1"); + const result = await promptClientId(repo as never); + expect(result).toBe("client-1"); + expect(mockInput).not.toHaveBeenCalled(); + }); + + it("falls back to input when repo returns empty list", async () => { + const repo = { + listClientIds: jest.fn().mockResolvedValue([]), + }; + mockInput.mockResolvedValue("manual-client"); + const result = await promptClientId(repo as never); + expect(result).toBe("manual-client"); + }); + + it("shows input when user selects __manual__", async () => { + const repo = { + listClientIds: jest.fn().mockResolvedValue(["client-1"]), + }; + mockSelect.mockResolvedValue("__manual__"); + mockInput.mockResolvedValue("custom-id"); + const result = await promptClientId(repo as never); + expect(result).toBe("custom-id"); + }); + + it("falls back to input when repo throws", async () => { + const repo = { + listClientIds: jest.fn().mockRejectedValue(new Error("network error")), + }; + mockInput.mockResolvedValue("fallback-id"); + const result = await promptClientId(repo as never); + expect(result).toBe("fallback-id"); + }); +}); + +describe("promptDryRun", () => { + it("returns true when user confirms dry run", async () => { + mockConfirm.mockResolvedValue(true); + expect(await promptDryRun()).toBe(true); + }); + + it("returns false when user declines dry run", async () => { + mockConfirm.mockResolvedValue(false); + expect(await promptDryRun()).toBe(false); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts new file mode 100644 index 00000000..cc40092b --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts @@ -0,0 +1,231 @@ +const mockSelect = jest.fn(); +const mockInput = jest.fn(); +const mockConfirm = jest.fn(); +const mockCheckbox = jest.fn(); +const mockSeparator = jest + .fn() + .mockImplementation(() => ({ isSeparator: true })); + +jest.mock("@inquirer/prompts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + input: (...args: unknown[]) => mockInput(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), + checkbox: (...args: unknown[]) => mockCheckbox(...args), + Separator: mockSeparator, +})); + +const mockSubscriptionsListMain = jest.fn().mockResolvedValue(undefined); +const mockSubscriptionsAddMain = jest.fn().mockResolvedValue(undefined); +const mockSubscriptionsDelMain = jest.fn().mockResolvedValue(undefined); +const mockSubscriptionsSetStatesMain = jest.fn().mockResolvedValue(undefined); + +jest.mock("src/entrypoint/cli/subscriptions-list", () => ({ + main: (...args: unknown[]) => mockSubscriptionsListMain(...args), +})); +jest.mock("src/entrypoint/cli/subscriptions-add", () => ({ + main: (...args: unknown[]) => mockSubscriptionsAddMain(...args), +})); +jest.mock("src/entrypoint/cli/subscriptions-del", () => ({ + main: (...args: unknown[]) => mockSubscriptionsDelMain(...args), +})); +jest.mock("src/entrypoint/cli/subscriptions-set-states", () => ({ + main: (...args: unknown[]) => mockSubscriptionsSetStatesMain(...args), +})); + +const mockGetClientConfig = jest.fn(); +const mockListClientIds = jest.fn().mockResolvedValue([]); +const mockCreateRepo = jest.fn().mockReturnValue({ + listClientIds: mockListClientIds, + getClientConfig: mockGetClientConfig, +}); + +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), +})); +jest.mock("src/entrypoint/interactive/shared", () => ({ + buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), + promptClientId: jest.fn().mockResolvedValue("client-1"), + promptDryRun: jest.fn().mockResolvedValue(false), +})); + +import { + interactiveSubscriptionsAdd, + interactiveSubscriptionsDel, + interactiveSubscriptionsList, + interactiveSubscriptionsSetStates, +} from "src/entrypoint/interactive/subscriptions"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const connection = { bucketName: "bucket", project: "nhs" }; + +describe("interactiveSubscriptionsList", () => { + it("calls subscriptionsList main with client-id", async () => { + await interactiveSubscriptionsList(connection); + expect(mockSubscriptionsListMain).toHaveBeenCalledWith( + expect.arrayContaining(["--client-id", "client-1"]), + ); + }); +}); + +describe("interactiveSubscriptionsAdd", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockCheckbox.mockReset(); + mockInput.mockReset(); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + }); + + it("assembles MessageStatus subscription args and calls subscriptionsAdd main", async () => { + mockSelect.mockResolvedValueOnce("MessageStatus"); + mockCheckbox + .mockResolvedValueOnce(["DELIVERED"]) + .mockResolvedValueOnce(["target-001"]); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); + + await interactiveSubscriptionsAdd(connection); + + expect(mockSubscriptionsAddMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--subscription-type", + "MessageStatus", + "--message-statuses", + "DELIVERED", + "--target-id", + "target-001", + ]), + ); + }); + + it("assembles ChannelStatus subscription args", async () => { + mockSelect + .mockResolvedValueOnce("ChannelStatus") + .mockResolvedValueOnce("SMS"); + mockCheckbox + .mockResolvedValueOnce(["DELIVERED"]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(["target-001"]); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); + + await interactiveSubscriptionsAdd(connection); + + expect(mockSubscriptionsAddMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--subscription-type", + "ChannelStatus", + "--channel-type", + "SMS", + "--channel-statuses", + "DELIVERED", + ]), + ); + }); + + it("prompts for manual target input when client has no targets", async () => { + mockSelect.mockResolvedValueOnce("MessageStatus"); + mockCheckbox.mockResolvedValueOnce(["DELIVERED"]); + mockInput.mockResolvedValueOnce("target-x, target-y"); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + + await interactiveSubscriptionsAdd(connection); + + expect(mockSubscriptionsAddMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--target-id", + "target-x", + "--target-id", + "target-y", + ]), + ); + }); +}); + +describe("interactiveSubscriptionsDel", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockInput.mockReset(); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + }); + + it("calls subscriptionsDel main with selected subscription ID", async () => { + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ subscriptionId: "sub-001" }), + ], + }), + ); + mockSelect.mockResolvedValue("sub-001"); + + await interactiveSubscriptionsDel(connection); + + expect(mockSubscriptionsDelMain).toHaveBeenCalledWith( + expect.arrayContaining(["--subscription-id", "sub-001"]), + ); + }); + + it("falls back to manual input when no subscriptions exist", async () => { + mockInput.mockResolvedValue("sub-manual"); + + await interactiveSubscriptionsDel(connection); + + expect(mockSubscriptionsDelMain).toHaveBeenCalledWith( + expect.arrayContaining(["--subscription-id", "sub-manual"]), + ); + }); +}); + +describe("interactiveSubscriptionsSetStates", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockCheckbox.mockReset(); + mockInput.mockReset(); + }); + + it("calls subscriptionsSetStates main for MessageStatus subscription", async () => { + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ + subscriptionId: "sub-001", + messageStatuses: ["DELIVERED"], + }), + ], + }), + ); + mockSelect.mockResolvedValue("sub-001"); + mockCheckbox.mockResolvedValue(["FAILED"]); + + await interactiveSubscriptionsSetStates(connection); + + expect(mockSubscriptionsSetStatesMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--subscription-id", + "sub-001", + "--message-statuses", + "FAILED", + ]), + ); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts new file mode 100644 index 00000000..957d86af --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts @@ -0,0 +1,174 @@ +const mockSelect = jest.fn(); +const mockInput = jest.fn(); +const mockConfirm = jest.fn(); +const mockPassword = jest.fn(); +const mockSeparator = jest + .fn() + .mockImplementation(() => ({ isSeparator: true })); + +jest.mock("@inquirer/prompts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + input: (...args: unknown[]) => mockInput(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), + password: (...args: unknown[]) => mockPassword(...args), + Separator: mockSeparator, +})); + +const mockTargetsListMain = jest.fn().mockResolvedValue(undefined); +const mockTargetsAddMain = jest.fn().mockResolvedValue(undefined); +const mockTargetsDelMain = jest.fn().mockResolvedValue(undefined); + +jest.mock("src/entrypoint/cli/targets-list", () => ({ + main: (...args: unknown[]) => mockTargetsListMain(...args), +})); +jest.mock("src/entrypoint/cli/targets-add", () => ({ + main: (...args: unknown[]) => mockTargetsAddMain(...args), +})); +jest.mock("src/entrypoint/cli/targets-del", () => ({ + main: (...args: unknown[]) => mockTargetsDelMain(...args), +})); + +const mockGetClientConfig = jest.fn(); +const mockListClientIds = jest.fn().mockResolvedValue([]); +const mockCreateRepo = jest.fn().mockReturnValue({ + listClientIds: mockListClientIds, + getClientConfig: mockGetClientConfig, +}); + +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), +})); +jest.mock("src/entrypoint/interactive/shared", () => ({ + buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), + promptClientId: jest.fn().mockResolvedValue("client-1"), + promptDryRun: jest.fn().mockResolvedValue(false), +})); + +import { + interactiveTargetsAdd, + interactiveTargetsDel, + interactiveTargetsList, +} from "src/entrypoint/interactive/targets"; +import { promptDryRun } from "src/entrypoint/interactive/shared"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const connection = { bucketName: "bucket", project: "nhs" }; + +describe("interactiveTargetsList", () => { + it("calls targetsList main with client-id", async () => { + await interactiveTargetsList(connection); + expect(mockTargetsListMain).toHaveBeenCalledWith( + expect.arrayContaining(["--client-id", "client-1"]), + ); + }); +}); + +describe("interactiveTargetsAdd", () => { + beforeEach(() => { + mockInput.mockReset(); + mockPassword.mockReset(); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + }); + + it("assembles target args and calls targetsAdd main", async () => { + mockInput + .mockResolvedValueOnce("https://example.com/hook") + .mockResolvedValueOnce("x-api-key") + .mockResolvedValueOnce("10"); + mockPassword.mockResolvedValue("secret-key"); + + await interactiveTargetsAdd(connection); + + expect(mockTargetsAddMain).toHaveBeenCalledWith( + expect.arrayContaining([ + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com/hook", + "--api-key", + "secret-key", + "--api-key-header-name", + "x-api-key", + "--rate-limit", + "10", + ]), + ); + }); + + it("includes --dry-run when dry run is selected", async () => { + jest.mocked(promptDryRun).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("https://example.com/hook") + .mockResolvedValueOnce("x-api-key") + .mockResolvedValueOnce("5"); + mockPassword.mockResolvedValue("key"); + + await interactiveTargetsAdd(connection); + + expect(mockTargetsAddMain).toHaveBeenCalledWith( + expect.arrayContaining(["--dry-run"]), + ); + }); +}); + +describe("interactiveTargetsDel", () => { + beforeEach(() => { + mockSelect.mockReset(); + mockInput.mockReset(); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); + }); + + it("calls targetsDel main with selected target ID from list", async () => { + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); + mockSelect.mockResolvedValue("target-001"); + + await interactiveTargetsDel(connection); + + expect(mockTargetsDelMain).toHaveBeenCalledWith( + expect.arrayContaining(["--target-id", "target-001"]), + ); + }); + + it("falls back to manual input when client has no targets", async () => { + mockInput.mockResolvedValue("manual-target"); + + await interactiveTargetsDel(connection); + + expect(mockTargetsDelMain).toHaveBeenCalledWith( + expect.arrayContaining(["--target-id", "manual-target"]), + ); + }); + + it("prompts for manual input when user selects __manual__", async () => { + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); + mockSelect.mockResolvedValue("__manual__"); + mockInput.mockResolvedValue("custom-target-id"); + + await interactiveTargetsDel(connection); + + expect(mockTargetsDelMain).toHaveBeenCalledWith( + expect.arrayContaining(["--target-id", "custom-target-id"]), + ); + }); +}); 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..001feaa9 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,302 @@ -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 }); - - await expect( - repository.getClientSubscriptions("client-1"), - ).resolves.toBeUndefined(); - }); + 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 }); - 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.toEqual( + config, + ); }); - 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("returns undefined when config file is missing", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); + + await expect( + repository.getClientConfig("client-1"), + ).resolves.toBeUndefined(); }); - expect(result).toEqual([channelSubscription, newMessage]); - expect(putRawData).toHaveBeenCalledWith( - JSON.stringify([channelSubscription, newMessage]), - "client_subscriptions/client-1.json", - ); + 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/, + ); + }); }); - 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(); }); + }); - 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, - }); + 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, + ); - expect(putRawData).not.toHaveBeenCalled(); - }); + expect(result.subscriptions).toHaveLength(2); + expect(result.subscriptions[1]).toEqual(channelSubscription); + expect(putRawData).toHaveBeenCalledTimes(1); + }); - describe("validation", () => { - it("throws validation error for invalid message status", async () => { - const { repository } = createRepository(); + 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.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); + repository.addSubscription("client-1", messageSubscription, false), + ).rejects.toThrow(/Config validation failed/); + + expect(putRawData).not.toHaveBeenCalled(); }); + }); - it("throws validation error for missing required fields in message subscription", 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", - // @ts-expect-error Testing missing field - statuses: undefined, - 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 invalid channel type", 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: ["DELIVERED"], - supplierStatuses: ["delivered"], - channelType: "INVALID_CHANNEL" as never, - 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 status", 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: ["INVALID_STATUS" as never], - supplierStatuses: ["delivered"], - channelType: "SMS", - 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 supplier 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: ["DELIVERED"], - supplierStatuses: ["INVALID_STATUS" as never], - 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 when neither channelStatuses nor supplierStatuses are provided", async () => { - const { repository } = createRepository(); + 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 }); - await expect( - repository.putChannelStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - channelType: "SMS", - rateLimit: 10, - dryRun: false, - }), - ).rejects.toThrow( - /at least one of channelStatuses or supplierStatuses must be provided/, - ); + 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 message subscription", async () => { - const getObject = jest.fn().mockResolvedValue(undefined as never); - const messageStatus = jest.fn().mockReturnValue(messageSubscription); + 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, - messageStatus, - }); + const result = await repository.addTarget("client-1", baseTarget, false); - await repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["DELIVERED"], - rateLimit: 10, - dryRun: false, - }); + expect(result.clientId).toBe("client-1"); + expect(result.targets).toEqual([baseTarget]); + }); + }); - expect(configurationBuilder.messageStatus).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("applies default value for apiKeyHeaderName on channel subscription", async () => { - const getObject = jest.fn().mockResolvedValue(undefined as never); - const channelStatus = jest.fn().mockReturnValue(channelSubscription); + 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 }); - const { configurationBuilder, repository } = createRepository({ - getObject, - channelStatus, - }); + await expect( + repository.deleteTarget("client-1", TARGET_ID, false), + ).rejects.toThrow( + `Cannot delete target ${TARGET_ID}: still referenced by subscriptions sub-001, sub-002`, + ); - 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(putRawData).not.toHaveBeenCalled(); + }); - expect(configurationBuilder.channelStatus).toHaveBeenCalledWith( - expect.objectContaining({ - apiKeyHeaderName: "x-api-key", - }), - ); + 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__/terraform.test.ts b/tools/client-subscriptions-management/src/__tests__/terraform.test.ts new file mode 100644 index 00000000..6728391a --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/terraform.test.ts @@ -0,0 +1,118 @@ +import runTerraformApply from "src/terraform"; + +jest.mock("node:child_process", () => ({ + spawnSync: jest.fn().mockReturnValue({ status: 0 }), +})); + +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; + }; + + const originalExitCode = process.exitCode; + + beforeEach(() => { + spawnSync.mockReturnValue({ status: 0 }); + 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 confirmFn = jest.fn(); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + confirmFn, + }); + + expect(result).toBe(false); + expect(confirmFn).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 confirmFn = jest.fn().mockResolvedValue(false); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + confirmFn, + }); + + 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 () => { + const confirmFn = jest.fn().mockResolvedValue(true); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + confirmFn, + }); + + 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 () => { + const confirmFn = jest.fn().mockResolvedValue(true); + + await runTerraformApply({ + environment: "dev", + group: "mygroup", + tfRegion: "eu-west-1", + confirmFn, + }); + + 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 + const confirmFn = jest.fn().mockResolvedValue(true); + + const result = await runTerraformApply({ + environment: "dev", + group: "mygroup", + confirmFn, + }); + + expect(result).toBe(false); + expect(process.exitCode).toBe(2); + }); +}); diff --git a/tools/client-subscriptions-management/src/aws.ts b/tools/client-subscriptions-management/src/aws.ts new file mode 100644 index 00000000..b83f88ae --- /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 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 = region ?? "eu-west-2"; + const accountId = await resolveAccountId(profile, resolvedRegion); + return deriveBucketName(accountId, environment, resolvedRegion); +}; + +export const resolveRegion = ( + regionArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; + +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..de17a0b2 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -0,0 +1,33 @@ +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(); + 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..17587ddd --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -0,0 +1,127 @@ +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; + } + + // 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..54a2a847 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); + 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..006ff732 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -0,0 +1,86 @@ +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"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + 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/entrypoint/interactive/clients.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts new file mode 100644 index 00000000..a1520a41 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts @@ -0,0 +1,112 @@ +import { confirm, input } from "@inquirer/prompts"; +import { createRepository } from "src/aws"; +import runTerraformApply from "src/terraform"; +import { main as clientsGetMain } from "src/entrypoint/cli/clients-get"; +import { main as clientsListMain } from "src/entrypoint/cli/clients-list"; +import { main as clientsPutMain } from "src/entrypoint/cli/clients-put"; +import { + type ConnectionConfig, + buildConnectionArgs, + promptClientId, + promptDryRun, +} from "src/entrypoint/interactive/shared"; + +export async function interactiveClientsList( + connection: ConnectionConfig, +): Promise { + await clientsListMain(["node", "script", ...buildConnectionArgs(connection)]); +} + +export async function interactiveClientsGet( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + await clientsGetMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + ]); +} + +export async function interactiveClientsPut( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const inputMethod = await input({ + message: "Path to JSON config file (or blank to paste inline JSON):", + default: "", + }); + + const args: string[] = [ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + ]; + + if (inputMethod.trim()) { + args.push("--file", inputMethod.trim()); + } else { + const json = await input({ + message: "Paste JSON config (single line):", + validate: (v) => { + try { + JSON.parse(v); + return true; + } catch { + return "Must be valid JSON"; + } + }, + }); + args.push("--json", json.trim()); + } + + const dryRun = await promptDryRun(); + if (dryRun) args.push("--dry-run"); + + await clientsPutMain(args); + + if (!dryRun) { + const runTerraform = await confirm({ + message: "Run terraform apply to sync infrastructure?", + default: false, + }); + if (runTerraform) { + let tfEnvironment = connection.environment; + if (!tfEnvironment) { + tfEnvironment = await input({ + message: "Environment name (for terraform):", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + const group = await input({ + message: "Group name:", + validate: (v) => v.trim().length > 0 || "Required", + }); + const tfRegionRaw = await input({ + message: "Terraform region override (blank for default):", + default: "", + }); + await runTerraformApply({ + environment: tfEnvironment, + group, + tfRegion: tfRegionRaw.trim() || undefined, + confirmFn: () => confirm({ message: "Confirm terraform apply?" }), + }); + } + } +} diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/index.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/index.ts new file mode 100644 index 00000000..6ac420ad --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/index.ts @@ -0,0 +1,116 @@ +import { Separator, select } from "@inquirer/prompts"; +import { + interactiveClientsGet, + interactiveClientsList, + interactiveClientsPut, +} from "src/entrypoint/interactive/clients"; +import { + type ConnectionConfig, + promptConnection, +} from "src/entrypoint/interactive/shared"; +import { + interactiveSubscriptionsAdd, + interactiveSubscriptionsDel, + interactiveSubscriptionsList, + interactiveSubscriptionsSetStates, +} from "src/entrypoint/interactive/subscriptions"; +import { + interactiveTargetsAdd, + interactiveTargetsDel, + interactiveTargetsList, +} from "src/entrypoint/interactive/targets"; + +type Command = + | "clients:list" + | "clients:get" + | "clients:put" + | "subscriptions:list" + | "subscriptions:add" + | "subscriptions:del" + | "subscriptions:set-states" + | "targets:list" + | "targets:add" + | "targets:del" + | "exit"; + +const COMMAND_DISPATCH: Record< + Exclude, + (conn: ConnectionConfig) => Promise +> = { + "clients:list": interactiveClientsList, + "clients:get": interactiveClientsGet, + "clients:put": interactiveClientsPut, + "subscriptions:list": interactiveSubscriptionsList, + "subscriptions:add": interactiveSubscriptionsAdd, + "subscriptions:del": interactiveSubscriptionsDel, + "subscriptions:set-states": interactiveSubscriptionsSetStates, + "targets:list": interactiveTargetsList, + "targets:add": interactiveTargetsAdd, + "targets:del": interactiveTargetsDel, +}; + +export async function runInteractive(): Promise { + console.log("\n─────────────────────────────────────────────────────────"); + console.log(" Client Subscriptions Management – Interactive Mode"); + console.log("─────────────────────────────────────────────────────────\n"); + + const connection = await promptConnection(); + + for (;;) { + console.log(""); + const command = await select({ + message: "What would you like to do?", + choices: [ + new Separator("── Clients ──"), + { value: "clients:list", name: "List all clients" }, + { value: "clients:get", name: "Get client config (full JSON)" }, + { value: "clients:put", name: "Put client config (replace)" }, + new Separator("── Subscriptions ──"), + { value: "subscriptions:list", name: "List subscriptions" }, + { value: "subscriptions:add", name: "Add subscription" }, + { value: "subscriptions:del", name: "Delete subscription" }, + { + value: "subscriptions:set-states", + name: "Update subscription states", + }, + new Separator("── Targets ──"), + { value: "targets:list", name: "List targets" }, + { value: "targets:add", name: "Add target" }, + { value: "targets:del", name: "Delete target" }, + new Separator(), + { value: "exit", name: "Exit" }, + ], + }); + + if (command === "exit") break; + + try { + // eslint-disable-next-line security/detect-object-injection + await COMMAND_DISPATCH[command](connection); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("User force closed") + ) { + console.log("\nOperation cancelled."); + } else { + console.error("\nError:", error); + } + } + } + + console.log("\nGoodbye!"); +} + +export const runIfMain = async ( + isMain: boolean = require.main === module, +): Promise => { + if (isMain) { + await runInteractive().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + } +}; + +runIfMain(); diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts new file mode 100644 index 00000000..1dd2235d --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts @@ -0,0 +1,110 @@ +import { Separator, confirm, input, select } from "@inquirer/prompts"; +import type { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { resolveBucketName } from "src/aws"; + +const AWS_REGIONS = [ + "eu-west-2", + "eu-west-1", + "eu-central-1", + "us-east-1", + "us-west-2", +] as const; + +export interface ConnectionConfig { + bucketName: string; + environment?: string; + region?: string; + profile?: string; +} + +export type Repository = ClientSubscriptionRepository; + +export const buildConnectionArgs = (connection: ConnectionConfig): string[] => { + const args: string[] = ["--bucket-name", connection.bucketName]; + if (connection.region) args.push("--region", connection.region); + if (connection.profile) args.push("--profile", connection.profile); + return args; +}; + +export async function promptConnection(): Promise { + const regionChoice = await select({ + message: "AWS region:", + choices: [ + ...AWS_REGIONS.map((r) => ({ value: r as string })), + new Separator(), + { value: "custom", name: "Other (enter manually)" }, + ], + default: "eu-west-2", + }); + + const region = + regionChoice === "custom" + ? await input({ + message: "AWS region:", + validate: (v) => v.trim().length > 0 || "Region is required", + }) + : regionChoice; + + const profileRaw = await input({ + message: `AWS profile${ + process.env.AWS_PROFILE ? ` (current: ${process.env.AWS_PROFILE})` : "" + } (blank for default):`, + default: "", + }); + const profile = profileRaw.trim() || undefined; + + const bucketNameRaw = await input({ + message: "S3 bucket name (blank to derive from environment name):", + default: "", + }); + + let bucketName: string; + let environment: string | undefined; + + if (bucketNameRaw.trim()) { + bucketName = bucketNameRaw.trim(); + } else { + environment = await input({ + message: "Environment name:", + validate: (v) => v.trim().length > 0 || "Environment name is required", + }); + environment = environment.trim(); + bucketName = await resolveBucketName( + undefined, + environment, + region, + profile, + ); + console.log(`\nResolved bucket: ${bucketName}`); + } + + return { bucketName, environment, region, profile }; +} + +export async function promptClientId(repo?: Repository): Promise { + if (repo) { + const clientIds = await repo.listClientIds().catch(() => [] as string[]); + if (clientIds.length > 0) { + const choice = await select({ + message: "Select client:", + choices: [ + ...clientIds.map((id) => ({ value: id })), + new Separator(), + { value: "__manual__", name: "Enter client ID manually" }, + ], + }); + if (choice !== "__manual__") return choice; + } + } + return input({ + message: "Client ID:", + validate: (v) => v.trim().length > 0 || "Client ID is required", + }); +} + +export async function promptDryRun(): Promise { + return confirm({ + message: "Dry run? (validate without writing to S3)", + default: false, + }); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts new file mode 100644 index 00000000..6ec264e9 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts @@ -0,0 +1,307 @@ +import { Separator, checkbox, input, select } from "@inquirer/prompts"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import type { SubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { createRepository } from "src/aws"; +import { main as subscriptionsAddMain } from "src/entrypoint/cli/subscriptions-add"; +import { main as subscriptionsDelMain } from "src/entrypoint/cli/subscriptions-del"; +import { main as subscriptionsListMain } from "src/entrypoint/cli/subscriptions-list"; +import { main as subscriptionsSetStatesMain } from "src/entrypoint/cli/subscriptions-set-states"; +import { + type ConnectionConfig, + buildConnectionArgs, + promptClientId, + promptDryRun, +} from "src/entrypoint/interactive/shared"; + +export async function interactiveSubscriptionsList( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + await subscriptionsListMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + ]); +} + +export async function interactiveSubscriptionsAdd( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const config = await repo.getClientConfig(clientId).catch(() => undefined); + + const subscriptionType = await select<"MessageStatus" | "ChannelStatus">({ + message: "Subscription type:", + choices: [ + { value: "MessageStatus", name: "MessageStatus" }, + { value: "ChannelStatus", name: "ChannelStatus" }, + ], + }); + + const args: string[] = [ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + "--subscription-type", + subscriptionType, + ]; + + if (subscriptionType === "MessageStatus") { + const statuses = await checkbox({ + message: "Message statuses:", + choices: MESSAGE_STATUSES.map((s) => ({ value: s })), + validate: (v) => v.length > 0 || "Select at least one status", + }); + for (const s of statuses) args.push("--message-statuses", s); + } else { + const channelType = await select({ + message: "Channel type:", + choices: CHANNEL_TYPES.map((t) => ({ value: t })), + }); + args.push("--channel-type", channelType); + + const channelStatuses = await checkbox({ + message: "Channel statuses (optional):", + choices: CHANNEL_STATUSES.map((s) => ({ value: s })), + }); + for (const s of channelStatuses) args.push("--channel-statuses", s); + + const supplierStatuses = await checkbox({ + message: "Supplier statuses (optional):", + choices: SUPPLIER_STATUSES.map((s) => ({ value: s })), + }); + for (const s of supplierStatuses) args.push("--supplier-statuses", s); + } + + // Target IDs: use existing targets from config if available + const existingTargets = config?.targets ?? []; + const targetIds: string[] = []; + if (existingTargets.length > 0) { + const selected = await checkbox({ + message: "Select target IDs:", + choices: existingTargets.map((t) => ({ + value: t.targetId, + name: `${t.targetId} (${t.invocationEndpoint})`, + })), + validate: (v) => v.length > 0 || "Select at least one target", + }); + targetIds.push(...selected); + } else { + const raw = await input({ + message: "Target ID(s) (comma-separated):", + validate: (v) => + v.trim().length > 0 || "At least one target ID is required", + }); + targetIds.push( + ...raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + for (const t of targetIds) args.push("--target-id", t); + + const dryRun = await promptDryRun(); + if (dryRun) args.push("--dry-run"); + + await subscriptionsAddMain(args); +} + +export async function interactiveSubscriptionsDel( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const config = await repo.getClientConfig(clientId).catch(() => undefined); + const subscriptions = config?.subscriptions ?? []; + + let subscriptionId: string; + if (subscriptions.length > 0) { + subscriptionId = await select({ + message: "Select subscription to delete:", + choices: [ + ...subscriptions.map((s) => ({ + value: s.subscriptionId, + name: `${s.subscriptionId} (${s.subscriptionType})`, + })), + new Separator(), + { value: "__manual__", name: "Enter subscription ID manually" }, + ], + }); + if (subscriptionId === "__manual__") { + subscriptionId = await input({ + message: "Subscription ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + } else { + subscriptionId = await input({ + message: "Subscription ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + + const dryRun = await promptDryRun(); + + await subscriptionsDelMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + "--subscription-id", + subscriptionId, + ...(dryRun ? ["--dry-run"] : []), + ]); +} + +async function pushMessageStatusArgs( + args: string[], + current: string[], +): Promise { + const statuses = await checkbox({ + message: "New message statuses:", + choices: MESSAGE_STATUSES.map((s) => ({ + value: s, + checked: current.includes(s), + })), + }); + for (const s of statuses) args.push("--message-statuses", s); +} + +async function pushChannelStatusArgs( + args: string[], + current: Extract< + SubscriptionConfiguration, + { subscriptionType: "ChannelStatus" } + >, +): Promise { + const channelStatuses = await checkbox({ + message: "New channel statuses:", + choices: CHANNEL_STATUSES.map((s) => ({ + value: s, + checked: current.channelStatuses.includes(s), + })), + }); + for (const s of channelStatuses) args.push("--channel-statuses", s); + + const supplierStatuses = await checkbox({ + message: "New supplier statuses:", + choices: SUPPLIER_STATUSES.map((s) => ({ + value: s, + checked: current.supplierStatuses.includes(s), + })), + }); + for (const s of supplierStatuses) args.push("--supplier-statuses", s); +} + +async function pushUnknownTypeStatusArgs(args: string[]): Promise { + const messageStatuses = await checkbox({ + message: "Message statuses (optional):", + choices: MESSAGE_STATUSES.map((s) => ({ value: s })), + }); + for (const s of messageStatuses) args.push("--message-statuses", s); + + const channelStatuses = await checkbox({ + message: "Channel statuses (optional):", + choices: CHANNEL_STATUSES.map((s) => ({ value: s })), + }); + for (const s of channelStatuses) args.push("--channel-statuses", s); + + const supplierStatuses = await checkbox({ + message: "Supplier statuses (optional):", + choices: SUPPLIER_STATUSES.map((s) => ({ value: s })), + }); + for (const s of supplierStatuses) args.push("--supplier-statuses", s); +} + +export async function interactiveSubscriptionsSetStates( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const config = await repo.getClientConfig(clientId).catch(() => undefined); + const subscriptions = config?.subscriptions ?? []; + + let subscriptionId: string; + if (subscriptions.length > 0) { + subscriptionId = await select({ + message: "Select subscription to update:", + choices: [ + ...subscriptions.map((s) => ({ + value: s.subscriptionId, + name: `${s.subscriptionId} (${s.subscriptionType})`, + })), + new Separator(), + { value: "__manual__", name: "Enter subscription ID manually" }, + ], + }); + if (subscriptionId === "__manual__") { + subscriptionId = await input({ + message: "Subscription ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + } else { + subscriptionId = await input({ + message: "Subscription ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + + const selectedSub = subscriptions.find( + (s) => s.subscriptionId === subscriptionId, + ); + const args: string[] = [ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + "--subscription-id", + subscriptionId, + ]; + + if (selectedSub?.subscriptionType === "MessageStatus") { + await pushMessageStatusArgs(args, selectedSub.messageStatuses); + } else if (selectedSub?.subscriptionType === "ChannelStatus") { + await pushChannelStatusArgs(args, selectedSub); + } else { + await pushUnknownTypeStatusArgs(args); + } + + const dryRun = await promptDryRun(); + if (dryRun) args.push("--dry-run"); + + await subscriptionsSetStatesMain(args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts new file mode 100644 index 00000000..ce2d097c --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts @@ -0,0 +1,133 @@ +import { Separator, input, password, select } from "@inquirer/prompts"; +import { createRepository } from "src/aws"; +import { main as targetsAddMain } from "src/entrypoint/cli/targets-add"; +import { main as targetsDelMain } from "src/entrypoint/cli/targets-del"; +import { main as targetsListMain } from "src/entrypoint/cli/targets-list"; +import { + type ConnectionConfig, + buildConnectionArgs, + promptClientId, + promptDryRun, +} from "src/entrypoint/interactive/shared"; + +export async function interactiveTargetsList( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + await targetsListMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + ]); +} + +export async function interactiveTargetsAdd( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const apiEndpoint = await input({ + message: "Webhook endpoint URL (https://):", + validate: (v) => + /^https:\/\//.test(v) || "Endpoint must start with https://", + }); + + const apiKey = await password({ message: "API key:" }); + + const apiKeyHeaderName = await input({ + message: "API key header name:", + default: "x-api-key", + }); + + const rateLimitRaw = await input({ + message: "Rate limit (max webhook calls per second):", + validate: (v) => { + const n = Number(v); + return (Number.isFinite(n) && n > 0) || "Must be a positive number"; + }, + }); + + const dryRun = await promptDryRun(); + + await targetsAddMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + "--api-endpoint", + apiEndpoint, + "--api-key", + apiKey, + "--api-key-header-name", + apiKeyHeaderName, + "--rate-limit", + rateLimitRaw, + ...(dryRun ? ["--dry-run"] : []), + ]); +} + +export async function interactiveTargetsDel( + connection: ConnectionConfig, +): Promise { + const repo = createRepository({ + bucketName: connection.bucketName, + region: connection.region, + profile: connection.profile, + }); + const clientId = await promptClientId(repo); + + const config = await repo.getClientConfig(clientId).catch(() => undefined); + const targets = config?.targets ?? []; + + let targetId: string; + if (targets.length > 0) { + targetId = await select({ + message: "Select target to delete:", + choices: [ + ...targets.map((t) => ({ + value: t.targetId, + name: `${t.targetId} (${t.invocationEndpoint})`, + })), + new Separator(), + { value: "__manual__", name: "Enter target ID manually" }, + ], + }); + if (targetId === "__manual__") { + targetId = await input({ + message: "Target ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + } else { + targetId = await input({ + message: "Target ID:", + validate: (v) => v.trim().length > 0 || "Required", + }); + } + + const dryRun = await promptDryRun(); + + await targetsDelMain([ + "node", + "script", + ...buildConnectionArgs(connection), + "--client-id", + clientId, + "--target-id", + targetId, + ...(dryRun ? ["--dry-run"] : []), + ]); +} 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..3aa60f05 --- /dev/null +++ b/tools/client-subscriptions-management/src/terraform.ts @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; + +const runTerraformApply = async (opts: { + environment?: string; + group?: string; + tfRegion?: string; + confirmFn?: () => Promise; +}): Promise => { + const { confirmFn, 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; + } + + let confirmed: boolean; + if (confirmFn) { + confirmed = await confirmFn(); + } else { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await rl.question("\nApply these changes? [y/N] "); + rl.close(); + 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" }