From ad260927d1113d87c2419ea5f39dff72bd1c91c5 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 16 Mar 2026 08:23:40 +0000 Subject: [PATCH 01/29] CCM-15259 - Improve client subscription management script --- .../src/__tests__/index.component.test.ts | 42 +- .../src/__tests__/index.test.ts | 97 ++-- .../__tests__/services/config-cache.test.ts | 76 ++- .../__tests__/services/config-loader.test.ts | 44 +- .../services/config-update.component.test.ts | 86 ++- .../filters/channel-status-filter.test.ts | 46 +- .../filters/message-status-filter.test.ts | 42 +- .../services/subscription-filter.test.ts | 88 +-- .../validators/config-validator.test.ts | 93 ++-- .../src/handler.ts | 6 +- .../src/services/config-loader.ts | 2 +- .../services/filters/channel-status-filter.ts | 28 +- .../services/filters/message-status-filter.ts | 21 +- .../services/validators/config-validator.ts | 94 ++-- package-lock.json | 418 +++++++++++++- package.json | 13 +- src/models/src/client-config.ts | 68 +-- src/models/src/index.ts | 2 + .../package.json | 16 +- .../src/__tests__/container.test.ts | 9 - .../client-subscription-builder.test.ts | 95 ++-- ...scriptions.test.ts => clients-get.test.ts} | 80 +-- .../entrypoint/cli/clients-list.test.ts | 87 +++ .../entrypoint/cli/clients-put.test.ts | 218 ++++++++ .../__tests__/entrypoint/cli/helper.test.ts | 240 ++++++-- .../entrypoint/cli/put-channel-status.test.ts | 378 ------------- .../entrypoint/cli/put-message-status.test.ts | 313 ----------- .../entrypoint/cli/subscriptions-add.test.ts | 213 +++++++ .../entrypoint/cli/subscriptions-del.test.ts | 108 ++++ .../entrypoint/cli/subscriptions-list.test.ts | 155 ++++++ .../cli/subscriptions-set-states.test.ts | 149 +++++ .../entrypoint/cli/targets-add.test.ts | 157 ++++++ .../entrypoint/cli/targets-del.test.ts | 108 ++++ .../entrypoint/cli/targets-list.test.ts | 154 ++++++ .../entrypoint/cli/validate-config.test.ts | 106 ++++ .../entrypoint/interactive/clients.test.ts | 149 +++++ .../entrypoint/interactive/index.test.ts | 125 +++++ .../entrypoint/interactive/shared.test.ts | 182 ++++++ .../interactive/subscriptions.test.ts | 227 ++++++++ .../entrypoint/interactive/targets.test.ts | 163 ++++++ .../repository/client-subscriptions.test.ts | 520 ++++++++---------- .../src/container.ts | 6 +- .../src/domain/client-subscription-builder.ts | 129 ++--- ...client-subscriptions.ts => clients-get.ts} | 22 +- .../src/entrypoint/cli/clients-list.ts | 74 +++ .../src/entrypoint/cli/clients-put.ts | 163 ++++++ .../src/entrypoint/cli/deploy.ts | 305 ---------- .../src/entrypoint/cli/helper.ts | 157 +++++- .../src/entrypoint/cli/subscriptions-add.ts | 198 +++++++ .../src/entrypoint/cli/subscriptions-del.ts | 101 ++++ .../src/entrypoint/cli/subscriptions-list.ts | 89 +++ ...-status.ts => subscriptions-set-states.ts} | 104 ++-- .../{put-message-status.ts => targets-add.ts} | 67 +-- .../src/entrypoint/cli/targets-del.ts | 102 ++++ .../src/entrypoint/cli/targets-list.ts | 89 +++ .../src/entrypoint/cli/validate-config.ts | 120 ++++ .../src/entrypoint/interactive/clients.ts | 113 ++++ .../src/entrypoint/interactive/index.ts | 116 ++++ .../src/entrypoint/interactive/shared.ts | 122 ++++ .../entrypoint/interactive/subscriptions.ts | 307 +++++++++++ .../src/entrypoint/interactive/targets.ts | 133 +++++ .../src/repository/client-subscriptions.ts | 254 ++++----- .../src/repository/s3.ts | 24 + 63 files changed, 5779 insertions(+), 2234 deletions(-) rename tools/client-subscriptions-management/src/__tests__/entrypoint/cli/{get-client-subscriptions.test.ts => clients-get.test.ts} (54%) create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts delete mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts delete mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/index.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts rename tools/client-subscriptions-management/src/entrypoint/cli/{get-client-subscriptions.ts => clients-get.ts} (81%) create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts delete mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts rename tools/client-subscriptions-management/src/entrypoint/cli/{put-channel-status.ts => subscriptions-set-states.ts} (54%) rename tools/client-subscriptions-management/src/entrypoint/cli/{put-message-status.ts => targets-add.ts} (71%) create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/interactive/index.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts 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..13f48fac 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 @@ -73,27 +73,27 @@ 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) => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED", "FAILED"], + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); 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..32cd391f 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -18,58 +18,61 @@ 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" }, + targetId: "00000000-0000-4000-8000-000000000001", + type: "API" as const, + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST" as const, + 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", - ], - }, - { - 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", + loadClientConfig: jest + .fn() + .mockImplementation(async (clientId: string) => ({ + clientId, + subscriptions: [ + { + subscriptionType: "MessageStatus", + subscriptionId: "00000000-0000-0000-0000-000000000001", + targetIds: ["00000000-0000-4000-8000-000000000001"], + messageStatuses: [ + "DELIVERED", + "FAILED", + "PENDING", + "SENDING", + "TECHNICAL_FAILURE", + "PERMANENT_FAILURE", + ], + }, + { + subscriptionType: "ChannelStatus", + subscriptionId: "00000000-0000-0000-0000-000000000002", + targetIds: ["00000000-0000-4000-8000-000000000001"], + channelType: "NHSAPP", + channelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + supplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + { + subscriptionType: "ChannelStatus", + subscriptionId: "00000000-0000-0000-0000-000000000003", + targetIds: ["00000000-0000-4000-8000-000000000001"], + channelType: "SMS", + channelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + supplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, ], - }, - ]), + targets: [stubTarget], + })), }) as unknown as ConfigLoader; const makeStubConfigLoaderService = (): ConfigLoaderService => { 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..07f91667 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 @@ -4,20 +4,18 @@ import { ConfigCache } from "services/config-cache"; 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"], - }, - ]; - - cache.set("client-1", config); - const result = cache.get("client-1"); - - expect(result).toEqual(config); + const config: ClientSubscriptionConfiguration = { + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus" as const, + targetIds: [], + messageStatuses: ["DELIVERED"], + }, + ], + targets: [], + }; }); it("returns undefined for non-existent key", () => { @@ -32,20 +30,18 @@ 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"], - }, - ]; - - cache.set("client-1", config); - - // Advance time past expiry - jest.advanceTimersByTime(1500); + const config: ClientSubscriptionConfiguration = { + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus" as const, + targetIds: [], + messageStatuses: ["DELIVERED"], + }, + ], + targets: [], + }; const result = cache.get("client-1"); @@ -56,18 +52,18 @@ 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"], - }, - ]; - - cache.set("client-1", config); - cache.set("client-2", config); + const config: ClientSubscriptionConfiguration = { + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus" as const, + targetIds: [], + messageStatuses: ["DELIVERED"], + }, + ], + targets: [], + }; cache.clear(); 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..40faf1fb 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 @@ -16,27 +16,27 @@ 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) => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); const createLoader = (send: jest.Mock) => new ConfigLoader({ @@ -89,7 +89,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..b73b16ae 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 @@ -2,6 +2,28 @@ import { S3Client } from "@aws-sdk/client-s3"; import { ConfigCache } from "services/config-cache"; import { ConfigLoader } from "services/config-loader"; +const makeConfig = (messageStatuses: string[]) => ({ + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses, + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); + describe("config update component", () => { it("reloads configuration after cache expiry", async () => { jest.useFakeTimers(); @@ -11,56 +33,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 +54,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..db183efe 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 @@ -38,29 +38,29 @@ 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, - }, -]; +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "ChannelStatus", + channelType: "EMAIL", + channelStatuses, + supplierStatuses, + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); const createChannelStatusData = ( overrides: Partial = {}, 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..f89a3c5b 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 @@ -36,27 +36,27 @@ const createBaseEvent = ( 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, - }, -]; +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: statuses, + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); const createMessageStatusData = ( overrides: Partial = {}, 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..45924904 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 @@ -86,56 +86,56 @@ 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, - }, -]; +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: statuses, + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); 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, - }, -]; +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000002", + subscriptionType: "ChannelStatus", + channelType, + channelStatuses, + supplierStatuses, + targetIds: ["00000000-0000-4000-8000-000000000001"], + }, + ], + targets: [ + { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); describe("evaluateSubscriptionFilters", () => { describe("when config is undefined", () => { 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..677df851 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 @@ -4,48 +4,37 @@ import { 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", - }, - }, - ], - 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"], - }, -]; +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +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("validateClientConfig", () => { it("returns the config when valid", () => { @@ -54,27 +43,35 @@ describe("validateClientConfig", () => { expect(validateClientConfig(config)).toEqual(config); }); - it("throws when config is not an array", () => { - expect(() => validateClientConfig({})).toThrow(ConfigValidationError); + it("throws when config is not an object", () => { + expect(() => validateClientConfig([])).toThrow(ConfigValidationError); }); it("throws when invocation endpoint is not https", () => { const config = createValidConfig(); - config[0].Targets[0].InvocationEndpoint = "http://example.com"; + config.targets[0].invocationEndpoint = "http://example.com"; expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); }); it("throws when subscription IDs are not unique", () => { const config = createValidConfig(); - config[1].SubscriptionId = config[0].SubscriptionId; + config.subscriptions[1].subscriptionId = + config.subscriptions[0].subscriptionId; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when invocationEndpoint is not a valid URL", () => { + const config = createValidConfig(); + config.targets[0].invocationEndpoint = "not-a-url"; expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); }); - it("throws when InvocationEndpoint is not a valid URL", () => { + it("throws when a subscription references an unknown targetId", () => { const config = createValidConfig(); - config[0].Targets[0].InvocationEndpoint = "not-a-url"; + config.subscriptions[0].targetIds = ["unknown-target-id"]; expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 8f38f101..3247a333 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( @@ -220,9 +220,7 @@ async function filterBatch( if (filterResult.matched) { filtered.push(event); - const targetIds = config?.flatMap((s) => - s.Targets.map((t) => t.TargetId), - ); + const targetIds = config?.targets?.map((t) => t.targetId); observability.recordFilteringMatched({ clientId, eventType: event.type, 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..5d3f576e 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(isChannelStatusSubscription) + .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..0875b394 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(isMessageStatusSubscription) + .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/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index cf476d5b..61e7fd68 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 @@ -29,55 +29,85 @@ const httpsUrlSchema = z.string().refine( ); 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(), + 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), - ClientId: z.string(), - Targets: z.array(targetSchema).min(1), + 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)), + 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)), + 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", [ +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); +const configSchema = 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 [sIdx, subscription] of config.subscriptions.entries()) { + for (const [tIdx, targetId] of subscription.targetIds.entries()) { + if (!validTargetIds.has(targetId)) { + ctx.addIssue({ + code: "custom", + message: `targetId "${targetId}" not found in targets`, + path: ["subscriptions", sIdx, "targetIds", tIdx], + }); + } + } + } + }); export const validateClientConfig = ( rawConfig: unknown, diff --git a/package-lock.json b/package-lock.json index 6fc02487..c93eb325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2099,6 +2099,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, @@ -3551,7 +3927,7 @@ }, "node_modules/@types/node": { "version": "24.0.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -4552,6 +4928,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 +4979,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 @@ -8487,6 +8878,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 +9838,6 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -10480,7 +10879,7 @@ }, "node_modules/undici-types": { "version": "7.8.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -10930,6 +11329,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 +11399,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..56cb0eef 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,16 @@ "test:unit": "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/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..dab7c233 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -12,9 +12,11 @@ export type { MessageStatusAttributes, } from "./client-callback-payload"; export type { + CallbackTarget, ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, + SubscriptionConfiguration, } from "./client-config"; export type { MessageStatusData } from "./message-status-data"; export type { RoutingPlan } from "./routing-plan"; diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index ef4857d3..9ae01ce5 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -4,10 +4,17 @@ "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/clients-list.ts", + "clients-get": "tsx ./src/entrypoint/cli/clients-get.ts", + "clients-put": "tsx ./src/entrypoint/cli/clients-put.ts", + "subscriptions-list": "tsx ./src/entrypoint/cli/subscriptions-list.ts", + "subscriptions-add": "tsx ./src/entrypoint/cli/subscriptions-add.ts", + "subscriptions-del": "tsx ./src/entrypoint/cli/subscriptions-del.ts", + "subscriptions-set-states": "tsx ./src/entrypoint/cli/subscriptions-set-states.ts", + "targets-list": "tsx ./src/entrypoint/cli/targets-list.ts", + "targets-add": "tsx ./src/entrypoint/cli/targets-add.ts", + "targets-del": "tsx ./src/entrypoint/cli/targets-del.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", @@ -17,6 +24,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__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts index f264e1e9..9865522e 100644 --- a/tools/client-subscriptions-management/src/__tests__/container.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -1,20 +1,12 @@ 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, })); @@ -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-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/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts similarity index 54% rename from tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts rename to tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts index a0993abe..5365c032 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts @@ -1,9 +1,8 @@ -const mockGetClientSubscriptions = jest.fn(); +const mockGetClientConfig = jest.fn(); const mockCreateRepository = jest.fn().mockReturnValue({ - getClientSubscriptions: mockGetClientSubscriptions, + getClientConfig: mockGetClientConfig, }); -const mockFormatSubscriptionFileResponse = jest.fn(); -const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveBucketName = jest.fn().mockResolvedValue("bucket"); const mockResolveProfile = jest.fn().mockReturnValue(undefined); const mockResolveRegion = jest.fn().mockReturnValue("region"); @@ -12,25 +11,28 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ - formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, })); -import * as cli from "src/entrypoint/cli/get-client-subscriptions"; +import * as cli from "src/entrypoint/cli/clients-get"; -describe("get-client-subscriptions CLI", () => { +const validConfig = { + clientId: "client-1", + subscriptions: [], + targets: [], +}; + +describe("clients-get CLI", () => { const originalLog = console.log; const originalError = console.error; const originalExitCode = process.exitCode; - const originalArgv = process.argv; beforeEach(() => { - mockGetClientSubscriptions.mockReset(); - mockFormatSubscriptionFileResponse.mockReset(); + mockGetClientConfig.mockReset(); mockResolveBucketName.mockReset(); - mockResolveBucketName.mockReturnValue("bucket"); + mockResolveBucketName.mockResolvedValue("bucket"); mockResolveRegion.mockReset(); mockResolveRegion.mockReturnValue("region"); console.log = jest.fn(); @@ -42,14 +44,10 @@ describe("get-client-subscriptions CLI", () => { 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"]); + it("prints JSON config when it exists", async () => { + mockGetClientConfig.mockResolvedValue(validConfig); await cli.main([ "node", @@ -60,13 +58,13 @@ describe("get-client-subscriptions CLI", () => { "bucket-1", ]); - expect(mockCreateRepository).toHaveBeenCalled(); - expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1"); - expect(console.log).toHaveBeenCalledWith(["formatted"]); + expect(console.log).toHaveBeenCalledWith( + JSON.stringify(validConfig, null, 2), + ); }); - it("prints message when no configuration exists", async () => { - mockGetClientSubscriptions.mockResolvedValue(undefined); + it("prints message when no config exists", async () => { + mockGetClientConfig.mockResolvedValue(undefined); await cli.main([ "node", @@ -83,9 +81,7 @@ describe("get-client-subscriptions CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockImplementation(() => { - throw new Error("Boom"); - }); + mockResolveBucketName.mockRejectedValue(new Error("Boom")); await cli.runCli([ "node", @@ -101,7 +97,7 @@ describe("get-client-subscriptions CLI", () => { }); it("executes when run as main module", async () => { - mockGetClientSubscriptions.mockResolvedValue(undefined); + mockGetClientConfig.mockResolvedValue(undefined); const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); await cli.runIfMain( @@ -138,36 +134,4 @@ describe("get-client-subscriptions CLI", () => { 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/clients-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts new file mode 100644 index 00000000..8a6e3afb --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-list.test.ts @@ -0,0 +1,87 @@ +const mockListClientIds = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + listClientIds: mockListClientIds, +}); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/clients-list"; + +describe("clients-list CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockListClientIds.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli(["node", "script", "--bucket-name", "bucket-1"]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + mockListClientIds.mockResolvedValue([]); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain(["node", "script", "--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", "--bucket-name", "bucket-1"], false); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..b556cf6f --- /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().mockReturnValue({ + putClientConfig: mockPutClientConfig, +}); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, + runTerraformApply: jest.fn(), +})); + +jest.mock("node:fs", () => ({ + readFileSync: jest.fn(), +})); + +import * as cli from "src/entrypoint/cli/clients-put"; + +const validConfig = { + clientId: "client-1", + subscriptions: [], + targets: [], +}; + +describe("clients-put CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + + beforeEach(() => { + mockPutClientConfig.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 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 without writing", async () => { + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--json", + JSON.stringify(validConfig), + "--dry-run", + "true", + ]); + + expect(mockPutClientConfig).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Dry run: config is valid"); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli([ + "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); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + ["node", "script", "--client-id", "c", "--bucket-name", "b"], + 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", "c", "--bucket-name", "b"], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..77eb7d2b 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,17 +1,25 @@ import type { + CallbackTarget, ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import { deriveBucketName, - formatSubscriptionFileResponse, + formatClientConfig, + formatSubscriptionsTable, + formatTargetsTable, normalizeClientName, resolveBucketName, resolveProfile, resolveRegion, + runTerraformApply, } from "src/entrypoint/cli/helper"; +jest.mock("node:child_process", () => ({ + spawnSync: jest.fn().mockReturnValue({ status: 0 }), +})); + jest.mock("@aws-sdk/client-sts", () => ({ STSClient: jest.fn().mockImplementation(() => ({ send: jest.fn().mockResolvedValue({ Account: "123456789012" }), @@ -19,71 +27,89 @@ jest.mock("@aws-sdk/client-sts", () => ({ GetCallerIdentityCommand: jest.fn(), })); +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + describe("cli helper", () => { + const target: CallbackTarget = { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }; + 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", - }, - }, - ], + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [TARGET_ID], }; 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", - }, - }, - ], + subscriptionId: "sub-002", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + targetIds: [TARGET_ID], }; - it("formats subscription output as a table string", () => { - const config: ClientSubscriptionConfiguration = [ - messageSubscription, - channelSubscription, - ]; + const config: ClientSubscriptionConfiguration = { + clientId: "client-a", + subscriptions: [messageSubscription, channelSubscription], + targets: [target], + }; - const result = formatSubscriptionFileResponse(config); + it("formats subscriptions as a table string", () => { + const result = formatSubscriptionsTable(config.subscriptions); expect(typeof result).toBe("string"); - // message status row - expect(result).toContain("client-a"); + expect(result).toContain("sub-001"); 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("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: ClientSubscriptionConfiguration = { + clientId: "empty-client", + subscriptions: [], + targets: [target], + }; + + expect(formatClientConfig(empty)).toContain("Subscriptions: (none)"); + }); + + it("shows (none) when targets is empty", () => { + const empty: ClientSubscriptionConfiguration = { + clientId: "empty-client", + subscriptions: [messageSubscription], + targets: [], + }; + + expect(formatClientConfig(empty)).toContain("Targets: (none)"); + }); + it("normalizes client name", () => { expect(normalizeClientName("My Client Name")).toBe("my-client-name"); }); @@ -164,3 +190,117 @@ describe("cli helper", () => { expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); }); }); + +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", + project: "nhs", + 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/__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..7a15ab85 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-add.test.ts @@ -0,0 +1,213 @@ +const mockAddSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + addSubscription: mockAddSubscription, +}); +const mockBuildMessageStatusSubscription = jest.fn(); +const mockBuildChannelStatusSubscription = jest.fn(); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("bucket"); +const mockResolveProfile = jest.fn().mockReturnValue(undefined); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildMessageStatusSubscription: mockBuildMessageStatusSubscription, + buildChannelStatusSubscription: mockBuildChannelStatusSubscription, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatClientConfig: mockFormatClientConfig, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-add"; + +const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli(baseMessageArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain(baseMessageArgs, 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(baseMessageArgs, false); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..f4892628 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts @@ -0,0 +1,108 @@ +const mockDeleteSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + deleteSubscription: mockDeleteSubscription, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + formatClientConfig: mockFormatClientConfig, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-del"; + +const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain(baseArgs, 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(baseArgs, false); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..26d77d7b --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts @@ -0,0 +1,155 @@ +const mockGetClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + getClientConfig: mockGetClientConfig, +}); +const mockFormatSubscriptionsTable = jest.fn().mockReturnValue("table-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + formatSubscriptionsTable: mockFormatSubscriptionsTable, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-list"; + +const validConfig = { + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: ["target-001"], + }, + ], + targets: [], +}; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(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 () => { + mockGetClientConfig.mockResolvedValue(undefined); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + ["node", "script", "--client-id", "client-1", "--bucket-name", "b"], + 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", "b"], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..ada2cfa2 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-set-states.test.ts @@ -0,0 +1,149 @@ +const mockSetSubscriptionStates = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + setSubscriptionStates: mockSetSubscriptionStates, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + formatClientConfig: mockFormatClientConfig, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/subscriptions-set-states"; + +const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli([...baseArgs, "--message-statuses", "DELIVERED"]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain([...baseArgs, "--message-statuses", "DELIVERED"], 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( + [...baseArgs, "--message-statuses", "DELIVERED"], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..063bb9c7 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts @@ -0,0 +1,157 @@ +const mockAddTarget = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + addTarget: mockAddTarget, +}); +const mockBuildTarget = jest.fn(); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("bucket"); +const mockResolveProfile = jest.fn().mockReturnValue(undefined); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + buildTarget: mockBuildTarget, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatClientConfig: mockFormatClientConfig, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/targets-add"; + +const builtTarget = { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, +}; + +const resultConfig = { + clientId: "client-1", + subscriptions: [], + 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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain(baseArgs, 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(baseArgs, false); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..50d5a592 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts @@ -0,0 +1,108 @@ +const mockDeleteTarget = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + deleteTarget: mockDeleteTarget, +}); +const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + formatClientConfig: mockFormatClientConfig, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/targets-del"; + +const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(new Error("Boom")); + + await cli.runCli(baseArgs); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain(baseArgs, 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(baseArgs, false); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); 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..93975130 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts @@ -0,0 +1,154 @@ +const mockGetClientConfig = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + getClientConfig: mockGetClientConfig, +}); +const mockFormatTargetsTable = jest.fn().mockReturnValue("targets-table"); +const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ + formatTargetsTable: mockFormatTargetsTable, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/targets-list"; + +const target = { + targetId: "00000000-0000-4000-8000-000000000001", + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, +}; + +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"); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockResolvedValue("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; + }); + + it("prints targets table when config has targets", async () => { + mockGetClientConfig.mockResolvedValue({ + clientId: "client-1", + subscriptions: [], + 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({ + clientId: "client-1", + subscriptions: [], + targets: [], + }); + + 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 runCli", async () => { + mockResolveBucketName.mockRejectedValue(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 () => { + mockGetClientConfig.mockResolvedValue(undefined); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + ["node", "script", "--client-id", "client-1", "--bucket-name", "b"], + 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", "b"], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts new file mode 100644 index 00000000..c3216833 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts @@ -0,0 +1,106 @@ +import { validateClientConfig } from "src/entrypoint/cli/validate-config"; + +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +const createValidConfig = () => ({ + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [TARGET_ID], + }, + ], + targets: [ + { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); + +describe("validateClientConfig", () => { + it("returns the config when valid", () => { + const config = createValidConfig(); + const result = validateClientConfig(config); + expect(result).toEqual(config); + }); + + it("throws when input is not an object", () => { + expect(() => validateClientConfig([])).toThrow(/Config validation failed/); + }); + + it("throws when clientId is missing", () => { + const config = { ...createValidConfig(), clientId: "" }; + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed/, + ); + }); + + it("throws when invocation endpoint is not https", () => { + const config = createValidConfig(); + config.targets[0].invocationEndpoint = "http://example.com"; + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed/, + ); + }); + + it("throws when subscriptionIds are not unique", () => { + const config = createValidConfig(); + config.subscriptions.push({ + ...config.subscriptions[0], + }); + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed/, + ); + }); + + it("throws when a subscription references an unknown targetId", () => { + const config = createValidConfig(); + config.subscriptions[0].targetIds = ["unknown-target-id"]; + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed/, + ); + }); + + it("throws when targetIds are not unique", () => { + const config = createValidConfig(); + config.targets.push({ ...config.targets[0] }); + expect(() => validateClientConfig(config)).toThrow( + /Config validation failed/, + ); + }); + + it("validates a ChannelStatus subscription correctly", () => { + const config = { + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "sub-001", + subscriptionType: "ChannelStatus", + channelType: "EMAIL", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["read"], + targetIds: [TARGET_ID], + }, + ], + targets: [ + { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], + }; + + expect(validateClientConfig(config)).toEqual(config); + }); +}); 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..fcf8d16c --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts @@ -0,0 +1,149 @@ +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/entrypoint/cli/helper", () => ({ + runTerraformApply: (...args: unknown[]) => mockRunTerraformApply(...args), +})); +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: (...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", project: "nhs" }; + +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", + project: "nhs", + }), + ); + }); + + 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..60b090f6 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts @@ -0,0 +1,182 @@ +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/entrypoint/cli/helper", () => ({ + resolveBucketName: (...args: unknown[]) => mockResolveBucketName(...args), +})); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: jest.fn(), +})); + +import { + type ConnectionConfig, + buildConnectionArgs, + promptClientId, + promptConnection, + promptDryRun, +} from "src/entrypoint/interactive/shared"; + +describe("buildConnectionArgs", () => { + const base: ConnectionConfig = { + bucketName: "my-bucket", + project: "nhs", + }; + + it("returns bucket-name and project args", () => { + expect(buildConnectionArgs(base)).toEqual([ + "--bucket-name", + "my-bucket", + "--project", + "nhs", + ]); + }); + + 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("nhs") // project + .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("nhs") // project + .mockResolvedValueOnce("") // bucket name (blank) + .mockResolvedValueOnce("dev"); // environment + + const result = await promptConnection(); + + expect(mockResolveBucketName).toHaveBeenCalledWith( + undefined, + "dev", + "eu-west-2", + undefined, + "nhs", + ); + 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("nhs") // project + .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..b292700b --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts @@ -0,0 +1,227 @@ +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/container", () => ({ + createClientSubscriptionRepository: (...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"; + +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({ subscriptions: [], targets: [] }); + }); + + it("assembles MessageStatus subscription args and calls subscriptionsAdd main", async () => { + mockSelect.mockResolvedValueOnce("MessageStatus"); // subscription type + mockCheckbox + .mockResolvedValueOnce(["DELIVERED"]) // message statuses + .mockResolvedValueOnce(["target-001"]); // target IDs (checkbox from targets) + mockGetClientConfig.mockResolvedValue({ + subscriptions: [], + targets: [ + { + 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") // subscription type + .mockResolvedValueOnce("SMS"); // channel type + mockCheckbox + .mockResolvedValueOnce(["DELIVERED"]) // channel statuses + .mockResolvedValueOnce([]) // supplier statuses + .mockResolvedValueOnce(["target-001"]); // target IDs + mockGetClientConfig.mockResolvedValue({ + subscriptions: [], + targets: [ + { + 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({ + subscriptions: [], + targets: [], + }); + + 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({ subscriptions: [], targets: [] }); + }); + + it("calls subscriptionsDel main with selected subscription ID", async () => { + mockGetClientConfig.mockResolvedValue({ + subscriptions: [ + { subscriptionId: "sub-001", subscriptionType: "MessageStatus" }, + ], + targets: [], + }); + 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({ + subscriptions: [ + { + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + }, + ], + targets: [], + }); + 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..023b3a00 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts @@ -0,0 +1,163 @@ +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/container", () => ({ + createClientSubscriptionRepository: (...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"; + +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({ subscriptions: [], targets: [] }); + }); + + it("assembles target args and calls targetsAdd main", async () => { + mockInput + .mockResolvedValueOnce("https://example.com/hook") // endpoint + .mockResolvedValueOnce("x-api-key") // header name + .mockResolvedValueOnce("10"); // rate limit + 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({ subscriptions: [], targets: [] }); + }); + + it("calls targetsDel main with selected target ID from list", async () => { + mockGetClientConfig.mockResolvedValue({ + subscriptions: [], + targets: [ + { 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({ + subscriptions: [], + targets: [ + { 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__/repository/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts index 93fa6f5a..5ef01968 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,288 @@ -import { z } from "zod"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import type { + CallbackTarget, 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; - }>, -) => { + +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +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: CallbackTarget = { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, +}; - const channelSubscription: ChannelStatusSubscriptionConfiguration = { - SubscriptionId: "client-1-SMS", - SubscriptionType: "ChannelStatus", - ClientId: "client-1", - ChannelType: "SMS", - ChannelStatuses: ["DELIVERED"], - SupplierStatuses: ["delivered"], - Targets: [baseTarget], - }; +const messageSubscription: MessageStatusSubscriptionConfiguration = { + subscriptionId: "sub-001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [TARGET_ID], +}; - it("returns parsed subscriptions when file exists", async () => { - const storedConfig: ClientSubscriptionConfiguration = [messageSubscription]; - const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); - const { repository } = createRepository({ getObject }); +const channelSubscription: ChannelStatusSubscriptionConfiguration = { + subscriptionId: "sub-002", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + targetIds: [TARGET_ID], +}; - const result = await repository.getClientSubscriptions("client-1"); +const baseConfig = ( + clientId = "client-1", +): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [messageSubscription, channelSubscription], + targets: [baseTarget], +}); - expect(result).toEqual(storedConfig); - }); +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", + ]); + }); - it("returns undefined when config file is missing", async () => { - const getObject = jest.fn().mockResolvedValue(undefined); - const { repository } = createRepository({ getObject }); + it("returns empty array when no objects found", async () => { + const listObjectKeys = jest.fn().mockResolvedValue([]); + const { repository } = createRepository({ listObjectKeys }); - await expect( - repository.getClientSubscriptions("client-1"), - ).resolves.toBeUndefined(); + await expect(repository.listClientIds()).resolves.toEqual([]); + }); }); - 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, - }); + 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 }); - const result = await repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["FAILED"], - rateLimit: 10, - dryRun: false, + await expect(repository.getClientConfig("client-1")).resolves.toEqual( + config, + ); }); - expect(result).toEqual([channelSubscription, newMessage]); - expect(putRawData).toHaveBeenCalledWith( - JSON.stringify([channelSubscription, newMessage]), - "client_subscriptions/client-1.json", - ); + 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(); + }); }); - 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( + JSON.stringify(config), + "client_subscriptions/client-1.json", + ); }); - 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("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); + describe("addSubscription", () => { + it("appends subscription to existing config", async () => { + const existing: ClientSubscriptionConfiguration = { + clientId: "client-1", + 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, + ); - const { repository } = createRepository({ - getObject, - putRawData, - channelStatus, + expect(result.subscriptions).toHaveLength(2); + expect(result.subscriptions[1]).toEqual(channelSubscription); + expect(putRawData).toHaveBeenCalledTimes(1); }); - 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, - }); + it("creates new config when none exists", async () => { + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); - expect(putRawData).not.toHaveBeenCalled(); - }); - - describe("validation", () => { - it("throws validation error for invalid message status", async () => { - const { repository } = createRepository(); + const result = await repository.addSubscription( + "client-1", + messageSubscription, + false, + ); - await expect( - repository.putMessageStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - statuses: ["INVALID_STATUS" as never], - rateLimit: 10, - dryRun: false, - }), - ).rejects.toThrow(z.ZodError); + expect(result.clientId).toBe("client-1"); + expect(result.subscriptions).toEqual([messageSubscription]); + expect(result.targets).toEqual([]); }); + }); - 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: ClientSubscriptionConfiguration = { + clientId: "client-1", + subscriptions: [], + targets: [], + }; + 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(configurationBuilder.messageStatus).toHaveBeenCalledWith( - expect.objectContaining({ - apiKeyHeaderName: "x-api-key", - }), - ); + expect(result.clientId).toBe("client-1"); + expect(result.targets).toEqual([baseTarget]); }); + }); - it("applies default value for apiKeyHeaderName on channel subscription", async () => { - const getObject = jest.fn().mockResolvedValue(undefined as never); - const channelStatus = jest.fn().mockReturnValue(channelSubscription); + describe("deleteTarget", () => { + it("removes target 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.deleteTarget( + "client-1", + TARGET_ID, + false, + ); - const { configurationBuilder, repository } = createRepository({ - getObject, - channelStatus, - }); + expect(result.targets).toHaveLength(0); + }); - await repository.putChannelStatusSubscription({ - clientName: "Client 1", - clientId: "client-1", - apiKey: "secret", - apiEndpoint: "https://example.com/webhook", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["delivered"], - channelType: "SMS", - rateLimit: 10, - dryRun: false, - }); - - expect(configurationBuilder.channelStatus).toHaveBeenCalledWith( - expect.objectContaining({ - apiKeyHeaderName: "x-api-key", - }), - ); + 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/container.ts b/tools/client-subscriptions-management/src/container.ts index ddac5009..288f4da7 100644 --- a/tools/client-subscriptions-management/src/container.ts +++ b/tools/client-subscriptions-management/src/container.ts @@ -2,7 +2,6 @@ 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; @@ -28,8 +27,5 @@ export const createClientSubscriptionRepository = ( options.bucketName, createS3Client(options.region, options.profile), ); - return new ClientSubscriptionRepository( - s3Repository, - clientSubscriptionBuilder, - ); + return new ClientSubscriptionRepository(s3Repository); }; 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/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts similarity index 81% rename from tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts rename to tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts index f9ce855c..657f3381 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -2,7 +2,6 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { - formatSubscriptionFileResponse, resolveBucketName, resolveProfile, resolveRegion, @@ -50,18 +49,16 @@ export async function main(args: string[] = process.argv) { region, profile, ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ + const repository = createClientSubscriptionRepository({ bucketName, region, profile, }); - const result = await clientSubscriptionRepository.getClientSubscriptions( - argv["client-id"], - ); + const config = await repository.getClientConfig(argv["client-id"]); - if (result) { - console.log(formatSubscriptionFileResponse(result)); + if (config) { + console.log(JSON.stringify(config, null, 2)); } else { console.log(`No configuration exists for client: ${argv["client-id"]}`); } @@ -78,13 +75,8 @@ export const runCli = async (args: string[] = process.argv) => { export const runIfMain = async ( args: string[] = process.argv, - isMain: boolean = require.main === module, + isMain = require.main === module, ) => { - if (isMain) { - await runCli(args); - } + if (isMain) await runCli(args); }; - -(async () => { - await runIfMain(); -})(); +runIfMain(); 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..a917e567 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -0,0 +1,74 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + 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", + }, + 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 repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + const clientIds = await repository.listClientIds(); + for (const id of clientIds) { + console.log(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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); 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..775716c4 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { createClientSubscriptionRepository } from "src/container"; +import { + resolveBucketName, + resolveProfile, + resolveRegion, + runTerraformApply, +} 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", + }, + 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)", + }, + "dry-run": { + type: "boolean", + default: false, + demandOption: false, + 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)", + }, + "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)", + }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + "tf-region": { + type: "string", + demandOption: false, + description: "AWS region override for terraform", + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + + if (!argv.json && !argv.file) { + console.error("Error: one of --json or --file is required"); + 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; + } + + if (argv["dry-run"]) { + console.log("Dry run: config is valid"); + console.log(JSON.stringify(config, null, 2)); + return; + } + + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + await repository.putClientConfig(argv["client-id"], config, false); + console.log(`Config written for client: ${argv["client-id"]}`); + + if (argv["terraform-apply"]) { + await runTerraformApply({ + environment: argv.environment, + group: argv.group, + project: argv.project, + tfRegion: argv["tf-region"], + }); + } +} + +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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); 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/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index d060417b..f2a4ecf5 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,50 +1,79 @@ +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; 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 type { + CallbackTarget, + ClientSubscriptionConfiguration, + SubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; const SUBSCRIPTION_TABLE_HEADER = [ - "Client ID", - "Subscription Type", + "Subscription ID", + "Type", "Statuses", + "Target IDs", +]; + +const TARGET_TABLE_HEADER = [ "Target ID", "Endpoint", "Method", "Rate Limit", "API Key Header", - "API Key Value", ]; const subscriptionStatuses = ( - subscription: ClientSubscriptionConfiguration[number], + subscription: SubscriptionConfiguration, ): string => { - if (subscription.SubscriptionType === "MessageStatus") { - return subscription.MessageStatuses.join(", "); + if (subscription.subscriptionType === "MessageStatus") { + return subscription.messageStatuses.join(", "); } const statuses = [ - ...subscription.ChannelStatuses, - ...subscription.SupplierStatuses, + ...subscription.channelStatuses, + ...subscription.supplierStatuses, ]; - return `${subscription.ChannelType}: ${statuses.join(", ")}`; + return `${subscription.channelType}: ${statuses.join(", ")}`; }; -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 const formatSubscriptionsTable = ( + subscriptions: SubscriptionConfiguration[], +): string => + table([ + SUBSCRIPTION_TABLE_HEADER, + ...subscriptions.map((sub) => [ + sub.subscriptionId, + sub.subscriptionType, + subscriptionStatuses(sub), + sub.targetIds.join(", "), ]), - ); - return table([SUBSCRIPTION_TABLE_HEADER, ...rows]); + ]); + +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 => @@ -101,3 +130,79 @@ export const resolveRegion = ( regionArg?: string, env: NodeJS.ProcessEnv = process.env, ): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; + +export const runTerraformApply = async (opts: { + environment?: string; + group?: string; + project?: string; + tfRegion?: string; + confirmFn?: () => Promise; +}): Promise => { + const { confirmFn, environment, group, project = "nhs", 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=${project}`, + ]; + 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; +}; 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..aa0a62f3 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -0,0 +1,198 @@ +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 { + buildChannelStatusSubscription, + buildMessageStatusSubscription, +} from "src/domain/client-subscription-builder"; +import { + formatClientConfig, + 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", + }, + "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)", + }, + "dry-run": { + type: "boolean", + default: false, + demandOption: false, + 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)", + }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + 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 region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + const result = await repository.addSubscription( + argv["client-id"], + subscription, + argv["dry-run"], + ); + + console.log(formatClientConfig(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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); 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..1c488770 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -0,0 +1,101 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatClientConfig, + 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", + }, + "subscription-id": { + type: "string", + demandOption: true, + description: "Subscription ID to delete", + }, + "dry-run": { + type: "boolean", + default: false, + demandOption: false, + description: "Simulate deletion 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)", + }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + }) + .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, + argv.project, + ); + const repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + const result = await repository.deleteSubscription( + argv["client-id"], + argv["subscription-id"], + argv["dry-run"], + ); + + console.log(formatClientConfig(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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); 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..26d55bfc --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -0,0 +1,89 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionsTable, + 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 repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + 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 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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts similarity index 54% rename from tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts rename to tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts index 4097dd91..05ec23c8 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -2,12 +2,12 @@ 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, + formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, @@ -27,61 +27,42 @@ export const parseArgs = (args: string[]) => 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": { + "subscription-id": { type: "string", demandOption: true, - description: "Webhook endpoint URL (must start with https://)", + description: "Subscription ID to update", }, - "api-key-header-name": { - type: "string", - default: "x-api-key", + "message-statuses": { + string: true, + type: "array", demandOption: false, - description: "HTTP header name for the API key", - }, - "api-key": { - type: "string", - demandOption: true, - description: "API key value for authenticating webhook calls", + choices: MESSAGE_STATUSES, + description: "New message statuses (for MessageStatus subscriptions)", }, "channel-statuses": { string: true, type: "array", demandOption: false, choices: CHANNEL_STATUSES, - description: "Channel statuses to subscribe to", + description: "New channel statuses (for ChannelStatus subscriptions)", }, "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", + description: "New supplier statuses (for ChannelStatus subscriptions)", }, "dry-run": { type: "boolean", - demandOption: true, - description: "Validate config without writing to S3", + default: false, + demandOption: false, + description: "Simulate update without writing to S3", }, region: { type: "string", @@ -93,23 +74,29 @@ export const parseArgs = (args: string[]) => demandOption: false, description: "AWS profile to use (overrides AWS_PROFILE)", }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, }) .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 messageStatuses = argv["message-statuses"]; const channelStatuses = argv["channel-statuses"]; const supplierStatuses = argv["supplier-statuses"]; - if (!channelStatuses?.length && !supplierStatuses?.length) { + + if ( + !messageStatuses?.length && + !channelStatuses?.length && + !supplierStatuses?.length + ) { console.error( - "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + "Error: at least one of --message-statuses, --channel-statuses, or --supplier-statuses must be provided", ); process.exitCode = 1; return; @@ -122,28 +109,22 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, + argv.project, ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ + const repository = 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"], - }); + const result = await repository.setSubscriptionStates( + argv["client-id"], + argv["subscription-id"], + { messageStatuses, channelStatuses, supplierStatuses }, + argv["dry-run"], + ); - console.log(formatSubscriptionFileResponse(result)); + console.log(formatClientConfig(result)); } export const runCli = async (args: string[] = process.argv) => { @@ -157,13 +138,8 @@ export const runCli = async (args: string[] = process.argv) => { export const runIfMain = async ( args: string[] = process.argv, - isMain: boolean = require.main === module, + isMain = require.main === module, ) => { - if (isMain) { - await runCli(args); - } + if (isMain) await runCli(args); }; - -(async () => { - await runIfMain(); -})(); +runIfMain(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts similarity index 71% rename from tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts rename to tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts index 8dcdb356..fa6a8b33 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -1,9 +1,9 @@ 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 { buildTarget } from "src/domain/client-subscription-builder"; import { - formatSubscriptionFileResponse, + formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, @@ -23,11 +23,6 @@ export const parseArgs = (args: string[]) => 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, @@ -49,13 +44,6 @@ export const parseArgs = (args: string[]) => 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, @@ -63,7 +51,8 @@ export const parseArgs = (args: string[]) => }, "dry-run": { type: "boolean", - demandOption: true, + default: false, + demandOption: false, description: "Validate config without writing to S3", }, region: { @@ -76,11 +65,18 @@ export const parseArgs = (args: string[]) => demandOption: false, description: "AWS profile to use (overrides AWS_PROFILE)", }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, }) .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://"); @@ -88,6 +84,13 @@ export async function main(args: string[] = process.argv) { return; } + const target = buildTarget({ + apiEndpoint, + apiKey: argv["api-key"], + apiKeyHeaderName: argv["api-key-header-name"], + rateLimit: argv["rate-limit"], + }); + const region = resolveRegion(argv.region); const profile = resolveProfile(argv.profile); const bucketName = await resolveBucketName( @@ -95,26 +98,21 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, + argv.project, ); - const clientSubscriptionRepository = createClientSubscriptionRepository({ + const repository = 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)); + 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 runCli = async (args: string[] = process.argv) => { @@ -128,13 +126,8 @@ export const runCli = async (args: string[] = process.argv) => { export const runIfMain = async ( args: string[] = process.argv, - isMain: boolean = require.main === module, + isMain = require.main === module, ) => { - if (isMain) { - await runCli(args); - } + if (isMain) await runCli(args); }; - -(async () => { - await runIfMain(); -})(); +runIfMain(); 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..6fa225a7 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -0,0 +1,102 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatClientConfig, + 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", + }, + "target-id": { + type: "string", + demandOption: true, + description: "Target identifier to delete", + }, + "dry-run": { + type: "boolean", + default: false, + demandOption: false, + 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)", + }, + project: { + type: "string", + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + }) + .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, + argv.project, + ); + const repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + const result = await repository.deleteTarget( + argv["client-id"], + argv["target-id"], + argv["dry-run"], + ); + + console.log(formatClientConfig(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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); 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..78202133 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -0,0 +1,89 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatTargetsTable, + 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 repository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + 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 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 = require.main === module, +) => { + if (isMain) await runCli(args); +}; +runIfMain(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts b/tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts new file mode 100644 index 00000000..eb9f6272 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import type { + CallbackTarget, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, + SubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; + +const httpsUrlSchema = z.string().refine( + (value) => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } + }, + { message: "Expected HTTPS URL" }, +); + +const targetSchema = z.object({ + targetId: z.string().min(1), + type: z.literal("API"), + invocationEndpoint: httpsUrlSchema, + invocationMethod: z.literal("POST"), + invocationRateLimit: z.number().positive(), + apiKey: z.object({ + headerName: z.string().min(1), + headerValue: z.string().min(1), + }), +}) satisfies z.ZodType; + +const baseSubscriptionSchema = z.object({ + subscriptionId: z.string().min(1), + targetIds: z.array(z.string().min(1)).min(1), +}); + +const messageStatusSchema = baseSubscriptionSchema.extend({ + subscriptionType: z.literal("MessageStatus"), + messageStatuses: z.array(z.enum(MESSAGE_STATUSES)).min(1), +}) satisfies z.ZodType; + +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)), +}) satisfies z.ZodType; + +const subscriptionSchema = z.discriminatedUnion("subscriptionType", [ + messageStatusSchema, + channelStatusSchema, +]) satisfies z.ZodType; + +const configSchema = z + .object({ + clientId: z.string().min(1), + subscriptions: z.array(subscriptionSchema), + targets: z.array(targetSchema), + }) + .superRefine((config, ctx) => { + const seenSubIds = new Set(); + for (const [i, sub] of config.subscriptions.entries()) { + if (seenSubIds.has(sub.subscriptionId)) { + ctx.addIssue({ + code: "custom", + message: "Expected subscriptionId to be unique", + path: ["subscriptions", i, "subscriptionId"], + }); + } else { + seenSubIds.add(sub.subscriptionId); + } + } + + const validTargetIds = new Set(); + for (const [i, target] of config.targets.entries()) { + if (validTargetIds.has(target.targetId)) { + ctx.addIssue({ + code: "custom", + message: "Expected targetId to be unique", + path: ["targets", i, "targetId"], + }); + } else { + validTargetIds.add(target.targetId); + } + } + + for (const [sIdx, sub] of config.subscriptions.entries()) { + for (const [tIdx, targetId] of sub.targetIds.entries()) { + if (!validTargetIds.has(targetId)) { + ctx.addIssue({ + code: "custom", + message: `targetId "${targetId}" not found in targets`, + path: ["subscriptions", sIdx, "targetIds", tIdx], + }); + } + } + } + }) satisfies z.ZodType; + +export const validateClientConfig = ( + rawConfig: unknown, +): ClientSubscriptionConfiguration => { + const result = configSchema.safeParse(rawConfig); + if (!result.success) { + const messages = result.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("\n"); + throw new Error(`Config validation failed:\n${messages}`); + } + return result.data; +}; + +export default validateClientConfig; 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..fb9f3725 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts @@ -0,0 +1,113 @@ +import { confirm, input } from "@inquirer/prompts"; +import { createClientSubscriptionRepository } from "src/container"; +import { runTerraformApply } from "src/entrypoint/cli/helper"; +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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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, + project: connection.project, + 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..b9edf364 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts @@ -0,0 +1,122 @@ +import { Separator, confirm, input, select } from "@inquirer/prompts"; +import { createClientSubscriptionRepository } from "src/container"; +import { resolveBucketName } from "src/entrypoint/cli/helper"; + +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; + project: string; +} + +export type Repository = ReturnType; + +export const buildConnectionArgs = (connection: ConnectionConfig): string[] => { + const args: string[] = [ + "--bucket-name", + connection.bucketName, + "--project", + connection.project, + ]; + 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 project = await input({ + message: "Project name:", + default: "nhs", + }); + + 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, + project, + ); + console.log(`\nResolved bucket: ${bucketName}`); + } + + return { bucketName, environment, region, profile, project }; +} + +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..2c64efce --- /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 { createClientSubscriptionRepository } from "src/container"; +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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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..e5ee414c --- /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 { createClientSubscriptionRepository } from "src/container"; +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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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 = createClientSubscriptionRepository({ + 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/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 48c56290..404b06a6 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -1,165 +1,155 @@ -import { z } from "zod"; import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - type Channel, - type ChannelStatus, + type CallbackTarget, type ClientSubscriptionConfiguration, - MESSAGE_STATUSES, - type MessageStatus, - SUPPLIER_STATUSES, - type SupplierStatus, + type SubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; -import type { SubscriptionBuilder } from "src/domain/client-subscription-builder"; 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 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 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"), -}); +const CLIENT_SUBSCRIPTIONS_PREFIX = "client_subscriptions/"; export class ClientSubscriptionRepository { - constructor( - private readonly s3Repository: S3Repository, - private readonly configurationBuilder: SubscriptionBuilder, - ) {} + constructor(private readonly s3Repository: S3Repository) {} + + 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 getClientSubscriptions( + 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 JSON.parse(rawFile) as ClientSubscriptionConfiguration; } 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, - ]; - - if (!parsedSubscriptionArgs.dryRun) { + async putClientConfig( + clientId: string, + config: ClientSubscriptionConfiguration, + dryRun: boolean, + ): Promise { + if (!dryRun) { await this.s3Repository.putRawData( - JSON.stringify(newConfigFile), - `client_subscriptions/${clientId}.json`, + JSON.stringify(config), + `${CLIENT_SUBSCRIPTIONS_PREFIX}${clientId}.json`, ); } - - return newConfigFile; + return config; } - 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)) ?? []; - - const indexOfChannelStatusSubscription = subscriptions.findIndex( - (subscription) => - subscription.SubscriptionType === "ChannelStatus" && - subscription.ChannelType === parsedSubscriptionArgs.channelType, - ); - - if (indexOfChannelStatusSubscription !== -1) { - subscriptions.splice(indexOfChannelStatusSubscription, 1); + async setSubscriptionStates( + clientId: string, + subscriptionId: string, + states: { + messageStatuses?: string[]; + channelStatuses?: string[]; + supplierStatuses?: 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.map((sub) => { + if (sub.subscriptionId !== subscriptionId) return sub; + if ( + sub.subscriptionType === "MessageStatus" && + states.messageStatuses + ) { + return { ...sub, messageStatuses: states.messageStatuses as any }; + } + if (sub.subscriptionType === "ChannelStatus") { + return { + ...sub, + ...(states.channelStatuses && { + channelStatuses: states.channelStatuses as any, + }), + ...(states.supplierStatuses && { + supplierStatuses: states.supplierStatuses as any, + }), + }; + } + return sub; + }), + }; + return this.putClientConfig(clientId, updated, dryRun); + } - const channelStatusConfig = this.configurationBuilder.channelStatus( - parsedSubscriptionArgs, - ); - - const newConfigFile: ClientSubscriptionConfiguration = [ - ...subscriptions, - channelStatusConfig, - ]; + 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 (!parsedSubscriptionArgs.dryRun) { - await this.s3Repository.putRawData( - JSON.stringify(newConfigFile), - `client_subscriptions/${clientId}.json`, - ); + 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}`); } - - 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; + } } From adea58478d4250d367fadfe000474598e715e740 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 17 Mar 2026 14:36:26 +0000 Subject: [PATCH 02/29] lint fixes --- .../src/__tests__/services/config-cache.test.ts | 10 ++++++++++ .../src/services/filters/channel-status-filter.ts | 2 +- .../src/services/filters/message-status-filter.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) 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 07f91667..23a47b5e 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 @@ -16,6 +16,10 @@ describe("ConfigCache", () => { ], targets: [], }; + + cache.set("client-1", config); + + expect(cache.get("client-1")).toEqual(config); }); it("returns undefined for non-existent key", () => { @@ -43,6 +47,9 @@ describe("ConfigCache", () => { targets: [], }; + cache.set("client-1", config); + jest.advanceTimersByTime(1001); + const result = cache.get("client-1"); expect(result).toBeUndefined(); @@ -65,6 +72,9 @@ describe("ConfigCache", () => { targets: [], }; + cache.set("client-1", config); + cache.set("client-2", config); + cache.clear(); expect(cache.get("client-1")).toBeUndefined(); 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 5d3f576e..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 @@ -27,7 +27,7 @@ export const matchesChannelStatusSubscription = ( } const matched = config.subscriptions - .filter(isChannelStatusSubscription) + .filter((subscription) => isChannelStatusSubscription(subscription)) .some((subscription) => { if (subscription.channelType !== notifyData.channel) { logger.debug("Channel status filter rejected: channel type mismatch", { 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 0875b394..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 @@ -27,7 +27,7 @@ export const matchesMessageStatusSubscription = ( } const matched = config.subscriptions - .filter(isMessageStatusSubscription) + .filter((subscription) => isMessageStatusSubscription(subscription)) .some((subscription) => { const messageStatusChanged = notifyData.previousMessageStatus !== notifyData.messageStatus; From 5ff8589dbfb5584be939028bbf8d7ceb07aefc66 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 17 Mar 2026 15:46:11 +0000 Subject: [PATCH 03/29] Resolve duplicated code in cli error wrapper --- .../src/__tests__/entrypoint/cli/clients-get.test.ts | 1 + .../src/__tests__/entrypoint/cli/clients-list.test.ts | 1 + .../src/__tests__/entrypoint/cli/clients-put.test.ts | 2 ++ .../entrypoint/cli/subscriptions-add.test.ts | 1 + .../entrypoint/cli/subscriptions-del.test.ts | 1 + .../entrypoint/cli/subscriptions-list.test.ts | 1 + .../entrypoint/cli/subscriptions-set-states.test.ts | 1 + .../src/__tests__/entrypoint/cli/targets-add.test.ts | 1 + .../src/__tests__/entrypoint/cli/targets-del.test.ts | 1 + .../src/__tests__/entrypoint/cli/targets-list.test.ts | 1 + .../src/entrypoint/cli/clients-get.ts | 10 ++-------- .../src/entrypoint/cli/clients-list.ts | 10 ++-------- .../src/entrypoint/cli/clients-put.ts | 10 ++-------- .../src/entrypoint/cli/helper.ts | 11 +++++++++++ .../src/entrypoint/cli/subscriptions-add.ts | 10 ++-------- .../src/entrypoint/cli/subscriptions-del.ts | 10 ++-------- .../src/entrypoint/cli/subscriptions-list.ts | 10 ++-------- .../src/entrypoint/cli/subscriptions-set-states.ts | 10 ++-------- .../src/entrypoint/cli/targets-add.ts | 10 ++-------- .../src/entrypoint/cli/targets-del.ts | 10 ++-------- .../src/entrypoint/cli/targets-list.ts | 10 ++-------- 21 files changed, 42 insertions(+), 80 deletions(-) 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 index 5365c032..181cd85d 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, 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 index 8a6e3afb..cdb64e20 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, 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 index b556cf6f..414b52ba 100644 --- 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 @@ -15,6 +15,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, resolveRegion: mockResolveRegion, @@ -22,6 +23,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); jest.mock("node:fs", () => ({ + ...jest.requireActual("node:fs"), readFileSync: jest.fn(), })); 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 index 7a15ab85..694be5c6 100644 --- 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 @@ -19,6 +19,7 @@ jest.mock("src/domain/client-subscription-builder", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatClientConfig: mockFormatClientConfig, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index f4892628..ce0373d2 100644 --- 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 @@ -12,6 +12,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatClientConfig: mockFormatClientConfig, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index 26d77d7b..d1bca882 100644 --- 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 @@ -12,6 +12,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatSubscriptionsTable: mockFormatSubscriptionsTable, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index ada2cfa2..36065d25 100644 --- 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 @@ -12,6 +12,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatClientConfig: mockFormatClientConfig, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index 063bb9c7..0b405d55 100644 --- 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 @@ -17,6 +17,7 @@ jest.mock("src/domain/client-subscription-builder", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatClientConfig: mockFormatClientConfig, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index 50d5a592..1e0b797c 100644 --- 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 @@ -12,6 +12,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatClientConfig: mockFormatClientConfig, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, 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 index 93975130..6cdc334a 100644 --- 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 @@ -12,6 +12,7 @@ jest.mock("src/container", () => ({ })); jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), formatTargetsTable: mockFormatTargetsTable, resolveBucketName: mockResolveBucketName, resolveProfile: mockResolveProfile, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts index 657f3381..c6c38cee 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -5,6 +5,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -64,14 +65,7 @@ export async function main(args: string[] = process.argv) { } } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts index a917e567..4da96e38 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -5,6 +5,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -56,14 +57,7 @@ export async function main(args: string[] = process.argv) { } } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index 775716c4..55c6b405 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -8,6 +8,7 @@ import { resolveProfile, resolveRegion, runTerraformApply, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -145,14 +146,7 @@ export async function main(args: string[] = process.argv) { } } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index f2a4ecf5..ede4ba42 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -206,3 +206,14 @@ export const runTerraformApply = async (opts: { } return true; }; + +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; + } + }; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts index aa0a62f3..5a447c1f 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -16,6 +16,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -180,14 +181,7 @@ export async function main(args: string[] = process.argv) { console.log(formatClientConfig(result)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts index 1c488770..dd9bee51 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -6,6 +6,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -83,14 +84,7 @@ export async function main(args: string[] = process.argv) { console.log(formatClientConfig(result)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts index 26d55bfc..e078bc53 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -6,6 +6,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -71,14 +72,7 @@ export async function main(args: string[] = process.argv) { console.log(formatSubscriptionsTable(config.subscriptions)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, 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 index 05ec23c8..99f7944e 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -11,6 +11,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -127,14 +128,7 @@ export async function main(args: string[] = process.argv) { console.log(formatClientConfig(result)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts index fa6a8b33..155ff287 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -7,6 +7,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -115,14 +116,7 @@ export async function main(args: string[] = process.argv) { console.log(formatClientConfig(result)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts index 6fa225a7..f85035ff 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -6,6 +6,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -84,14 +85,7 @@ export async function main(args: string[] = process.argv) { console.log(formatClientConfig(result)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts index 78202133..85a73e02 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -6,6 +6,7 @@ import { resolveBucketName, resolveProfile, resolveRegion, + wrapCli, } from "src/entrypoint/cli/helper"; export const parseArgs = (args: string[]) => @@ -71,14 +72,7 @@ export async function main(args: string[] = process.argv) { console.log(formatTargetsTable(config.targets)); } -export const runCli = async (args: string[] = process.argv) => { - try { - await main(args); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -}; +export const runCli = wrapCli(main); export const runIfMain = async ( args: string[] = process.argv, From a1d65aaa175d599492befd95c699da58837b46ea Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 16:15:13 +0000 Subject: [PATCH 04/29] fixup! CCM-15259 - Improve client subscription management script --- package-lock.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index c93eb325..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", @@ -3853,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, @@ -5149,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, From 600690b22e672155d5267ec01998d7bd8a607735 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 16:15:49 +0000 Subject: [PATCH 05/29] Resolve duplicated code in cli args, remove project arg --- .../__tests__/entrypoint/cli/helper.test.ts | 7 --- .../entrypoint/interactive/clients.test.ts | 3 +- .../entrypoint/interactive/shared.test.ts | 14 +---- .../src/entrypoint/cli/clients-get.ts | 30 ++--------- .../src/entrypoint/cli/clients-list.ts | 23 +------- .../src/entrypoint/cli/clients-put.ts | 46 +++------------- .../src/entrypoint/cli/helper.ts | 53 ++++++++++++++++--- .../src/entrypoint/cli/subscriptions-add.ts | 45 +++------------- .../src/entrypoint/cli/subscriptions-del.ts | 45 +++------------- .../src/entrypoint/cli/subscriptions-list.ts | 30 ++--------- .../cli/subscriptions-set-states.ts | 45 +++------------- .../src/entrypoint/cli/targets-add.ts | 45 +++------------- .../src/entrypoint/cli/targets-del.ts | 45 +++------------- .../src/entrypoint/cli/targets-list.ts | 30 ++--------- .../src/entrypoint/interactive/clients.ts | 1 - .../src/entrypoint/interactive/shared.ts | 10 +--- 16 files changed, 99 insertions(+), 373 deletions(-) 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 77eb7d2b..d0f9ad8a 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 @@ -144,12 +144,6 @@ describe("cli helper", () => { ); }); - 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"); }); @@ -262,7 +256,6 @@ describe("runTerraformApply", () => { const result = await runTerraformApply({ environment: "dev", group: "mygroup", - project: "nhs", confirmFn, }); 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 index fcf8d16c..5ed7779f 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts @@ -55,7 +55,7 @@ import { } from "src/entrypoint/interactive/clients"; import { promptDryRun } from "src/entrypoint/interactive/shared"; -const connection = { bucketName: "bucket", project: "nhs" }; +const connection = { bucketName: "bucket" }; describe("interactiveClientsList", () => { it("calls clientsList main with connection args", async () => { @@ -130,7 +130,6 @@ describe("interactiveClientsPut", () => { expect.objectContaining({ environment: "dev", group: "mygroup", - project: "nhs", }), ); }); 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 index 60b090f6..95ddc748 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts @@ -35,16 +35,10 @@ import { describe("buildConnectionArgs", () => { const base: ConnectionConfig = { bucketName: "my-bucket", - project: "nhs", }; - it("returns bucket-name and project args", () => { - expect(buildConnectionArgs(base)).toEqual([ - "--bucket-name", - "my-bucket", - "--project", - "nhs", - ]); + it("returns bucket-name arg", () => { + expect(buildConnectionArgs(base)).toEqual(["--bucket-name", "my-bucket"]); }); it("includes region and profile when present", () => { @@ -72,7 +66,6 @@ describe("promptConnection", () => { mockSelect.mockResolvedValue("eu-west-2"); mockInput .mockResolvedValueOnce("") // profile - .mockResolvedValueOnce("nhs") // project .mockResolvedValueOnce("my-direct-bucket"); // bucket name const result = await promptConnection(); @@ -86,7 +79,6 @@ describe("promptConnection", () => { mockSelect.mockResolvedValue("eu-west-2"); mockInput .mockResolvedValueOnce("") // profile - .mockResolvedValueOnce("nhs") // project .mockResolvedValueOnce("") // bucket name (blank) .mockResolvedValueOnce("dev"); // environment @@ -97,7 +89,6 @@ describe("promptConnection", () => { "dev", "eu-west-2", undefined, - "nhs", ); expect(result.environment).toBe("dev"); expect(result.bucketName).toBe("resolved-bucket"); @@ -108,7 +99,6 @@ describe("promptConnection", () => { mockInput .mockResolvedValueOnce("ap-southeast-1") // custom region .mockResolvedValueOnce("") // profile - .mockResolvedValueOnce("nhs") // project .mockResolvedValueOnce("my-bucket"); // bucket const result = await promptConnection(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts index c6c38cee..c508fea1 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -2,6 +2,8 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, resolveBucketName, resolveProfile, resolveRegion, @@ -11,32 +13,8 @@ import { 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)", - }, + ...commonOptions, + ...clientIdOption, }) .parseSync(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts index 4da96e38..938ff8f2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -2,6 +2,7 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + commonOptions, resolveBucketName, resolveProfile, resolveRegion, @@ -11,27 +12,7 @@ import { 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", - }, - 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)", - }, + ...commonOptions, }) .parseSync(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index 55c6b405..034d4c23 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -4,32 +4,22 @@ import { hideBin } from "yargs/helpers"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, resolveBucketName, resolveProfile, resolveRegion, runTerraformApply, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, json: { type: "string", demandOption: false, @@ -42,22 +32,6 @@ export const parseArgs = (args: string[]) => description: "Path to a JSON file containing the full client config (mutually exclusive with --json)", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - 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)", - }, "terraform-apply": { type: "boolean", default: false, @@ -69,12 +43,6 @@ export const parseArgs = (args: string[]) => demandOption: false, description: "Group name (required when --terraform-apply is set)", }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, "tf-region": { type: "string", demandOption: false, @@ -125,7 +93,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, @@ -140,7 +107,6 @@ export async function main(args: string[] = process.argv) { await runTerraformApply({ environment: argv.environment, group: argv.group, - project: argv.project, tfRegion: argv["tf-region"], }); } diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index ede4ba42..c5d1411e 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -101,17 +101,14 @@ export const deriveBucketName = ( accountId: string, environment: string, region: string, - project = "nhs", - component = "callbacks", ): string => - `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; + `nhs-${accountId}-${region}-${environment}-callbacks-subscription-config`; export const resolveBucketName = async ( bucketArg?: string, environment?: string, region?: string, profile?: string, - project?: string, ): Promise => { if (bucketArg) { return bucketArg; @@ -123,7 +120,7 @@ export const resolveBucketName = async ( } const resolvedRegion = region ?? "eu-west-2"; const accountId = await resolveAccountId(profile, resolvedRegion); - return deriveBucketName(accountId, environment, resolvedRegion, project); + return deriveBucketName(accountId, environment, resolvedRegion); }; export const resolveRegion = ( @@ -134,11 +131,10 @@ export const resolveRegion = ( export const runTerraformApply = async (opts: { environment?: string; group?: string; - project?: string; tfRegion?: string; confirmFn?: () => Promise; }): Promise => { - const { confirmFn, environment, group, project = "nhs", tfRegion } = opts; + const { confirmFn, environment, group, tfRegion } = opts; if (!environment || !group) { console.error( "Error: --environment and --group are required when --terraform-apply is set", @@ -151,7 +147,7 @@ export const runTerraformApply = async (opts: { `component=callbacks`, `environment=${environment}`, `group=${group}`, - `project=${project}`, + `project=nhs`, ]; if (tfRegion) { makeArgs.push(`region=${tfRegion}`); @@ -217,3 +213,44 @@ export const wrapCli = process.exitCode = 1; } }; + +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 clientIdOption = { + "client-id": { + type: "string" as const, + demandOption: true as const, + description: "Client identifier", + }, +}; + +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/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts index 5a447c1f..285daa96 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -12,32 +12,22 @@ import { buildMessageStatusSubscription, } from "src/domain/client-subscription-builder"; import { + clientIdOption, + commonOptions, formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, "subscription-type": { type: "string", demandOption: true, @@ -83,28 +73,6 @@ export const parseArgs = (args: string[]) => description: "Explicit subscription ID (defaults to a generated UUID v4)", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - 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)", - }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, }) .parseSync(); @@ -164,7 +132,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts index dd9bee51..2d89e604 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -2,59 +2,27 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, "subscription-id": { type: "string", demandOption: true, description: "Subscription ID to delete", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - description: "Simulate deletion 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)", - }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, }) .parseSync(); @@ -67,7 +35,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts index e078bc53..ffffbb98 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -2,6 +2,8 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, formatSubscriptionsTable, resolveBucketName, resolveProfile, @@ -12,32 +14,8 @@ import { 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)", - }, + ...commonOptions, + ...clientIdOption, }) .parseSync(); 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 index 99f7944e..a671b4d2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -7,32 +7,22 @@ import { } from "@nhs-notify-client-callbacks/models"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, "subscription-id": { type: "string", demandOption: true, @@ -59,28 +49,6 @@ export const parseArgs = (args: string[]) => choices: SUPPLIER_STATUSES, description: "New supplier statuses (for ChannelStatus subscriptions)", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - description: "Simulate update 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)", - }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, }) .parseSync(); @@ -110,7 +78,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts index 155ff287..3f0dcf58 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -3,32 +3,22 @@ import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { buildTarget } from "src/domain/client-subscription-builder"; import { + clientIdOption, + commonOptions, formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, "api-endpoint": { type: "string", demandOption: true, @@ -50,28 +40,6 @@ export const parseArgs = (args: string[]) => demandOption: true, description: "Maximum number of webhook calls per second", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - 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)", - }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, }) .parseSync(); @@ -99,7 +67,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts index f85035ff..19a413fa 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -2,59 +2,27 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, formatClientConfig, resolveBucketName, resolveProfile, resolveRegion, wrapCli, + writeOptions, } 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", - }, + ...commonOptions, + ...clientIdOption, + ...writeOptions, "target-id": { type: "string", demandOption: true, description: "Target identifier to delete", }, - "dry-run": { - type: "boolean", - default: false, - demandOption: false, - 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)", - }, - project: { - type: "string", - default: "nhs", - demandOption: false, - description: "Project name prefix for derived resource names", - }, }) .parseSync(); @@ -68,7 +36,6 @@ export async function main(args: string[] = process.argv) { argv.environment, region, profile, - argv.project, ); const repository = createClientSubscriptionRepository({ bucketName, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts index 85a73e02..a4b9d0d2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -2,6 +2,8 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import { createClientSubscriptionRepository } from "src/container"; import { + clientIdOption, + commonOptions, formatTargetsTable, resolveBucketName, resolveProfile, @@ -12,32 +14,8 @@ import { 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)", - }, + ...commonOptions, + ...clientIdOption, }) .parseSync(); diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts index fb9f3725..79d5c8d6 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts @@ -104,7 +104,6 @@ export async function interactiveClientsPut( await runTerraformApply({ environment: tfEnvironment, group, - project: connection.project, tfRegion: tfRegionRaw.trim() || undefined, confirmFn: () => confirm({ message: "Confirm terraform apply?" }), }); diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts index b9edf364..97ca2db2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts @@ -15,7 +15,6 @@ export interface ConnectionConfig { environment?: string; region?: string; profile?: string; - project: string; } export type Repository = ReturnType; @@ -24,8 +23,6 @@ export const buildConnectionArgs = (connection: ConnectionConfig): string[] => { const args: string[] = [ "--bucket-name", connection.bucketName, - "--project", - connection.project, ]; if (connection.region) args.push("--region", connection.region); if (connection.profile) args.push("--profile", connection.profile); @@ -59,10 +56,6 @@ export async function promptConnection(): Promise { }); const profile = profileRaw.trim() || undefined; - const project = await input({ - message: "Project name:", - default: "nhs", - }); const bucketNameRaw = await input({ message: "S3 bucket name (blank to derive from environment name):", @@ -85,12 +78,11 @@ export async function promptConnection(): Promise { environment, region, profile, - project, ); console.log(`\nResolved bucket: ${bucketName}`); } - return { bucketName, environment, region, profile, project }; + return { bucketName, environment, region, profile }; } export async function promptClientId(repo?: Repository): Promise { From 68438a82e3ef83f5631aea1ef951fa0ddad97516 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 16:21:03 +0000 Subject: [PATCH 06/29] mutually exclusive json/file arg validation handling --- .../entrypoint/cli/clients-put.test.ts | 21 +++++++++++++++++++ .../src/entrypoint/cli/clients-put.ts | 6 ++++++ 2 files changed, 27 insertions(+) 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 index 414b52ba..b511f911 100644 --- 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 @@ -74,6 +74,27 @@ describe("clients-put CLI", () => { 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", diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index 034d4c23..bbd35baa 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -60,6 +60,12 @@ export async function main(args: string[] = process.argv) { 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"); From e2e35290fadd50a586c73860bbb3a16015b8831e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 16:47:22 +0000 Subject: [PATCH 07/29] Refactor create repository code to reduce duplication --- .../entrypoint/cli/clients-get.test.ts | 23 +++++--------- .../entrypoint/cli/clients-list.test.ts | 23 +++++--------- .../entrypoint/cli/clients-put.test.ts | 23 +++++--------- .../__tests__/entrypoint/cli/helper.test.ts | 30 +++++++++++++++++++ .../entrypoint/cli/subscriptions-add.test.ts | 23 +++++--------- .../entrypoint/cli/subscriptions-del.test.ts | 23 +++++--------- .../entrypoint/cli/subscriptions-list.test.ts | 23 +++++--------- .../cli/subscriptions-set-states.test.ts | 23 +++++--------- .../entrypoint/cli/targets-add.test.ts | 21 ++++--------- .../entrypoint/cli/targets-del.test.ts | 21 ++++--------- .../entrypoint/cli/targets-list.test.ts | 23 +++++--------- .../src/entrypoint/cli/clients-get.ts | 19 ++---------- .../src/entrypoint/cli/clients-list.ts | 19 ++---------- .../src/entrypoint/cli/clients-put.ts | 19 ++---------- .../src/entrypoint/cli/helper.ts | 18 +++++++++++ .../src/entrypoint/cli/subscriptions-add.ts | 19 ++---------- .../src/entrypoint/cli/subscriptions-del.ts | 19 ++---------- .../src/entrypoint/cli/subscriptions-list.ts | 19 ++---------- .../cli/subscriptions-set-states.ts | 19 ++---------- .../src/entrypoint/cli/targets-add.ts | 19 ++---------- .../src/entrypoint/cli/targets-del.ts | 19 ++---------- .../src/entrypoint/cli/targets-list.ts | 19 ++---------- 22 files changed, 134 insertions(+), 330 deletions(-) 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 index 181cd85d..44580157 100644 --- 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 @@ -1,20 +1,11 @@ const mockGetClientConfig = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ getClientConfig: mockGetClientConfig, }); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, + createRepository: mockCreateRepository, })); import * as cli from "src/entrypoint/cli/clients-get"; @@ -32,10 +23,10 @@ describe("clients-get CLI", () => { beforeEach(() => { mockGetClientConfig.mockReset(); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -82,7 +73,7 @@ describe("clients-get CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli([ "node", 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 index cdb64e20..e3c32d4e 100644 --- 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 @@ -1,20 +1,11 @@ const mockListClientIds = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ listClientIds: mockListClientIds, }); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, + createRepository: mockCreateRepository, })); import * as cli from "src/entrypoint/cli/clients-list"; @@ -26,10 +17,10 @@ describe("clients-list CLI", () => { beforeEach(() => { mockListClientIds.mockReset(); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + listClientIds: mockListClientIds, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -59,7 +50,7 @@ describe("clients-list CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli(["node", "script", "--bucket-name", "bucket-1"]); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-put.test.ts index b511f911..acff50e0 100644 --- 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 @@ -3,22 +3,13 @@ import path from "node:path"; import { readFileSync } from "node:fs"; const mockPutClientConfig = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ putClientConfig: mockPutClientConfig, }); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, + createRepository: mockCreateRepository, runTerraformApply: jest.fn(), })); @@ -42,10 +33,10 @@ describe("clients-put CLI", () => { beforeEach(() => { mockPutClientConfig.mockReset(); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + putClientConfig: mockPutClientConfig, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -198,7 +189,7 @@ describe("clients-put CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli([ "node", 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 d0f9ad8a..10b83e4d 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 @@ -5,6 +5,7 @@ import type { MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import { + createRepository, deriveBucketName, formatClientConfig, formatSubscriptionsTable, @@ -16,6 +17,10 @@ import { runTerraformApply, } from "src/entrypoint/cli/helper"; +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: jest.fn(), +})); + jest.mock("node:child_process", () => ({ spawnSync: jest.fn().mockReturnValue({ status: 0 }), })); @@ -297,3 +302,28 @@ describe("runTerraformApply", () => { expect(process.exitCode).toBe(2); }); }); + +describe("createRepository", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createClientSubscriptionRepository } = require("src/container") as { + createClientSubscriptionRepository: jest.Mock; + }; + + beforeEach(() => { + createClientSubscriptionRepository.mockReset(); + createClientSubscriptionRepository.mockReturnValue({ + listClientIds: jest.fn(), + }); + }); + + it("creates a repository using provided bucket-name", async () => { + const repo = await createRepository({ "bucket-name": "test-bucket" }); + + expect(createClientSubscriptionRepository).toHaveBeenCalledWith({ + bucketName: "test-bucket", + region: undefined, + profile: undefined, + }); + expect(repo).toEqual({ listClientIds: expect.any(Function) }); + }); +}); 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 index 694be5c6..b439cbfe 100644 --- 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 @@ -1,17 +1,10 @@ const mockAddSubscription = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ addSubscription: mockAddSubscription, }); const mockBuildMessageStatusSubscription = jest.fn(); const mockBuildChannelStatusSubscription = jest.fn(); const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("bucket"); -const mockResolveProfile = jest.fn().mockReturnValue(undefined); -const mockResolveRegion = jest.fn().mockReturnValue("region"); - -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: mockCreateRepository, -})); jest.mock("src/domain/client-subscription-builder", () => ({ buildMessageStatusSubscription: mockBuildMessageStatusSubscription, @@ -20,10 +13,8 @@ jest.mock("src/domain/client-subscription-builder", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatClientConfig: mockFormatClientConfig, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/subscriptions-add"; @@ -87,10 +78,10 @@ describe("subscriptions-add CLI", () => { targetIds: ["target-001"], }); mockFormatClientConfig.mockReturnValue("formatted-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + addSubscription: mockAddSubscription, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -186,7 +177,7 @@ describe("subscriptions-add CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli(baseMessageArgs); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-del.test.ts index ce0373d2..53fe0875 100644 --- 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 @@ -1,22 +1,13 @@ const mockDeleteSubscription = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ deleteSubscription: mockDeleteSubscription, }); const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatClientConfig: mockFormatClientConfig, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/subscriptions-del"; @@ -44,10 +35,10 @@ describe("subscriptions-del CLI", () => { mockDeleteSubscription.mockResolvedValue(resultConfig); mockFormatClientConfig.mockReset(); mockFormatClientConfig.mockReturnValue("formatted-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + deleteSubscription: mockDeleteSubscription, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -81,7 +72,7 @@ describe("subscriptions-del CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli(baseArgs); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/subscriptions-list.test.ts index d1bca882..ff7847c0 100644 --- 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 @@ -1,22 +1,13 @@ const mockGetClientConfig = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ getClientConfig: mockGetClientConfig, }); const mockFormatSubscriptionsTable = jest.fn().mockReturnValue("table-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatSubscriptionsTable: mockFormatSubscriptionsTable, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/subscriptions-list"; @@ -43,10 +34,10 @@ describe("subscriptions-list CLI", () => { mockGetClientConfig.mockReset(); mockFormatSubscriptionsTable.mockReset(); mockFormatSubscriptionsTable.mockReturnValue("table-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -114,7 +105,7 @@ describe("subscriptions-list CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli([ "node", 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 index 36065d25..2fd12bf8 100644 --- 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 @@ -1,22 +1,13 @@ const mockSetSubscriptionStates = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ setSubscriptionStates: mockSetSubscriptionStates, }); const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatClientConfig: mockFormatClientConfig, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/subscriptions-set-states"; @@ -44,10 +35,10 @@ describe("subscriptions-set-states CLI", () => { mockSetSubscriptionStates.mockResolvedValue(resultConfig); mockFormatClientConfig.mockReset(); mockFormatClientConfig.mockReturnValue("formatted-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + setSubscriptionStates: mockSetSubscriptionStates, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -119,7 +110,7 @@ describe("subscriptions-set-states CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli([...baseArgs, "--message-statuses", "DELIVERED"]); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-add.test.ts index 0b405d55..588fa36a 100644 --- 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 @@ -1,16 +1,9 @@ const mockAddTarget = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ addTarget: mockAddTarget, }); const mockBuildTarget = jest.fn(); const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("bucket"); -const mockResolveProfile = jest.fn().mockReturnValue(undefined); -const mockResolveRegion = jest.fn().mockReturnValue("region"); - -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: mockCreateRepository, -})); jest.mock("src/domain/client-subscription-builder", () => ({ buildTarget: mockBuildTarget, @@ -18,10 +11,8 @@ jest.mock("src/domain/client-subscription-builder", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatClientConfig: mockFormatClientConfig, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/targets-add"; @@ -68,10 +59,8 @@ describe("targets-add CLI", () => { mockBuildTarget.mockReturnValue(builtTarget); mockFormatClientConfig.mockReset(); mockFormatClientConfig.mockReturnValue("formatted-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ addTarget: mockAddTarget }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -130,7 +119,7 @@ describe("targets-add CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli(baseArgs); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-del.test.ts index 1e0b797c..01c6fd4d 100644 --- 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 @@ -1,22 +1,13 @@ const mockDeleteTarget = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ deleteTarget: mockDeleteTarget, }); const mockFormatClientConfig = jest.fn().mockReturnValue("formatted-output"); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatClientConfig: mockFormatClientConfig, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/targets-del"; @@ -44,10 +35,8 @@ describe("targets-del CLI", () => { mockDeleteTarget.mockResolvedValue(resultConfig); mockFormatClientConfig.mockReset(); mockFormatClientConfig.mockReturnValue("formatted-output"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ deleteTarget: mockDeleteTarget }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -81,7 +70,7 @@ describe("targets-del CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli(baseArgs); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-list.test.ts index 6cdc334a..c0dfb403 100644 --- 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 @@ -1,22 +1,13 @@ const mockGetClientConfig = jest.fn(); -const mockCreateRepository = jest.fn().mockReturnValue({ +const mockCreateRepository = jest.fn().mockResolvedValue({ getClientConfig: mockGetClientConfig, }); const mockFormatTargetsTable = jest.fn().mockReturnValue("targets-table"); -const mockResolveBucketName = jest.fn().mockResolvedValue("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", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: mockCreateRepository, formatTargetsTable: mockFormatTargetsTable, - resolveBucketName: mockResolveBucketName, - resolveProfile: mockResolveProfile, - resolveRegion: mockResolveRegion, })); import * as cli from "src/entrypoint/cli/targets-list"; @@ -39,10 +30,10 @@ describe("targets-list CLI", () => { mockGetClientConfig.mockReset(); mockFormatTargetsTable.mockReset(); mockFormatTargetsTable.mockReturnValue("targets-table"); - mockResolveBucketName.mockReset(); - mockResolveBucketName.mockResolvedValue("bucket"); - mockResolveRegion.mockReset(); - mockResolveRegion.mockReturnValue("region"); + mockCreateRepository.mockReset(); + mockCreateRepository.mockResolvedValue({ + getClientConfig: mockGetClientConfig, + }); console.log = jest.fn(); console.error = jest.fn(); delete process.exitCode; @@ -113,7 +104,7 @@ describe("targets-list CLI", () => { }); it("handles errors in runCli", async () => { - mockResolveBucketName.mockRejectedValue(new Error("Boom")); + mockCreateRepository.mockRejectedValue(new Error("Boom")); await cli.runCli([ "node", diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts index c508fea1..caf2f52f 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -1,12 +1,9 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, - resolveBucketName, - resolveProfile, - resolveRegion, + createRepository, wrapCli, } from "src/entrypoint/cli/helper"; @@ -20,19 +17,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts index 938ff8f2..c858781c 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -1,11 +1,8 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { commonOptions, - resolveBucketName, - resolveProfile, - resolveRegion, + createRepository, wrapCli, } from "src/entrypoint/cli/helper"; @@ -18,19 +15,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const clientIds = await repository.listClientIds(); for (const id of clientIds) { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index bbd35baa..f05be4f0 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -2,13 +2,10 @@ import { readFileSync } from "node:fs"; import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, - resolveBucketName, - resolveProfile, - resolveRegion, + createRepository, runTerraformApply, wrapCli, writeOptions, @@ -92,19 +89,7 @@ export async function main(args: string[] = process.argv) { return; } - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); await repository.putClientConfig(argv["client-id"], config, false); console.log(`Config written for client: ${argv["client-id"]}`); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index c5d1411e..28e0e1ad 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -3,6 +3,7 @@ import { createInterface } from "node:readline/promises"; import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { fromIni } from "@aws-sdk/credential-providers"; import { table } from "table"; +import { createClientSubscriptionRepository } from "src/container"; import type { CallbackTarget, ClientSubscriptionConfiguration, @@ -214,6 +215,23 @@ export const wrapCli = } }; +export const createRepository = async (argv: { + "bucket-name"?: string; + environment?: string; + region?: string; + profile?: string; +}) => { + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); + return createClientSubscriptionRepository({ bucketName, region, profile }); +}; + export const commonOptions = { "bucket-name": { type: "string" as const, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts index 285daa96..5c56b8af 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -6,7 +6,6 @@ import { MESSAGE_STATUSES, SUPPLIER_STATUSES, } from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; import { buildChannelStatusSubscription, buildMessageStatusSubscription, @@ -14,10 +13,8 @@ import { import { clientIdOption, commonOptions, + createRepository, formatClientConfig, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; @@ -125,19 +122,7 @@ export async function main(args: string[] = process.argv) { }); } - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const result = await repository.addSubscription( argv["client-id"], diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts index 2d89e604..17cf1570 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -1,13 +1,10 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, + createRepository, formatClientConfig, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; @@ -28,19 +25,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const result = await repository.deleteSubscription( argv["client-id"], diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts index ffffbb98..7c9f3866 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -1,13 +1,10 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, + createRepository, formatSubscriptionsTable, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, } from "src/entrypoint/cli/helper"; @@ -21,19 +18,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); 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 index a671b4d2..9c04cd2c 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -5,14 +5,11 @@ import { MESSAGE_STATUSES, SUPPLIER_STATUSES, } from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, + createRepository, formatClientConfig, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; @@ -71,19 +68,7 @@ export async function main(args: string[] = process.argv) { return; } - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const result = await repository.setSubscriptionStates( argv["client-id"], diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts index 3f0dcf58..d0bb393b 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -1,14 +1,11 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { buildTarget } from "src/domain/client-subscription-builder"; import { clientIdOption, commonOptions, + createRepository, formatClientConfig, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; @@ -60,19 +57,7 @@ export async function main(args: string[] = process.argv) { rateLimit: argv["rate-limit"], }); - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - const repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const result = await repository.addTarget( argv["client-id"], diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts index 19a413fa..d0bf807e 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -1,13 +1,10 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, + createRepository, formatClientConfig, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; @@ -29,19 +26,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const result = await repository.deleteTarget( argv["client-id"], diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts index a4b9d0d2..37b90856 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -1,13 +1,10 @@ import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; -import { createClientSubscriptionRepository } from "src/container"; import { clientIdOption, commonOptions, + createRepository, formatTargetsTable, - resolveBucketName, - resolveProfile, - resolveRegion, wrapCli, } from "src/entrypoint/cli/helper"; @@ -21,19 +18,7 @@ export const parseArgs = (args: string[]) => 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 repository = createClientSubscriptionRepository({ - bucketName, - region, - profile, - }); + const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); From 3dc9dcb461548e477a62f43bee829988c22a3fbc Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 16:48:43 +0000 Subject: [PATCH 08/29] fixup! Resolve duplicated code in cli args, remove project arg --- .../src/entrypoint/interactive/shared.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts index 97ca2db2..0a8a87a7 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts @@ -20,10 +20,7 @@ export interface ConnectionConfig { export type Repository = ReturnType; export const buildConnectionArgs = (connection: ConnectionConfig): string[] => { - const args: string[] = [ - "--bucket-name", - connection.bucketName, - ]; + 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; @@ -56,7 +53,6 @@ export async function promptConnection(): Promise { }); const profile = profileRaw.trim() || undefined; - const bucketNameRaw = await input({ message: "S3 bucket name (blank to derive from environment name):", default: "", From 9879c0daffe02fb1724d1a61991dd7646b598ab1 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 17:02:17 +0000 Subject: [PATCH 09/29] fixup! Refactor create repository code to reduce duplication --- .../src/__tests__/entrypoint/cli/helper.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 10b83e4d..33bdd73b 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 @@ -317,11 +317,14 @@ describe("createRepository", () => { }); it("creates a repository using provided bucket-name", async () => { - const repo = await createRepository({ "bucket-name": "test-bucket" }); + const repo = await createRepository({ + "bucket-name": "test-bucket", + region: "eu-west-2", + }); expect(createClientSubscriptionRepository).toHaveBeenCalledWith({ bucketName: "test-bucket", - region: undefined, + region: "eu-west-2", profile: undefined, }); expect(repo).toEqual({ listClientIds: expect.any(Function) }); From ab4d21dbdc4ada689de5810b96520281906aa5a8 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 18 Mar 2026 17:03:55 +0000 Subject: [PATCH 10/29] Simplify dry run in client put CLI command --- .../entrypoint/cli/clients-put.test.ts | 13 +++++++++++-- .../src/entrypoint/cli/clients-put.ts | 17 +++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) 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 index acff50e0..4a24adec 100644 --- 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 @@ -170,7 +170,9 @@ describe("clients-put CLI", () => { expect(mockPutClientConfig).toHaveBeenCalledTimes(1); }); - it("prints dry-run output without writing", async () => { + it("prints dry-run output and does not log success message", async () => { + mockPutClientConfig.mockResolvedValue(validConfig); + await cli.main([ "node", "script", @@ -184,8 +186,15 @@ describe("clients-put CLI", () => { "true", ]); - expect(mockPutClientConfig).not.toHaveBeenCalled(); + 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 runCli", async () => { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index f05be4f0..e8b3f532 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -83,17 +83,22 @@ export async function main(args: string[] = process.argv) { 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(config, null, 2)); + console.log(JSON.stringify(result, null, 2)); return; } - const repository = await createRepository(argv); - - await repository.putClientConfig(argv["client-id"], config, false); - console.log(`Config written for client: ${argv["client-id"]}`); - if (argv["terraform-apply"]) { await runTerraformApply({ environment: argv.environment, From 30be86a9057b6773c8acc2789fe077a6437ea248 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 09:36:41 +0000 Subject: [PATCH 11/29] Use statement_id_prefix to fix terraform deployment issues with mock lambda --- .../callbacks/module_mock_webhook_lambda.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 = "*" } From 170cb6c94ad127fd5bbe9df9297c72a6bab276c0 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 10:08:32 +0000 Subject: [PATCH 12/29] Refactor config validation to shared package for re-use --- .../validators/config-validator.test.ts | 53 ++++--- .../services/validators/config-validator.ts | 105 +----------- src/models/jest.config.ts | 14 ++ src/models/package.json | 8 +- .../__tests__/client-config-schema.test.ts | 150 ++++++++++++++++++ src/models/src/client-config-schema.ts | 117 ++++++++++++++ src/models/src/index.ts | 4 + 7 files changed, 324 insertions(+), 127 deletions(-) create mode 100644 src/models/jest.config.ts create mode 100644 src/models/src/__tests__/client-config-schema.test.ts create mode 100644 src/models/src/client-config-schema.ts 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 677df851..7e1417e1 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 @@ -43,36 +43,43 @@ describe("validateClientConfig", () => { expect(validateClientConfig(config)).toEqual(config); }); - it("throws when config is not an object", () => { - expect(() => validateClientConfig([])).toThrow(ConfigValidationError); - }); - - it("throws when invocation endpoint is not https", () => { - const config = createValidConfig(); - config.targets[0].invocationEndpoint = "http://example.com"; - - expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); - }); - - it("throws when subscription IDs are not unique", () => { + it("throws ConfigValidationError with formatted issues when schema parsing fails", () => { const config = createValidConfig(); - config.subscriptions[1].subscriptionId = - config.subscriptions[0].subscriptionId; + 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 invocationEndpoint is not a valid URL", () => { + it("preserves all schema issues on the thrown error", () => { const config = createValidConfig(); - config.targets[0].invocationEndpoint = "not-a-url"; + 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 a subscription references an unknown targetId", () => { - const config = createValidConfig(); - config.subscriptions[0].targetIds = ["unknown-target-id"]; + 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/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index 61e7fd68..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,105 +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({ - 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, -]); - -const configSchema = 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 [sIdx, subscription] of config.subscriptions.entries()) { - for (const [tIdx, targetId] of subscription.targetIds.entries()) { - if (!validTargetIds.has(targetId)) { - ctx.addIssue({ - code: "custom", - message: `targetId "${targetId}" not found in targets`, - path: ["subscriptions", sIdx, "targetIds", tIdx], - }); - } - } - } - }); - 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/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/index.ts b/src/models/src/index.ts index dab7c233..de08f07d 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -18,6 +18,10 @@ export type { MessageStatusSubscriptionConfiguration, SubscriptionConfiguration, } from "./client-config"; +export { + clientSubscriptionConfigurationSchema, + parseClientSubscriptionConfiguration, +} from "./client-config-schema"; export type { MessageStatusData } from "./message-status-data"; export type { RoutingPlan } from "./routing-plan"; export { EventTypes } from "./status-publish-event"; From 3b380d219930338ae1239006edad3cb746e3eb66 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 11:00:27 +0000 Subject: [PATCH 13/29] Use shared config validator --- .../domain/client-config-validator.test.ts | 47 +++++++ .../entrypoint/cli/validate-config.test.ts | 106 ---------------- .../repository/client-subscriptions.test.ts | 69 ++++++++-- .../src/domain/client-config-validator.ts | 21 +++ .../src/entrypoint/cli/validate-config.ts | 120 ------------------ .../src/repository/client-subscriptions.ts | 26 +++- 6 files changed, 148 insertions(+), 241 deletions(-) create mode 100644 tools/client-subscriptions-management/src/__tests__/domain/client-config-validator.test.ts delete mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts create mode 100644 tools/client-subscriptions-management/src/domain/client-config-validator.ts delete mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts 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..71d8a05d --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/domain/client-config-validator.test.ts @@ -0,0 +1,47 @@ +import { validateClientConfig } from "src/domain/client-config-validator"; + +const TARGET_ID = "00000000-0000-4000-8000-000000000001"; + +const createValidConfig = () => ({ + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus", + messageStatuses: ["DELIVERED"], + targetIds: [TARGET_ID], + }, + ], + targets: [ + { + targetId: TARGET_ID, + type: "API", + invocationEndpoint: "https://example.com/webhook", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + }, + ], +}); + +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__/entrypoint/cli/validate-config.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts deleted file mode 100644 index c3216833..00000000 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/validate-config.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { validateClientConfig } from "src/entrypoint/cli/validate-config"; - -const TARGET_ID = "00000000-0000-4000-8000-000000000001"; - -const createValidConfig = () => ({ - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: [TARGET_ID], - }, - ], - targets: [ - { - targetId: TARGET_ID, - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); - -describe("validateClientConfig", () => { - it("returns the config when valid", () => { - const config = createValidConfig(); - const result = validateClientConfig(config); - expect(result).toEqual(config); - }); - - it("throws when input is not an object", () => { - expect(() => validateClientConfig([])).toThrow(/Config validation failed/); - }); - - it("throws when clientId is missing", () => { - const config = { ...createValidConfig(), clientId: "" }; - expect(() => validateClientConfig(config)).toThrow( - /Config validation failed/, - ); - }); - - it("throws when invocation endpoint is not https", () => { - const config = createValidConfig(); - config.targets[0].invocationEndpoint = "http://example.com"; - expect(() => validateClientConfig(config)).toThrow( - /Config validation failed/, - ); - }); - - it("throws when subscriptionIds are not unique", () => { - const config = createValidConfig(); - config.subscriptions.push({ - ...config.subscriptions[0], - }); - expect(() => validateClientConfig(config)).toThrow( - /Config validation failed/, - ); - }); - - it("throws when a subscription references an unknown targetId", () => { - const config = createValidConfig(); - config.subscriptions[0].targetIds = ["unknown-target-id"]; - expect(() => validateClientConfig(config)).toThrow( - /Config validation failed/, - ); - }); - - it("throws when targetIds are not unique", () => { - const config = createValidConfig(); - config.targets.push({ ...config.targets[0] }); - expect(() => validateClientConfig(config)).toThrow( - /Config validation failed/, - ); - }); - - it("validates a ChannelStatus subscription correctly", () => { - const config = { - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "sub-001", - subscriptionType: "ChannelStatus", - channelType: "EMAIL", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["read"], - targetIds: [TARGET_ID], - }, - ], - targets: [ - { - targetId: TARGET_ID, - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], - }; - - expect(validateClientConfig(config)).toEqual(config); - }); -}); 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 5ef01968..d6c18072 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 @@ -103,6 +103,21 @@ describe("ClientSubscriptionRepository", () => { repository.getClientConfig("client-1"), ).resolves.toBeUndefined(); }); + + it("throws when stored config is invalid", async () => { + const getObject = jest.fn().mockResolvedValue( + JSON.stringify({ + clientId: "client-1", + subscriptions: [messageSubscription], + targets: [], + }), + ); + const { repository } = createRepository({ getObject }); + + await expect(repository.getClientConfig("client-1")).rejects.toThrow( + /Config validation failed/, + ); + }); }); describe("putClientConfig", () => { @@ -119,9 +134,10 @@ describe("ClientSubscriptionRepository", () => { expect(result).toEqual(config); expect(putRawData).toHaveBeenCalledWith( - JSON.stringify(config), + expect.any(String), "client_subscriptions/client-1.json", ); + expect(JSON.parse(putRawData.mock.calls[0][0] as string)).toEqual(config); }); it("skips S3 write on dry run", async () => { @@ -133,6 +149,22 @@ describe("ClientSubscriptionRepository", () => { expect(putRawData).not.toHaveBeenCalled(); }); + + it("throws when config is invalid and does not write to S3", async () => { + const putRawData = jest.fn(); + const invalidConfig = { + clientId: "client-1", + subscriptions: [messageSubscription], + targets: [], + } as unknown as ClientSubscriptionConfiguration; + const { repository } = createRepository({ putRawData }); + + await expect( + repository.putClientConfig("client-1", invalidConfig, false), + ).rejects.toThrow(/Config validation failed/); + + expect(putRawData).not.toHaveBeenCalled(); + }); }); describe("addSubscription", () => { @@ -157,20 +189,16 @@ describe("ClientSubscriptionRepository", () => { expect(putRawData).toHaveBeenCalledTimes(1); }); - it("creates new config when none exists", async () => { + it("throws when resulting config would be invalid", async () => { const getObject = jest.fn().mockResolvedValue(undefined); const putRawData = jest.fn(); const { repository } = createRepository({ getObject, putRawData }); - const result = await repository.addSubscription( - "client-1", - messageSubscription, - false, - ); + await expect( + repository.addSubscription("client-1", messageSubscription, false), + ).rejects.toThrow(/Config validation failed/); - expect(result.clientId).toBe("client-1"); - expect(result.subscriptions).toEqual([messageSubscription]); - expect(result.targets).toEqual([]); + expect(putRawData).not.toHaveBeenCalled(); }); }); @@ -261,8 +289,12 @@ describe("ClientSubscriptionRepository", () => { }); describe("deleteTarget", () => { - it("removes target by ID", async () => { - const config = baseConfig(); + it("removes target by ID when it is not referenced", async () => { + const config: ClientSubscriptionConfiguration = { + clientId: "client-1", + subscriptions: [], + targets: [baseTarget], + }; const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); const putRawData = jest.fn(); const { repository } = createRepository({ getObject, putRawData }); @@ -276,6 +308,19 @@ describe("ClientSubscriptionRepository", () => { expect(result.targets).toHaveLength(0); }); + it("throws when removing a referenced target would invalidate the config", async () => { + const config = baseConfig(); + const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); + const putRawData = jest.fn(); + const { repository } = createRepository({ getObject, putRawData }); + + await expect( + repository.deleteTarget("client-1", TARGET_ID, false), + ).rejects.toThrow(/Config validation failed/); + + expect(putRawData).not.toHaveBeenCalled(); + }); + it("throws when config not found", async () => { const getObject = jest.fn().mockResolvedValue(undefined); const { repository } = createRepository({ getObject }); 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/entrypoint/cli/validate-config.ts b/tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts deleted file mode 100644 index eb9f6272..00000000 --- a/tools/client-subscriptions-management/src/entrypoint/cli/validate-config.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { z } from "zod"; -import type { - CallbackTarget, - ChannelStatusSubscriptionConfiguration, - ClientSubscriptionConfiguration, - MessageStatusSubscriptionConfiguration, - SubscriptionConfiguration, -} from "@nhs-notify-client-callbacks/models"; -import { - CHANNEL_STATUSES, - CHANNEL_TYPES, - MESSAGE_STATUSES, - SUPPLIER_STATUSES, -} from "@nhs-notify-client-callbacks/models"; - -const httpsUrlSchema = z.string().refine( - (value) => { - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } - }, - { message: "Expected HTTPS URL" }, -); - -const targetSchema = z.object({ - targetId: z.string().min(1), - type: z.literal("API"), - invocationEndpoint: httpsUrlSchema, - invocationMethod: z.literal("POST"), - invocationRateLimit: z.number().positive(), - apiKey: z.object({ - headerName: z.string().min(1), - headerValue: z.string().min(1), - }), -}) satisfies z.ZodType; - -const baseSubscriptionSchema = z.object({ - subscriptionId: z.string().min(1), - targetIds: z.array(z.string().min(1)).min(1), -}); - -const messageStatusSchema = baseSubscriptionSchema.extend({ - subscriptionType: z.literal("MessageStatus"), - messageStatuses: z.array(z.enum(MESSAGE_STATUSES)).min(1), -}) satisfies z.ZodType; - -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)), -}) satisfies z.ZodType; - -const subscriptionSchema = z.discriminatedUnion("subscriptionType", [ - messageStatusSchema, - channelStatusSchema, -]) satisfies z.ZodType; - -const configSchema = z - .object({ - clientId: z.string().min(1), - subscriptions: z.array(subscriptionSchema), - targets: z.array(targetSchema), - }) - .superRefine((config, ctx) => { - const seenSubIds = new Set(); - for (const [i, sub] of config.subscriptions.entries()) { - if (seenSubIds.has(sub.subscriptionId)) { - ctx.addIssue({ - code: "custom", - message: "Expected subscriptionId to be unique", - path: ["subscriptions", i, "subscriptionId"], - }); - } else { - seenSubIds.add(sub.subscriptionId); - } - } - - const validTargetIds = new Set(); - for (const [i, target] of config.targets.entries()) { - if (validTargetIds.has(target.targetId)) { - ctx.addIssue({ - code: "custom", - message: "Expected targetId to be unique", - path: ["targets", i, "targetId"], - }); - } else { - validTargetIds.add(target.targetId); - } - } - - for (const [sIdx, sub] of config.subscriptions.entries()) { - for (const [tIdx, targetId] of sub.targetIds.entries()) { - if (!validTargetIds.has(targetId)) { - ctx.addIssue({ - code: "custom", - message: `targetId "${targetId}" not found in targets`, - path: ["subscriptions", sIdx, "targetIds", tIdx], - }); - } - } - } - }) satisfies z.ZodType; - -export const validateClientConfig = ( - rawConfig: unknown, -): ClientSubscriptionConfiguration => { - const result = configSchema.safeParse(rawConfig); - if (!result.success) { - const messages = result.error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("\n"); - throw new Error(`Config validation failed:\n${messages}`); - } - return result.data; -}; - -export default validateClientConfig; diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 404b06a6..6999a625 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -3,10 +3,28 @@ import { type ClientSubscriptionConfiguration, type SubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; +import { validateClientConfig } from "src/domain/client-config-validator"; import { S3Repository } from "src/repository/s3"; const CLIENT_SUBSCRIPTIONS_PREFIX = "client_subscriptions/"; +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)}`, + ); + } + + return validateClientConfig(parsedConfig); +}; + export class ClientSubscriptionRepository { constructor(private readonly s3Repository: S3Repository) {} @@ -29,7 +47,7 @@ export class ClientSubscriptionRepository { ); if (rawFile !== undefined) { - return JSON.parse(rawFile) as ClientSubscriptionConfiguration; + return parseStoredConfig(clientId, rawFile); } return undefined; } @@ -39,13 +57,15 @@ export class ClientSubscriptionRepository { config: ClientSubscriptionConfiguration, dryRun: boolean, ): Promise { + const validatedConfig = validateClientConfig(config); + if (!dryRun) { await this.s3Repository.putRawData( - JSON.stringify(config), + JSON.stringify(validatedConfig), `${CLIENT_SUBSCRIPTIONS_PREFIX}${clientId}.json`, ); } - return config; + return validatedConfig; } async addSubscription( From d9f38e2d287a08854b7d8f7e9059575764a4a786 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 11:19:28 +0000 Subject: [PATCH 14/29] Feedback: refactor config-cache test --- .../__tests__/services/config-cache.test.ts | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) 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 23a47b5e..e74a384c 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,21 +1,23 @@ import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; import { ConfigCache } from "services/config-cache"; +const createConfig = (): ClientSubscriptionConfiguration => ({ + clientId: "client-1", + subscriptions: [ + { + subscriptionId: "00000000-0000-0000-0000-000000000001", + subscriptionType: "MessageStatus" as const, + targetIds: [], + messageStatuses: ["DELIVERED"], + }, + ], + targets: [], +}); + describe("ConfigCache", () => { it("stores and retrieves configuration", () => { const cache = new ConfigCache(60_000); - const config: ClientSubscriptionConfiguration = { - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus" as const, - targetIds: [], - messageStatuses: ["DELIVERED"], - }, - ], - targets: [], - }; + const config = createConfig(); cache.set("client-1", config); @@ -34,20 +36,11 @@ describe("ConfigCache", () => { jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); const cache = new ConfigCache(1000); // 1 second TTL - const config: ClientSubscriptionConfiguration = { - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus" as const, - targetIds: [], - messageStatuses: ["DELIVERED"], - }, - ], - targets: [], - }; + const config = createConfig(); cache.set("client-1", config); + expect(cache.get("client-1")).toEqual(config); + jest.advanceTimersByTime(1001); const result = cache.get("client-1"); @@ -59,22 +52,14 @@ describe("ConfigCache", () => { it("clears all entries", () => { const cache = new ConfigCache(60_000); - const config: ClientSubscriptionConfiguration = { - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus" as const, - targetIds: [], - messageStatuses: ["DELIVERED"], - }, - ], - targets: [], - }; + 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(); From 3cc76f4a855b3a3e904948c24d71b34c3b1fe010 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 11:35:30 +0000 Subject: [PATCH 15/29] Ensure correlationId is used in filtering observability --- lambdas/client-transform-filter-lambda/src/handler.ts | 3 +++ .../src/services/observability.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 3247a333..a0e76978 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -215,6 +215,7 @@ 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); @@ -222,6 +223,7 @@ async function filterBatch( filtered.push(event); const targetIds = config?.targets?.map((t) => t.targetId); observability.recordFilteringMatched({ + correlationId, clientId, eventType: event.type, subscriptionType: filterResult.subscriptionType, @@ -232,6 +234,7 @@ async function filterBatch( 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/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; From 14a11190edae1cadc5646accf3abb073aff9d464 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 11:53:11 +0000 Subject: [PATCH 16/29] Feedback: validate target not used in subscription when deleting it --- .../__tests__/repository/client-subscriptions.test.ts | 4 +++- .../src/repository/client-subscriptions.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 d6c18072..cb482993 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 @@ -316,7 +316,9 @@ describe("ClientSubscriptionRepository", () => { await expect( repository.deleteTarget("client-1", TARGET_ID, false), - ).rejects.toThrow(/Config validation failed/); + ).rejects.toThrow( + `Cannot delete target ${TARGET_ID}: still referenced by subscriptions sub-001, sub-002`, + ); expect(putRawData).not.toHaveBeenCalled(); }); diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 6999a625..4822d6d6 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -164,6 +164,17 @@ export class ClientSubscriptionRepository { if (!config) { throw new Error(`No configuration found for client: ${clientId}`); } + + const referencingSubscriptionIds = config.subscriptions + .filter((subscription) => subscription.targetIds.includes(targetId)) + .map((subscription) => subscription.subscriptionId); + + if (referencingSubscriptionIds.length > 0) { + throw new Error( + `Cannot delete target ${targetId}: still referenced by subscriptions ${referencingSubscriptionIds.join(", ")}`, + ); + } + const updated: ClientSubscriptionConfiguration = { ...config, targets: config.targets.filter((t) => t.targetId !== targetId), From 276236c7c35dcc266a9df49e41dda9f3b08edf54 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 11:53:47 +0000 Subject: [PATCH 17/29] Feedback: better type safety in setSubscriptionStates --- .../src/repository/client-subscriptions.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts index 4822d6d6..8cac33fe 100644 --- a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -1,7 +1,10 @@ import { type CallbackTarget, + type ChannelStatus, type ClientSubscriptionConfiguration, + type MessageStatus, type SubscriptionConfiguration, + type SupplierStatus, } from "@nhs-notify-client-callbacks/models"; import { validateClientConfig } from "src/domain/client-config-validator"; import { S3Repository } from "src/repository/s3"; @@ -104,9 +107,9 @@ export class ClientSubscriptionRepository { clientId: string, subscriptionId: string, states: { - messageStatuses?: string[]; - channelStatuses?: string[]; - supplierStatuses?: string[]; + messageStatuses?: MessageStatus[]; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; }, dryRun: boolean, ): Promise { @@ -122,16 +125,16 @@ export class ClientSubscriptionRepository { sub.subscriptionType === "MessageStatus" && states.messageStatuses ) { - return { ...sub, messageStatuses: states.messageStatuses as any }; + return { ...sub, messageStatuses: states.messageStatuses }; } if (sub.subscriptionType === "ChannelStatus") { return { ...sub, ...(states.channelStatuses && { - channelStatuses: states.channelStatuses as any, + channelStatuses: states.channelStatuses, }), ...(states.supplierStatuses && { - supplierStatuses: states.supplierStatuses as any, + supplierStatuses: states.supplierStatuses, }), }; } From 09c207552237a8732d4e25cbcd1fb8b181f72bd2 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 12:08:34 +0000 Subject: [PATCH 18/29] Fix client subscription fixture in int test --- tests/integration/jest.global-setup.ts | 72 +++++++++++--------------- 1 file changed, 31 insertions(+), 41 deletions(-) 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(); From 4ed3d28c339de60a133d870ecb2feffffd51cd30 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:14:42 +0000 Subject: [PATCH 19/29] Set isolatedModules to speed up test startup/transpile time --- tsconfig.base.json | 3 +++ 1 file changed, 3 insertions(+) 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" } From e9e2d4bacba198f767e6d29c5ae1b6686d9d7a53 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:19:33 +0000 Subject: [PATCH 20/29] Fix env var issue in helper test --- .../src/__tests__/entrypoint/cli/helper.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 33bdd73b..50705da0 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 @@ -309,13 +309,23 @@ describe("createRepository", () => { createClientSubscriptionRepository: jest.Mock; }; + const originalEnv = process.env; + beforeEach(() => { + process.env = { + ...originalEnv, + AWS_PROFILE: "some-environment", + } as NodeJS.ProcessEnv; createClientSubscriptionRepository.mockReset(); createClientSubscriptionRepository.mockReturnValue({ listClientIds: jest.fn(), }); }); + afterEach(() => { + process.env = originalEnv; + }); + it("creates a repository using provided bucket-name", async () => { const repo = await createRepository({ "bucket-name": "test-bucket", @@ -325,7 +335,7 @@ describe("createRepository", () => { expect(createClientSubscriptionRepository).toHaveBeenCalledWith({ bucketName: "test-bucket", region: "eu-west-2", - profile: undefined, + profile: "some-environment", }); expect(repo).toEqual({ listClientIds: expect.any(Function) }); }); From 46971dfebff7d439a4b886e28b3bb497cb9cb1e1 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:31:45 +0000 Subject: [PATCH 21/29] Silent mode on tests to suppress logging noise --- jest.config.base.ts | 1 + tools/client-subscriptions-management/jest.config.ts | 1 + 2 files changed, 2 insertions(+) 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/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", From 76d2effeb33b15401ed4e0b6760c82cde543841e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:49:05 +0000 Subject: [PATCH 22/29] Refactor test fixture creation to reduce repeat --- .../domain/client-config-validator.test.ts | 25 +---- .../entrypoint/cli/clients-get.test.ts | 7 +- .../entrypoint/cli/clients-put.test.ts | 7 +- .../__tests__/entrypoint/cli/helper.test.ts | 56 +++------- .../entrypoint/cli/subscriptions-add.test.ts | 3 +- .../entrypoint/cli/subscriptions-del.test.ts | 3 +- .../entrypoint/cli/subscriptions-list.test.ts | 17 ++- .../cli/subscriptions-set-states.test.ts | 3 +- .../entrypoint/cli/targets-add.test.ts | 21 ++-- .../entrypoint/cli/targets-del.test.ts | 3 +- .../entrypoint/cli/targets-list.test.ts | 27 ++--- .../interactive/subscriptions.test.ts | 101 +++++++++--------- .../entrypoint/interactive/targets.test.ts | 46 +++++--- .../helpers/client-subscription-fixtures.ts | 71 ++++++++++++ .../repository/client-subscriptions.test.ts | 81 +++++--------- 15 files changed, 232 insertions(+), 239 deletions(-) create mode 100644 tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts 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 index 71d8a05d..7df07252 100644 --- 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 @@ -1,28 +1,7 @@ import { validateClientConfig } from "src/domain/client-config-validator"; +import { createPopulatedClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const TARGET_ID = "00000000-0000-4000-8000-000000000001"; - -const createValidConfig = () => ({ - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: [TARGET_ID], - }, - ], - targets: [ - { - targetId: TARGET_ID, - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); +const createValidConfig = () => createPopulatedClientSubscriptionConfig(); describe("validateClientConfig", () => { it("returns the config unchanged when parsing succeeds", () => { 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 index 44580157..a8d12a10 100644 --- 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 @@ -9,12 +9,9 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/clients-get"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const validConfig = { - clientId: "client-1", - subscriptions: [], - targets: [], -}; +const validConfig = createClientSubscriptionConfig(); describe("clients-get CLI", () => { const originalLog = console.log; 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 index 4a24adec..d3e4f042 100644 --- 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 @@ -19,12 +19,9 @@ jest.mock("node:fs", () => ({ })); import * as cli from "src/entrypoint/cli/clients-put"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const validConfig = { - clientId: "client-1", - subscriptions: [], - targets: [], -}; +const validConfig = createClientSubscriptionConfig(); describe("clients-put CLI", () => { const originalLog = console.log; 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 50705da0..b2b1f98a 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,9 +1,3 @@ -import type { - CallbackTarget, - ChannelStatusSubscriptionConfiguration, - ClientSubscriptionConfiguration, - MessageStatusSubscriptionConfiguration, -} from "@nhs-notify-client-callbacks/models"; import { createRepository, deriveBucketName, @@ -16,6 +10,13 @@ import { resolveRegion, runTerraformApply, } from "src/entrypoint/cli/helper"; +import { + DEFAULT_TARGET_ID as TARGET_ID, + createChannelStatusSubscription, + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; jest.mock("src/container", () => ({ createClientSubscriptionRepository: jest.fn(), @@ -32,39 +33,16 @@ jest.mock("@aws-sdk/client-sts", () => ({ GetCallerIdentityCommand: jest.fn(), })); -const TARGET_ID = "00000000-0000-4000-8000-000000000001"; - describe("cli helper", () => { - const target: CallbackTarget = { - targetId: TARGET_ID, - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }; + const target = createTarget(); + const messageSubscription = createMessageStatusSubscription(); + const channelSubscription = createChannelStatusSubscription(); - const messageSubscription: MessageStatusSubscriptionConfiguration = { - subscriptionId: "sub-001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: [TARGET_ID], - }; - - const channelSubscription: ChannelStatusSubscriptionConfiguration = { - subscriptionId: "sub-002", - subscriptionType: "ChannelStatus", - channelType: "SMS", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["delivered"], - targetIds: [TARGET_ID], - }; - - const config: ClientSubscriptionConfiguration = { + const config = createClientSubscriptionConfig({ clientId: "client-a", subscriptions: [messageSubscription, channelSubscription], targets: [target], - }; + }); it("formats subscriptions as a table string", () => { const result = formatSubscriptionsTable(config.subscriptions); @@ -96,21 +74,19 @@ describe("cli helper", () => { }); it("shows (none) when subscriptions is empty", () => { - const empty: ClientSubscriptionConfiguration = { + const empty = createClientSubscriptionConfig({ clientId: "empty-client", - subscriptions: [], targets: [target], - }; + }); expect(formatClientConfig(empty)).toContain("Subscriptions: (none)"); }); it("shows (none) when targets is empty", () => { - const empty: ClientSubscriptionConfiguration = { + const empty = createClientSubscriptionConfig({ clientId: "empty-client", subscriptions: [messageSubscription], - targets: [], - }; + }); expect(formatClientConfig(empty)).toContain("Targets: (none)"); }); 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 index b439cbfe..5bf7df7e 100644 --- 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 @@ -18,8 +18,9 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/subscriptions-add"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; +const resultConfig = createClientSubscriptionConfig(); describe("subscriptions-add CLI", () => { const originalLog = console.log; 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 index 53fe0875..b8953870 100644 --- 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 @@ -11,8 +11,9 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/subscriptions-del"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; +const resultConfig = createClientSubscriptionConfig(); describe("subscriptions-del CLI", () => { const originalLog = console.log; 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 index ff7847c0..4de019a5 100644 --- 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 @@ -11,19 +11,18 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/subscriptions-list"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, +} from "src/__tests__/helpers/client-subscription-fixtures"; -const validConfig = { - clientId: "client-1", +const validConfig = createClientSubscriptionConfig({ subscriptions: [ - { - subscriptionId: "sub-001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], + createMessageStatusSubscription({ targetIds: ["target-001"], - }, + }), ], - targets: [], -}; +}); describe("subscriptions-list CLI", () => { const originalLog = console.log; 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 index 2fd12bf8..2be4f04f 100644 --- 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 @@ -11,8 +11,9 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/subscriptions-set-states"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; +const resultConfig = createClientSubscriptionConfig(); describe("subscriptions-set-states CLI", () => { const originalLog = console.log; 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 index 588fa36a..ea8aa0ee 100644 --- 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 @@ -16,21 +16,16 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/targets-add"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; -const builtTarget = { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, -}; - -const resultConfig = { - clientId: "client-1", - subscriptions: [], +const builtTarget = createTarget(); + +const resultConfig = createClientSubscriptionConfig({ targets: [builtTarget], -}; +}); describe("targets-add CLI", () => { const originalLog = console.log; 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 index 01c6fd4d..d5529784 100644 --- 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 @@ -11,8 +11,9 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/targets-del"; +import { createClientSubscriptionConfig } from "src/__tests__/helpers/client-subscription-fixtures"; -const resultConfig = { clientId: "client-1", subscriptions: [], targets: [] }; +const resultConfig = createClientSubscriptionConfig(); describe("targets-del CLI", () => { const originalLog = console.log; 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 index c0dfb403..f6997acb 100644 --- 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 @@ -11,15 +11,12 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/targets-list"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; -const target = { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, -}; +const target = createTarget(); describe("targets-list CLI", () => { const originalLog = console.log; @@ -46,11 +43,9 @@ describe("targets-list CLI", () => { }); it("prints targets table when config has targets", async () => { - mockGetClientConfig.mockResolvedValue({ - clientId: "client-1", - subscriptions: [], - targets: [target], - }); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ targets: [target] }), + ); await cli.main([ "node", @@ -83,11 +78,7 @@ describe("targets-list CLI", () => { }); it("prints message when targets is empty", async () => { - mockGetClientConfig.mockResolvedValue({ - clientId: "client-1", - subscriptions: [], - targets: [], - }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); await cli.main([ "node", 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 index b292700b..5f3e97a7 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts @@ -55,6 +55,11 @@ import { interactiveSubscriptionsList, interactiveSubscriptionsSetStates, } from "src/entrypoint/interactive/subscriptions"; +import { + createClientSubscriptionConfig, + createMessageStatusSubscription, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; const connection = { bucketName: "bucket", project: "nhs" }; @@ -72,23 +77,24 @@ describe("interactiveSubscriptionsAdd", () => { mockSelect.mockReset(); mockCheckbox.mockReset(); mockInput.mockReset(); - mockGetClientConfig.mockResolvedValue({ subscriptions: [], targets: [] }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); }); it("assembles MessageStatus subscription args and calls subscriptionsAdd main", async () => { - mockSelect.mockResolvedValueOnce("MessageStatus"); // subscription type + mockSelect.mockResolvedValueOnce("MessageStatus"); mockCheckbox - .mockResolvedValueOnce(["DELIVERED"]) // message statuses - .mockResolvedValueOnce(["target-001"]); // target IDs (checkbox from targets) - mockGetClientConfig.mockResolvedValue({ - subscriptions: [], - targets: [ - { - targetId: "target-001", - invocationEndpoint: "https://example.com", - }, - ], - }); + .mockResolvedValueOnce(["DELIVERED"]) + .mockResolvedValueOnce(["target-001"]); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); await interactiveSubscriptionsAdd(connection); @@ -106,21 +112,22 @@ describe("interactiveSubscriptionsAdd", () => { it("assembles ChannelStatus subscription args", async () => { mockSelect - .mockResolvedValueOnce("ChannelStatus") // subscription type - .mockResolvedValueOnce("SMS"); // channel type + .mockResolvedValueOnce("ChannelStatus") + .mockResolvedValueOnce("SMS"); mockCheckbox - .mockResolvedValueOnce(["DELIVERED"]) // channel statuses - .mockResolvedValueOnce([]) // supplier statuses - .mockResolvedValueOnce(["target-001"]); // target IDs - mockGetClientConfig.mockResolvedValue({ - subscriptions: [], - targets: [ - { - targetId: "target-001", - invocationEndpoint: "https://example.com", - }, - ], - }); + .mockResolvedValueOnce(["DELIVERED"]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(["target-001"]); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); await interactiveSubscriptionsAdd(connection); @@ -140,10 +147,7 @@ describe("interactiveSubscriptionsAdd", () => { mockSelect.mockResolvedValueOnce("MessageStatus"); mockCheckbox.mockResolvedValueOnce(["DELIVERED"]); mockInput.mockResolvedValueOnce("target-x, target-y"); - mockGetClientConfig.mockResolvedValue({ - subscriptions: [], - targets: [], - }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); await interactiveSubscriptionsAdd(connection); @@ -162,16 +166,17 @@ describe("interactiveSubscriptionsDel", () => { beforeEach(() => { mockSelect.mockReset(); mockInput.mockReset(); - mockGetClientConfig.mockResolvedValue({ subscriptions: [], targets: [] }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); }); it("calls subscriptionsDel main with selected subscription ID", async () => { - mockGetClientConfig.mockResolvedValue({ - subscriptions: [ - { subscriptionId: "sub-001", subscriptionType: "MessageStatus" }, - ], - targets: [], - }); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ subscriptionId: "sub-001" }), + ], + }), + ); mockSelect.mockResolvedValue("sub-001"); await interactiveSubscriptionsDel(connection); @@ -200,16 +205,16 @@ describe("interactiveSubscriptionsSetStates", () => { }); it("calls subscriptionsSetStates main for MessageStatus subscription", async () => { - mockGetClientConfig.mockResolvedValue({ - subscriptions: [ - { - subscriptionId: "sub-001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - }, - ], - targets: [], - }); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + subscriptions: [ + createMessageStatusSubscription({ + subscriptionId: "sub-001", + messageStatuses: ["DELIVERED"], + }), + ], + }), + ); mockSelect.mockResolvedValue("sub-001"); mockCheckbox.mockResolvedValue(["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 index 023b3a00..05bf0508 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts @@ -51,6 +51,10 @@ import { 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" }; @@ -67,14 +71,14 @@ describe("interactiveTargetsAdd", () => { beforeEach(() => { mockInput.mockReset(); mockPassword.mockReset(); - mockGetClientConfig.mockResolvedValue({ subscriptions: [], targets: [] }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); }); it("assembles target args and calls targetsAdd main", async () => { mockInput - .mockResolvedValueOnce("https://example.com/hook") // endpoint - .mockResolvedValueOnce("x-api-key") // header name - .mockResolvedValueOnce("10"); // rate limit + .mockResolvedValueOnce("https://example.com/hook") + .mockResolvedValueOnce("x-api-key") + .mockResolvedValueOnce("10"); mockPassword.mockResolvedValue("secret-key"); await interactiveTargetsAdd(connection); @@ -115,16 +119,20 @@ describe("interactiveTargetsDel", () => { beforeEach(() => { mockSelect.mockReset(); mockInput.mockReset(); - mockGetClientConfig.mockResolvedValue({ subscriptions: [], targets: [] }); + mockGetClientConfig.mockResolvedValue(createClientSubscriptionConfig()); }); it("calls targetsDel main with selected target ID from list", async () => { - mockGetClientConfig.mockResolvedValue({ - subscriptions: [], - targets: [ - { targetId: "target-001", invocationEndpoint: "https://example.com" }, - ], - }); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); mockSelect.mockResolvedValue("target-001"); await interactiveTargetsDel(connection); @@ -145,12 +153,16 @@ describe("interactiveTargetsDel", () => { }); it("prompts for manual input when user selects __manual__", async () => { - mockGetClientConfig.mockResolvedValue({ - subscriptions: [], - targets: [ - { targetId: "target-001", invocationEndpoint: "https://example.com" }, - ], - }); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + targetId: "target-001", + invocationEndpoint: "https://example.com", + }), + ], + }), + ); mockSelect.mockResolvedValue("__manual__"); mockInput.mockResolvedValue("custom-target-id"); 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 cb482993..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,13 +1,17 @@ import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; import type { - CallbackTarget, - ChannelStatusSubscriptionConfiguration, ClientSubscriptionConfiguration, MessageStatusSubscriptionConfiguration, } from "@nhs-notify-client-callbacks/models"; import type { S3Repository } from "src/repository/s3"; - -const TARGET_ID = "00000000-0000-4000-8000-000000000001"; +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; @@ -26,38 +30,12 @@ const createRepository = (overrides?: { }; }; -const baseTarget: CallbackTarget = { - targetId: TARGET_ID, - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, -}; - -const messageSubscription: MessageStatusSubscriptionConfiguration = { - subscriptionId: "sub-001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: [TARGET_ID], -}; - -const channelSubscription: ChannelStatusSubscriptionConfiguration = { - subscriptionId: "sub-002", - subscriptionType: "ChannelStatus", - channelType: "SMS", - channelStatuses: ["DELIVERED"], - supplierStatuses: ["delivered"], - targetIds: [TARGET_ID], -}; +const baseTarget = createTarget(); +const messageSubscription = createMessageStatusSubscription(); +const channelSubscription = createChannelStatusSubscription(); -const baseConfig = ( - clientId = "client-1", -): ClientSubscriptionConfiguration => ({ - clientId, - subscriptions: [messageSubscription, channelSubscription], - targets: [baseTarget], -}); +const baseConfig = (clientId = "client-1"): ClientSubscriptionConfiguration => + createPopulatedClientSubscriptionConfig(clientId); describe("ClientSubscriptionRepository", () => { describe("listClientIds", () => { @@ -106,11 +84,11 @@ describe("ClientSubscriptionRepository", () => { it("throws when stored config is invalid", async () => { const getObject = jest.fn().mockResolvedValue( - JSON.stringify({ - clientId: "client-1", - subscriptions: [messageSubscription], - targets: [], - }), + JSON.stringify( + createClientSubscriptionConfig({ + subscriptions: [messageSubscription], + }), + ), ); const { repository } = createRepository({ getObject }); @@ -152,11 +130,9 @@ describe("ClientSubscriptionRepository", () => { it("throws when config is invalid and does not write to S3", async () => { const putRawData = jest.fn(); - const invalidConfig = { - clientId: "client-1", + const invalidConfig = createClientSubscriptionConfig({ subscriptions: [messageSubscription], - targets: [], - } as unknown as ClientSubscriptionConfiguration; + }) as unknown as ClientSubscriptionConfiguration; const { repository } = createRepository({ putRawData }); await expect( @@ -169,11 +145,10 @@ describe("ClientSubscriptionRepository", () => { describe("addSubscription", () => { it("appends subscription to existing config", async () => { - const existing: ClientSubscriptionConfiguration = { - clientId: "client-1", + const existing = createClientSubscriptionConfig({ subscriptions: [messageSubscription], targets: [baseTarget], - }; + }); const getObject = jest.fn().mockResolvedValue(JSON.stringify(existing)); const putRawData = jest.fn(); const { repository } = createRepository({ getObject, putRawData }); @@ -261,11 +236,7 @@ describe("ClientSubscriptionRepository", () => { describe("addTarget", () => { it("appends target to existing config", async () => { - const existing: ClientSubscriptionConfiguration = { - clientId: "client-1", - subscriptions: [], - targets: [], - }; + const existing = createClientSubscriptionConfig(); const getObject = jest.fn().mockResolvedValue(JSON.stringify(existing)); const putRawData = jest.fn(); const { repository } = createRepository({ getObject, putRawData }); @@ -290,11 +261,7 @@ describe("ClientSubscriptionRepository", () => { describe("deleteTarget", () => { it("removes target by ID when it is not referenced", async () => { - const config: ClientSubscriptionConfiguration = { - clientId: "client-1", - subscriptions: [], - targets: [baseTarget], - }; + const config = createClientSubscriptionConfig({ targets: [baseTarget] }); const getObject = jest.fn().mockResolvedValue(JSON.stringify(config)); const putRawData = jest.fn(); const { repository } = createRepository({ getObject, putRawData }); From 24f1357723e41142184300af4b7990057cbbc82d Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:55:46 +0000 Subject: [PATCH 23/29] Refactor lambda test fixture creation to reduce repeat --- .../helpers/client-subscription-fixtures.ts | 94 +++++++++++++++++++ .../__tests__/services/config-cache.test.ts | 22 ++--- .../__tests__/services/config-loader.test.ts | 24 +---- .../services/config-update.component.test.ts | 24 +---- .../filters/channel-status-filter.test.ts | 32 +------ .../filters/message-status-filter.test.ts | 28 +----- .../services/subscription-filter.test.ts | 73 +++----------- .../validators/config-validator.test.ts | 45 +++------ 8 files changed, 137 insertions(+), 205 deletions(-) create mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts 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__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts index e74a384c..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,18 +1,16 @@ 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 => ({ - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus" as const, - targetIds: [], - messageStatuses: ["DELIVERED"], - }, - ], - targets: [], -}); +const createConfig = (): ClientSubscriptionConfiguration => + createClientSubscriptionConfig("client-1", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { targetIds: [] }), + ], + }); describe("ConfigCache", () => { it("stores and retrieves configuration", () => { 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 40faf1fb..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) => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); +const createValidConfig = (clientId: string) => + createMessageStatusConfig(["DELIVERED"], clientId); const createLoader = (send: jest.Mock) => new ConfigLoader({ 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 b73b16ae..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,28 +1,10 @@ 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[]) => ({ - clientId: "client-1", - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses, - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); +const makeConfig = (messageStatuses: string[]) => + createMessageStatusConfig(messageStatuses as never); describe("config update component", () => { it("reloads configuration after cache expiry", async () => { 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 db183efe..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 => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "ChannelStatus", - channelType: "EMAIL", - channelStatuses, - supplierStatuses, - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); - 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 f89a3c5b..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 => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: statuses, - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); - 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 45924904..b6666b99 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,16 @@ 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, + createMessageStatusConfig, +} from "__tests__/helpers/client-subscription-fixtures"; import { TransformationError } from "services/error-handler"; import { evaluateSubscriptionFilters } from "services/subscription-filter"; @@ -83,60 +86,6 @@ const createChannelStatusEvent = ( }, }); -const createMessageStatusConfig = ( - clientId: string, - statuses: MessageStatus[], -): ClientSubscriptionConfiguration => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: statuses, - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); - -const createChannelStatusConfig = ( - clientId: string, - channelType: Channel, - channelStatuses: ChannelStatus[], - supplierStatuses: SupplierStatus[], -): ClientSubscriptionConfiguration => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000002", - subscriptionType: "ChannelStatus", - channelType, - channelStatuses, - supplierStatuses, - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); - describe("evaluateSubscriptionFilters", () => { describe("when config is undefined", () => { it("returns not matched with Unknown subscription type", () => { @@ -153,7 +102,7 @@ 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); @@ -165,7 +114,7 @@ describe("evaluateSubscriptionFilters", () => { 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); @@ -187,10 +136,10 @@ describe("evaluateSubscriptionFilters", () => { "notified", ); const config = createChannelStatusConfig( - "client-1", - "EMAIL", ["DELIVERED"], ["delivered"], + "client-1", + "EMAIL", ); const result = evaluateSubscriptionFilters(event, config); @@ -211,10 +160,10 @@ describe("evaluateSubscriptionFilters", () => { "delivered", // previousSupplierStatus (no change) ); const config = createChannelStatusConfig( - "client-1", - "EMAIL", ["DELIVERED"], ["delivered"], + "client-1", + "EMAIL", ); const result = evaluateSubscriptionFilters(event, config); @@ -232,7 +181,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 7e1417e1..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,40 +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 TARGET_ID = "00000000-0000-4000-8000-000000000001"; - -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" }, - }, - ], -}); +const createValidConfig = (): ClientSubscriptionConfiguration => + createClientSubscriptionConfig("client-1", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"]), + createChannelStatusSubscription(["DELIVERED"], ["read"]), + ], + targets: [createTarget()], + }); describe("validateClientConfig", () => { it("returns the config when valid", () => { From 6859085a23e2829aaca98db09f9986791841dcf8 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 13:55:46 +0000 Subject: [PATCH 24/29] fixup: Refactor lambda test fixture creation to reduce repeat --- .../src/__tests__/index.component.test.ts | 24 +----- .../src/__tests__/index.test.ts | 85 +++++++++---------- 2 files changed, 41 insertions(+), 68 deletions(-) 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 13f48fac..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) => ({ - clientId, - subscriptions: [ - { - subscriptionId: "00000000-0000-0000-0000-000000000001", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED", "FAILED"], - targetIds: ["00000000-0000-4000-8000-000000000001"], - }, - ], - targets: [ - { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API", - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "secret" }, - }, - ], -}); +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 32cd391f..1987726a 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -13,65 +13,56 @@ 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 = { - targetId: "00000000-0000-4000-8000-000000000001", - type: "API" as const, - invocationEndpoint: "https://example.com/webhook", - invocationMethod: "POST" as const, - invocationRateLimit: 10, - apiKey: { headerName: "x-api-key", headerValue: "test-api-key" }, -}; - const createPassthroughConfigLoader = (): ConfigLoader => ({ loadClientConfig: jest .fn() .mockImplementation(async (clientId: string) => ({ - clientId, + ...createClientSubscriptionConfig(clientId), subscriptions: [ - { - subscriptionType: "MessageStatus", + createMessageStatusSubscription(["DELIVERED"], { subscriptionId: "00000000-0000-0000-0000-000000000001", - targetIds: ["00000000-0000-4000-8000-000000000001"], - messageStatuses: [ - "DELIVERED", - "FAILED", - "PENDING", - "SENDING", - "TECHNICAL_FAILURE", - "PERMANENT_FAILURE", - ], - }, - { - subscriptionType: "ChannelStatus", - subscriptionId: "00000000-0000-0000-0000-000000000002", - targetIds: ["00000000-0000-4000-8000-000000000001"], - channelType: "NHSAPP", - channelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], - supplierStatuses: [ - "delivered", - "permanent_failure", - "temporary_failure", - ], - }, - { - subscriptionType: "ChannelStatus", - subscriptionId: "00000000-0000-0000-0000-000000000003", - targetIds: ["00000000-0000-4000-8000-000000000001"], - channelType: "SMS", - channelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], - supplierStatuses: [ - "delivered", - "permanent_failure", - "temporary_failure", - ], - }, + 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], + }, + ), + ], + targets: [ + createTarget({ + invocationEndpoint: "https://example.com/webhook", + apiKey: { + headerName: "x-api-key", + headerValue: "test-api-key", + }, + }), ], - targets: [stubTarget], })), }) as unknown as ConfigLoader; From 65463514f8ee015e95e2a3ca59d586b32d6a38a4 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 14:23:50 +0000 Subject: [PATCH 25/29] Silence pinno/logger tests --- package.json | 1 + scripts/tests/unit.sh | 2 +- src/logger/src/index.ts | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 56cb0eef..26903c9f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "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", "clients:list": "npm run clients-list --workspace tools/client-subscriptions-management --", 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() }; From 5d937dadf9eb863444d1048667f32c2430fef887 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 14:57:03 +0000 Subject: [PATCH 26/29] Fix target id filter logging --- .../src/__tests__/index.test.ts | 53 +++++++++++++++ .../services/subscription-filter.test.ts | 66 +++++++++++++++++++ .../src/handler.ts | 3 +- .../src/services/subscription-filter.ts | 37 ++++++++++- 4 files changed, 155 insertions(+), 4 deletions(-) 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 1987726a..2710164e 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -179,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/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index b6666b99..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 @@ -10,7 +10,10 @@ import type { 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"; @@ -109,6 +112,7 @@ describe("evaluateSubscriptionFilters", () => { expect(result).toEqual({ matched: true, subscriptionType: "MessageStatus", + targetIds: ["00000000-0000-4000-8000-000000000001"], }); }); @@ -123,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", () => { @@ -147,6 +173,7 @@ describe("evaluateSubscriptionFilters", () => { expect(result).toEqual({ matched: true, subscriptionType: "ChannelStatus", + targetIds: ["00000000-0000-4000-8000-000000000001"], }); }); @@ -173,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", () => { diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index a0e76978..31ff34c0 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -221,13 +221,12 @@ async function filterBatch( if (filterResult.matched) { filtered.push(event); - const targetIds = config?.targets?.map((t) => t.targetId); observability.recordFilteringMatched({ correlationId, clientId, eventType: event.type, subscriptionType: filterResult.subscriptionType, - targetIds, + targetIds: filterResult.targetIds, }); } else { stats.recordFiltered(); 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 } : {}), }; } From 1097f8d8f2a587486233e84a79bf1e8a52ab49ef Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 14:58:36 +0000 Subject: [PATCH 27/29] fixup! Refactor config validation to shared package for re-use --- src/models/src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/models/src/index.ts b/src/models/src/index.ts index de08f07d..b037c6db 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -18,10 +18,7 @@ export type { MessageStatusSubscriptionConfiguration, SubscriptionConfiguration, } from "./client-config"; -export { - clientSubscriptionConfigurationSchema, - parseClientSubscriptionConfiguration, -} from "./client-config-schema"; +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"; From 145497adaec3d453e61f8f1a9f6439747be802c0 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 19 Mar 2026 16:23:34 +0000 Subject: [PATCH 28/29] Refactor cli to have common entry point --- .../package.json | 20 +-- .../entrypoint/cli/clients-get.test.ts | 44 +---- .../entrypoint/cli/clients-list.test.ts | 24 +-- .../entrypoint/cli/clients-put.test.ts | 29 +--- .../entrypoint/cli/subscriptions-add.test.ts | 23 +-- .../entrypoint/cli/subscriptions-del.test.ts | 23 +-- .../entrypoint/cli/subscriptions-list.test.ts | 30 +--- .../cli/subscriptions-set-states.test.ts | 26 +-- .../entrypoint/cli/targets-add.test.ts | 23 +-- .../entrypoint/cli/targets-del.test.ts | 23 +-- .../entrypoint/cli/targets-list.test.ts | 30 +--- .../src/entrypoint/cli/clients-get.ts | 41 +++-- .../src/entrypoint/cli/clients-list.ts | 39 +++-- .../src/entrypoint/cli/clients-put.ts | 108 +++++++------ .../src/entrypoint/cli/helper.ts | 60 +++++++ .../src/entrypoint/cli/index.ts | 33 ++++ .../src/entrypoint/cli/subscriptions-add.ts | 151 +++++++++-------- .../src/entrypoint/cli/subscriptions-del.ts | 61 +++---- .../src/entrypoint/cli/subscriptions-list.ts | 41 +++-- .../cli/subscriptions-set-states.ts | 153 ++++++++++-------- .../src/entrypoint/cli/targets-add.ts | 93 ++++++----- .../src/entrypoint/cli/targets-del.ts | 58 +++---- .../src/entrypoint/cli/targets-list.ts | 41 +++-- 23 files changed, 552 insertions(+), 622 deletions(-) create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/index.ts diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index 9ae01ce5..ba5b114c 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -5,16 +5,16 @@ "main": "src/index.ts", "scripts": { "start": "tsx ./src/entrypoint/interactive/index.ts", - "clients-list": "tsx ./src/entrypoint/cli/clients-list.ts", - "clients-get": "tsx ./src/entrypoint/cli/clients-get.ts", - "clients-put": "tsx ./src/entrypoint/cli/clients-put.ts", - "subscriptions-list": "tsx ./src/entrypoint/cli/subscriptions-list.ts", - "subscriptions-add": "tsx ./src/entrypoint/cli/subscriptions-add.ts", - "subscriptions-del": "tsx ./src/entrypoint/cli/subscriptions-del.ts", - "subscriptions-set-states": "tsx ./src/entrypoint/cli/subscriptions-set-states.ts", - "targets-list": "tsx ./src/entrypoint/cli/targets-list.ts", - "targets-add": "tsx ./src/entrypoint/cli/targets-add.ts", - "targets-del": "tsx ./src/entrypoint/cli/targets-del.ts", + "clients-list": "tsx ./src/entrypoint/cli/index.ts clients-list", + "clients-get": "tsx ./src/entrypoint/cli/index.ts clients-get", + "clients-put": "tsx ./src/entrypoint/cli/index.ts clients-put", + "subscriptions-list": "tsx ./src/entrypoint/cli/index.ts subscriptions-list", + "subscriptions-add": "tsx ./src/entrypoint/cli/index.ts subscriptions-add", + "subscriptions-del": "tsx ./src/entrypoint/cli/index.ts subscriptions-del", + "subscriptions-set-states": "tsx ./src/entrypoint/cli/index.ts subscriptions-set-states", + "targets-list": "tsx ./src/entrypoint/cli/index.ts targets-list", + "targets-add": "tsx ./src/entrypoint/cli/index.ts targets-add", + "targets-del": "tsx ./src/entrypoint/cli/index.ts targets-del", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/clients-get.test.ts index a8d12a10..14a35f15 100644 --- 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 @@ -9,6 +9,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); 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(); @@ -69,10 +70,10 @@ describe("clients-get CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli([ + await wrapCli(cli.main)([ "node", "script", "--client-id", @@ -84,43 +85,4 @@ describe("clients-get CLI", () => { expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - mockGetClientConfig.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(); - }); }); 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 index e3c32d4e..75c09cc3 100644 --- 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 @@ -9,6 +9,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/clients-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; describe("clients-list CLI", () => { const originalLog = console.log; @@ -49,31 +50,12 @@ describe("clients-list CLI", () => { expect(console.log).not.toHaveBeenCalled(); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli(["node", "script", "--bucket-name", "bucket-1"]); + await wrapCli(cli.main)(["node", "script", "--bucket-name", "bucket-1"]); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - mockListClientIds.mockResolvedValue([]); - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain(["node", "script", "--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", "--bucket-name", "bucket-1"], false); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index d3e4f042..869805a8 100644 --- 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 @@ -19,6 +19,7 @@ jest.mock("node:fs", () => ({ })); 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(); @@ -194,10 +195,10 @@ describe("clients-put CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli([ + await wrapCli(cli.main)([ "node", "script", "--client-id", @@ -211,28 +212,4 @@ describe("clients-put CLI", () => { expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain( - ["node", "script", "--client-id", "c", "--bucket-name", "b"], - 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", "c", "--bucket-name", "b"], - false, - ); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index 5bf7df7e..accd7904 100644 --- 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 @@ -18,6 +18,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); 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(); @@ -177,30 +178,12 @@ describe("subscriptions-add CLI", () => { expect(mockAddSubscription).not.toHaveBeenCalled(); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli(baseMessageArgs); + await wrapCli(cli.main)(baseMessageArgs); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain(baseMessageArgs, 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(baseMessageArgs, false); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index b8953870..3a161e21 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); 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(); @@ -72,30 +73,12 @@ describe("subscriptions-del CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli(baseArgs); + await wrapCli(cli.main)(baseArgs); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain(baseArgs, 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(baseArgs, false); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index 4de019a5..d09a6ce8 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/subscriptions-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; import { createClientSubscriptionConfig, createMessageStatusSubscription, @@ -103,10 +104,10 @@ describe("subscriptions-list CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli([ + await wrapCli(cli.main)([ "node", "script", "--client-id", @@ -118,29 +119,4 @@ describe("subscriptions-list CLI", () => { expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - mockGetClientConfig.mockResolvedValue(undefined); - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain( - ["node", "script", "--client-id", "client-1", "--bucket-name", "b"], - 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", "b"], - false, - ); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index 2be4f04f..591bb8bb 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); 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(); @@ -110,33 +111,12 @@ describe("subscriptions-set-states CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli([...baseArgs, "--message-statuses", "DELIVERED"]); + await wrapCli(cli.main)([...baseArgs, "--message-statuses", "DELIVERED"]); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain([...baseArgs, "--message-statuses", "DELIVERED"], 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( - [...baseArgs, "--message-statuses", "DELIVERED"], - false, - ); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index ea8aa0ee..2010cd68 100644 --- 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 @@ -16,6 +16,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/targets-add"; +import { wrapCli } from "src/entrypoint/cli/helper"; import { createClientSubscriptionConfig, createTarget, @@ -113,30 +114,12 @@ describe("targets-add CLI", () => { expect(mockAddTarget).toHaveBeenCalledWith("client-1", builtTarget, true); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli(baseArgs); + await wrapCli(cli.main)(baseArgs); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain(baseArgs, 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(baseArgs, false); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index d5529784..35eefab0 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); 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(); @@ -70,30 +71,12 @@ describe("targets-del CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli(baseArgs); + await wrapCli(cli.main)(baseArgs); expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain(baseArgs, 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(baseArgs, false); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); 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 index f6997acb..661f790c 100644 --- 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 @@ -11,6 +11,7 @@ jest.mock("src/entrypoint/cli/helper", () => ({ })); import * as cli from "src/entrypoint/cli/targets-list"; +import { wrapCli } from "src/entrypoint/cli/helper"; import { createClientSubscriptionConfig, createTarget, @@ -94,10 +95,10 @@ describe("targets-list CLI", () => { ); }); - it("handles errors in runCli", async () => { + it("handles errors in wrapped CLI", async () => { mockCreateRepository.mockRejectedValue(new Error("Boom")); - await cli.runCli([ + await wrapCli(cli.main)([ "node", "script", "--client-id", @@ -109,29 +110,4 @@ describe("targets-list CLI", () => { expect(console.error).toHaveBeenCalledWith(new Error("Boom")); expect(process.exitCode).toBe(1); }); - - it("executes when run as main module", async () => { - mockGetClientConfig.mockResolvedValue(undefined); - const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); - - await cli.runIfMain( - ["node", "script", "--client-id", "client-1", "--bucket-name", "b"], - 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", "b"], - false, - ); - - expect(runCliSpy).not.toHaveBeenCalled(); - runCliSpy.mockRestore(); - }); }); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts index caf2f52f..723b445d 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-get.ts @@ -1,22 +1,20 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type ClientCliArgs, clientIdOption, commonOptions, createRepository, - wrapCli, + runCommand, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - ...clientIdOption, - }) - .parseSync(); +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +export const handler: CliCommand["handler"] = async (argv) => { const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); @@ -26,14 +24,15 @@ export async function main(args: string[] = process.argv) { } else { console.log(`No configuration exists for client: ${argv["client-id"]}`); } -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "clients-get", + describe: "Get a client configuration", + builder, + handler, }; -runIfMain(); + +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 index c858781c..de17a0b2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-list.ts @@ -1,34 +1,33 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type CommonCliArgs, commonOptions, createRepository, - wrapCli, + runCommand, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - }) - .parseSync(); +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + }); -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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 runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "clients-list", + describe: "List configured client IDs", + builder, + handler, }; -runIfMain(); + +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 index e8b3f532..104f07e2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -1,56 +1,63 @@ import { readFileSync } from "node:fs"; -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +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, runTerraformApply, - wrapCli, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .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", - }, - }) - .parseSync(); +type ClientsPutArgs = ClientCliArgs & + WriteCliArgs & { + file?: string; + group?: string; + json?: string; + "terraform-apply": boolean; + "tf-region"?: string; + }; -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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; @@ -106,14 +113,15 @@ export async function main(args: string[] = process.argv) { tfRegion: argv["tf-region"], }); } -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "clients-put", + describe: "Write a full client configuration", + builder, + handler, }; -runIfMain(); + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index 28e0e1ad..5f7b9268 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -3,6 +3,9 @@ import { createInterface } from "node:readline/promises"; import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { fromIni } from "@aws-sdk/credential-providers"; import { table } from "table"; +import { hideBin } from "yargs/helpers"; +import yargs from "yargs/yargs"; +import type { Argv, CommandModule } from "yargs"; import { createClientSubscriptionRepository } from "src/container"; import type { CallbackTarget, @@ -215,6 +218,63 @@ export const wrapCli = } }; +export type CommonCliArgs = { + "bucket-name"?: string; + environment?: string; + profile?: string; + region?: string; +}; + +export type ClientCliArgs = CommonCliArgs & { + "client-id": string; +}; + +export type WriteCliArgs = { + "dry-run": boolean; +}; + +export type CliCommand = CommandModule, TArgs>; + +export type AnyCliCommand = CliCommand; + +const configureParser = (parser: Argv) => + parser + .strict() + .recommendCommands() + .demandCommand(1) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .help(); + +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); + } + await parser.parseAsync(); +}; + export const createRepository = async (argv: { "bucket-name"?: string; environment?: string; 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/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts index 5c56b8af..ed198c89 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -1,80 +1,96 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +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, formatClientConfig, - wrapCli, + runCommand, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .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)", - }, - }) - .parseSync(); +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 async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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"]; @@ -131,14 +147,15 @@ export async function main(args: string[] = process.argv) { ); console.log(formatClientConfig(result)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "subscriptions-add", + describe: "Add a subscription to a client", + builder, + handler, }; -runIfMain(); + +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 index 17cf1570..ef14a1a2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -1,30 +1,36 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, clientIdOption, commonOptions, createRepository, formatClientConfig, - wrapCli, + runCommand, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - ...clientIdOption, - ...writeOptions, - "subscription-id": { - type: "string", - demandOption: true, - description: "Subscription ID to delete", - }, - }) - .parseSync(); +type SubscriptionsDelArgs = ClientCliArgs & + WriteCliArgs & { + "subscription-id": string; + }; -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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( @@ -34,14 +40,15 @@ export async function main(args: string[] = process.argv) { ); console.log(formatClientConfig(result)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "subscriptions-del", + describe: "Delete a subscription from a client", + builder, + handler, }; -runIfMain(); + +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 index 7c9f3866..10abcee3 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -1,23 +1,21 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type ClientCliArgs, clientIdOption, commonOptions, createRepository, formatSubscriptionsTable, - wrapCli, + runCommand, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - ...clientIdOption, - }) - .parseSync(); +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +export const handler: CliCommand["handler"] = async (argv) => { const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); @@ -33,14 +31,15 @@ export async function main(args: string[] = process.argv) { } console.log(formatSubscriptionsTable(config.subscriptions)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "subscriptions-list", + describe: "List a client's subscriptions", + builder, + handler, }; -runIfMain(); + +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 index 9c04cd2c..92822714 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -1,91 +1,104 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +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, formatClientConfig, - wrapCli, + runCommand, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .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)", - }, - }) - .parseSync(); +type SubscriptionsSetStatesArgs = ClientCliArgs & + WriteCliArgs & { + "channel-statuses"?: ChannelStatus[]; + "message-statuses"?: MessageStatus[]; + "subscription-id": string; + "supplier-statuses"?: SupplierStatus[]; + }; -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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)", + }, + }); - const messageStatuses = argv["message-statuses"]; - const channelStatuses = argv["channel-statuses"]; - const supplierStatuses = argv["supplier-statuses"]; +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; - } + 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 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)); -} + const result = await repository.setSubscriptionStates( + argv["client-id"], + argv["subscription-id"], + { messageStatuses, channelStatuses, supplierStatuses }, + argv["dry-run"], + ); -export const runCli = wrapCli(main); + console.log(formatClientConfig(result)); + }; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "subscriptions-set-states", + describe: "Update the states on an existing subscription", + builder, + handler, }; -runIfMain(); + +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 index d0bb393b..04570332 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -1,48 +1,54 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { buildTarget } from "src/domain/client-subscription-builder"; import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, clientIdOption, commonOptions, createRepository, formatClientConfig, - wrapCli, + runCommand, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .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", - }, - }) - .parseSync(); +type TargetsAddArgs = ClientCliArgs & + WriteCliArgs & { + "api-endpoint": string; + "api-key": string; + "api-key-header-name": string; + "rate-limit": number; + }; -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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://"); @@ -66,14 +72,15 @@ export async function main(args: string[] = process.argv) { ); console.log(`Target added with ID: ${target.targetId}`); console.log(formatClientConfig(result)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "targets-add", + describe: "Add a callback target to a client", + builder, + handler, }; -runIfMain(); + +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 index d0bf807e..b89c1412 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -1,31 +1,34 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, clientIdOption, commonOptions, createRepository, formatClientConfig, - wrapCli, + runCommand, writeOptions, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - ...clientIdOption, - ...writeOptions, - "target-id": { - type: "string", - demandOption: true, - description: "Target identifier to delete", - }, - }) - .parseSync(); +type TargetsDelArgs = ClientCliArgs & + WriteCliArgs & { + "target-id": string; + }; -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +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( @@ -35,14 +38,15 @@ export async function main(args: string[] = process.argv) { ); console.log(formatClientConfig(result)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "targets-del", + describe: "Delete a callback target from a client", + builder, + handler, }; -runIfMain(); + +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 index 37b90856..f1074c24 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -1,23 +1,21 @@ -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; +import type { Argv } from "yargs"; import { + type CliCommand, + type ClientCliArgs, clientIdOption, commonOptions, createRepository, formatTargetsTable, - wrapCli, + runCommand, } from "src/entrypoint/cli/helper"; -export const parseArgs = (args: string[]) => - yargs(hideBin(args)) - .options({ - ...commonOptions, - ...clientIdOption, - }) - .parseSync(); +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + }); -export async function main(args: string[] = process.argv) { - const argv = parseArgs(args); +export const handler: CliCommand["handler"] = async (argv) => { const repository = await createRepository(argv); const config = await repository.getClientConfig(argv["client-id"]); @@ -33,14 +31,15 @@ export async function main(args: string[] = process.argv) { } console.log(formatTargetsTable(config.targets)); -} - -export const runCli = wrapCli(main); +}; -export const runIfMain = async ( - args: string[] = process.argv, - isMain = require.main === module, -) => { - if (isMain) await runCli(args); +export const command: CliCommand = { + command: "targets-list", + describe: "List a client's callback targets", + builder, + handler, }; -runIfMain(); + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} From 7cdedd2354380d8358ae5f1d809b989a6479a501 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Fri, 20 Mar 2026 09:31:10 +0000 Subject: [PATCH 29/29] Refactor: Split out helper functions --- .../package.json | 1 - .../src/__tests__/aws.test.ts | 85 +++++ .../src/__tests__/container-s3-config.test.ts | 2 +- .../src/__tests__/container.test.ts | 6 +- .../entrypoint/cli/clients-put.test.ts | 5 +- .../__tests__/entrypoint/cli/helper.test.ts | 349 +++--------------- .../entrypoint/cli/subscriptions-add.test.ts | 2 + .../entrypoint/cli/subscriptions-del.test.ts | 2 + .../entrypoint/cli/subscriptions-list.test.ts | 2 + .../cli/subscriptions-set-states.test.ts | 2 + .../entrypoint/cli/targets-add.test.ts | 2 + .../entrypoint/cli/targets-del.test.ts | 2 + .../entrypoint/cli/targets-list.test.ts | 2 + .../entrypoint/interactive/clients.test.ts | 10 +- .../entrypoint/interactive/shared.test.ts | 6 +- .../interactive/subscriptions.test.ts | 5 +- .../entrypoint/interactive/targets.test.ts | 5 +- .../src/__tests__/format.test.ts | 76 ++++ .../src/__tests__/terraform.test.ts | 118 ++++++ .../src/aws.ts | 77 ++++ .../src/container.ts | 31 -- .../src/entrypoint/cli/clients-put.ts | 2 +- .../src/entrypoint/cli/helper.ts | 244 ++---------- .../src/entrypoint/cli/subscriptions-add.ts | 2 +- .../src/entrypoint/cli/subscriptions-del.ts | 2 +- .../src/entrypoint/cli/subscriptions-list.ts | 2 +- .../cli/subscriptions-set-states.ts | 2 +- .../src/entrypoint/cli/targets-add.ts | 2 +- .../src/entrypoint/cli/targets-del.ts | 2 +- .../src/entrypoint/cli/targets-list.ts | 2 +- .../src/entrypoint/interactive/clients.ts | 8 +- .../src/entrypoint/interactive/shared.ts | 6 +- .../entrypoint/interactive/subscriptions.ts | 10 +- .../src/entrypoint/interactive/targets.ts | 8 +- .../src/format.ts | 76 ++++ .../src/index.ts | 2 - .../src/terraform.ts | 79 ++++ 37 files changed, 634 insertions(+), 605 deletions(-) create mode 100644 tools/client-subscriptions-management/src/__tests__/aws.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/format.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/terraform.test.ts create mode 100644 tools/client-subscriptions-management/src/aws.ts delete mode 100644 tools/client-subscriptions-management/src/container.ts create mode 100644 tools/client-subscriptions-management/src/format.ts delete mode 100644 tools/client-subscriptions-management/src/index.ts create mode 100644 tools/client-subscriptions-management/src/terraform.ts diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index ba5b114c..6ba68a22 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -2,7 +2,6 @@ "name": "client-subscriptions-management", "version": "0.0.1", "private": true, - "main": "src/index.ts", "scripts": { "start": "tsx ./src/entrypoint/interactive/index.ts", "clients-list": "tsx ./src/entrypoint/cli/index.ts clients-list", 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 9865522e..1838175f 100644 --- a/tools/client-subscriptions-management/src/__tests__/container.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -11,14 +11,14 @@ 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", }); 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 index 869805a8..04deead4 100644 --- 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 @@ -10,7 +10,10 @@ const mockCreateRepository = jest.fn().mockResolvedValue({ jest.mock("src/entrypoint/cli/helper", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), createRepository: mockCreateRepository, - runTerraformApply: jest.fn(), +})); +jest.mock("src/terraform", () => ({ + __esModule: true, + default: jest.fn(), })); jest.mock("node:fs", () => ({ 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 b2b1f98a..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,318 +1,61 @@ +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, +})); + import { + type AnyCliCommand, createRepository, - deriveBucketName, - formatClientConfig, - formatSubscriptionsTable, - formatTargetsTable, - normalizeClientName, - resolveBucketName, - resolveProfile, - resolveRegion, - runTerraformApply, + runCommands, } from "src/entrypoint/cli/helper"; -import { - DEFAULT_TARGET_ID as TARGET_ID, - createChannelStatusSubscription, - createClientSubscriptionConfig, - createMessageStatusSubscription, - createTarget, -} from "src/__tests__/helpers/client-subscription-fixtures"; - -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: jest.fn(), -})); - -jest.mock("node:child_process", () => ({ - spawnSync: jest.fn().mockReturnValue({ status: 0 }), -})); - -jest.mock("@aws-sdk/client-sts", () => ({ - STSClient: jest.fn().mockImplementation(() => ({ - send: jest.fn().mockResolvedValue({ Account: "123456789012" }), - })), - GetCallerIdentityCommand: jest.fn(), -})); - -describe("cli helper", () => { - 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"); - }); - - 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(); - }); -}); - -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, +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(result).toBe(false); - expect(confirmFn).not.toHaveBeenCalled(); - expect(spawnSync).toHaveBeenCalledTimes(1); - expect(spawnSync).toHaveBeenCalledWith( - "make", - expect.arrayContaining(["terraform-plan"]), - expect.anything(), + expect(mockResolveRegion).toHaveBeenCalledWith("eu-west-2"); + expect(mockResolveProfile).toHaveBeenCalledWith(undefined); + expect(mockResolveBucketName).toHaveBeenCalledWith( + "my-bucket", + undefined, + "eu-west-2", + undefined, ); - }); - - 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(mockCreateRepositoryFromOptions).toHaveBeenCalledWith({ + bucketName: "my-bucket", + region: "eu-west-2", + profile: undefined, }); - - expect(result).toBe(false); - expect(process.exitCode).toBe(2); + expect(result).toBe(fakeRepo); }); }); -describe("createRepository", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createClientSubscriptionRepository } = require("src/container") as { - createClientSubscriptionRepository: jest.Mock; - }; +describe("runCommands", () => { + it("dispatches to the matching command handler", async () => { + const mockHandler = jest.fn().mockResolvedValue(undefined); + const command: AnyCliCommand = { + command: "test-cmd", + handler: mockHandler, + }; - const originalEnv = process.env; + await runCommands([command], ["node", "script", "test-cmd"]); - beforeEach(() => { - process.env = { - ...originalEnv, - AWS_PROFILE: "some-environment", - } as NodeJS.ProcessEnv; - createClientSubscriptionRepository.mockReset(); - createClientSubscriptionRepository.mockReturnValue({ - listClientIds: jest.fn(), - }); - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("creates a repository using provided bucket-name", async () => { - const repo = await createRepository({ - "bucket-name": "test-bucket", - region: "eu-west-2", - }); - - expect(createClientSubscriptionRepository).toHaveBeenCalledWith({ - bucketName: "test-bucket", - region: "eu-west-2", - profile: "some-environment", - }); - expect(repo).toEqual({ listClientIds: expect.any(Function) }); + expect(mockHandler).toHaveBeenCalled(); }); }); 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 index accd7904..0d9a8ff3 100644 --- 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 @@ -14,6 +14,8 @@ jest.mock("src/domain/client-subscription-builder", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ formatClientConfig: mockFormatClientConfig, })); 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 index 3a161e21..4656d964 100644 --- 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 @@ -7,6 +7,8 @@ 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, })); 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 index d09a6ce8..77d87aea 100644 --- 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 @@ -7,6 +7,8 @@ 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, })); 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 index 591bb8bb..fef855d9 100644 --- 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 @@ -7,6 +7,8 @@ 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, })); 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 index 2010cd68..1ac76a12 100644 --- 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 @@ -12,6 +12,8 @@ jest.mock("src/domain/client-subscription-builder", () => ({ jest.mock("src/entrypoint/cli/helper", () => ({ ...jest.requireActual("src/entrypoint/cli/helper"), createRepository: mockCreateRepository, +})); +jest.mock("src/format", () => ({ formatClientConfig: mockFormatClientConfig, })); 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 index 35eefab0..ce5d9a98 100644 --- 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 @@ -7,6 +7,8 @@ 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, })); 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 index 661f790c..630f9c4f 100644 --- 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 @@ -7,6 +7,8 @@ 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, })); 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 index 5ed7779f..a41c60f3 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/clients.test.ts @@ -35,12 +35,12 @@ jest.mock("src/entrypoint/cli/clients-get", () => ({ jest.mock("src/entrypoint/cli/clients-put", () => ({ main: (...args: unknown[]) => mockClientsPutMain(...args), })); -jest.mock("src/entrypoint/cli/helper", () => ({ - runTerraformApply: (...args: unknown[]) => mockRunTerraformApply(...args), +jest.mock("src/terraform", () => ({ + __esModule: true, + default: (...args: unknown[]) => mockRunTerraformApply(...args), })); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: (...args: unknown[]) => - mockCreateRepo(...args), +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), })); jest.mock("src/entrypoint/interactive/shared", () => ({ buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), 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 index 95ddc748..fff7ebb6 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/shared.test.ts @@ -16,14 +16,10 @@ const mockResolveBucketName = jest .fn() .mockResolvedValue("nhs-123-eu-west-2-dev-callbacks-subscription-config"); -jest.mock("src/entrypoint/cli/helper", () => ({ +jest.mock("src/aws", () => ({ resolveBucketName: (...args: unknown[]) => mockResolveBucketName(...args), })); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: jest.fn(), -})); - import { type ConnectionConfig, buildConnectionArgs, 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 index 5f3e97a7..cc40092b 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/subscriptions.test.ts @@ -39,9 +39,8 @@ const mockCreateRepo = jest.fn().mockReturnValue({ getClientConfig: mockGetClientConfig, }); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: (...args: unknown[]) => - mockCreateRepo(...args), +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), })); jest.mock("src/entrypoint/interactive/shared", () => ({ buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), 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 index 05bf0508..957d86af 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/interactive/targets.test.ts @@ -35,9 +35,8 @@ const mockCreateRepo = jest.fn().mockReturnValue({ getClientConfig: mockGetClientConfig, }); -jest.mock("src/container", () => ({ - createClientSubscriptionRepository: (...args: unknown[]) => - mockCreateRepo(...args), +jest.mock("src/aws", () => ({ + createRepository: (...args: unknown[]) => mockCreateRepo(...args), })); jest.mock("src/entrypoint/interactive/shared", () => ({ buildConnectionArgs: jest.fn().mockReturnValue(["--bucket-name", "bucket"]), 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__/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 288f4da7..00000000 --- a/tools/client-subscriptions-management/src/container.ts +++ /dev/null @@ -1,31 +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"; - -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); -}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index 104f07e2..17587ddd 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -9,9 +9,9 @@ import { commonOptions, createRepository, runCommand, - runTerraformApply, writeOptions, } from "src/entrypoint/cli/helper"; +import runTerraformApply from "src/terraform"; type ClientsPutArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index 5f7b9268..54a2a847 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,211 +1,12 @@ -import { spawnSync } from "node:child_process"; -import { createInterface } from "node:readline/promises"; -import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; -import { fromIni } from "@aws-sdk/credential-providers"; -import { table } from "table"; +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"; -import { createClientSubscriptionRepository } from "src/container"; -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(); - -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 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 const wrapCli = (mainFn: (args: string[]) => Promise) => @@ -233,7 +34,21 @@ export type WriteCliArgs = { "dry-run": boolean; }; -export type CliCommand = CommandModule, TArgs>; +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 createRepositoryFromOptions({ bucketName, region, profile }); +}; + +type BaseArgs = Record; + +export type CliCommand = CommandModule; export type AnyCliCommand = CliCommand; @@ -275,23 +90,6 @@ export const runCommands = async ( await parser.parseAsync(); }; -export const createRepository = async (argv: { - "bucket-name"?: string; - environment?: string; - region?: string; - profile?: string; -}) => { - const region = resolveRegion(argv.region); - const profile = resolveProfile(argv.profile); - const bucketName = await resolveBucketName( - argv["bucket-name"], - argv.environment, - region, - profile, - ); - return createClientSubscriptionRepository({ bucketName, region, profile }); -}; - export const commonOptions = { "bucket-name": { type: "string" as const, diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts index ed198c89..fc34cd2b 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-add.ts @@ -21,10 +21,10 @@ import { clientIdOption, commonOptions, createRepository, - formatClientConfig, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; type SubscriptionsAddArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts index ef14a1a2..74c07da0 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-del.ts @@ -6,10 +6,10 @@ import { clientIdOption, commonOptions, createRepository, - formatClientConfig, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; type SubscriptionsDelArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts index 10abcee3..be2a11b3 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-list.ts @@ -5,9 +5,9 @@ import { clientIdOption, commonOptions, createRepository, - formatSubscriptionsTable, runCommand, } from "src/entrypoint/cli/helper"; +import { formatSubscriptionsTable } from "src/format"; export const builder = (yargs: Argv) => yargs.options({ 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 index 92822714..ee17a979 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/subscriptions-set-states.ts @@ -16,10 +16,10 @@ import { clientIdOption, commonOptions, createRepository, - formatClientConfig, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; type SubscriptionsSetStatesArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts index 04570332..006ff732 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-add.ts @@ -7,10 +7,10 @@ import { clientIdOption, commonOptions, createRepository, - formatClientConfig, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; type TargetsAddArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts index b89c1412..6fe56ac2 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-del.ts @@ -6,10 +6,10 @@ import { clientIdOption, commonOptions, createRepository, - formatClientConfig, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; type TargetsDelArgs = ClientCliArgs & WriteCliArgs & { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts index f1074c24..65941a98 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-list.ts @@ -5,9 +5,9 @@ import { clientIdOption, commonOptions, createRepository, - formatTargetsTable, runCommand, } from "src/entrypoint/cli/helper"; +import { formatTargetsTable } from "src/format"; export const builder = (yargs: Argv) => yargs.options({ diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts index 79d5c8d6..a1520a41 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/clients.ts @@ -1,6 +1,6 @@ import { confirm, input } from "@inquirer/prompts"; -import { createClientSubscriptionRepository } from "src/container"; -import { runTerraformApply } from "src/entrypoint/cli/helper"; +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"; @@ -20,7 +20,7 @@ export async function interactiveClientsList( export async function interactiveClientsGet( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -38,7 +38,7 @@ export async function interactiveClientsGet( export async function interactiveClientsPut( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts index 0a8a87a7..1dd2235d 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/shared.ts @@ -1,6 +1,6 @@ import { Separator, confirm, input, select } from "@inquirer/prompts"; -import { createClientSubscriptionRepository } from "src/container"; -import { resolveBucketName } from "src/entrypoint/cli/helper"; +import type { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { resolveBucketName } from "src/aws"; const AWS_REGIONS = [ "eu-west-2", @@ -17,7 +17,7 @@ export interface ConnectionConfig { profile?: string; } -export type Repository = ReturnType; +export type Repository = ClientSubscriptionRepository; export const buildConnectionArgs = (connection: ConnectionConfig): string[] => { const args: string[] = ["--bucket-name", connection.bucketName]; diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts index 2c64efce..6ec264e9 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/subscriptions.ts @@ -6,7 +6,7 @@ import { SUPPLIER_STATUSES, } from "@nhs-notify-client-callbacks/models"; import type { SubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -import { createClientSubscriptionRepository } from "src/container"; +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"; @@ -21,7 +21,7 @@ import { export async function interactiveSubscriptionsList( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -39,7 +39,7 @@ export async function interactiveSubscriptionsList( export async function interactiveSubscriptionsAdd( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -130,7 +130,7 @@ export async function interactiveSubscriptionsAdd( export async function interactiveSubscriptionsDel( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -243,7 +243,7 @@ async function pushUnknownTypeStatusArgs(args: string[]): Promise { export async function interactiveSubscriptionsSetStates( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, diff --git a/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts b/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts index e5ee414c..ce2d097c 100644 --- a/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts +++ b/tools/client-subscriptions-management/src/entrypoint/interactive/targets.ts @@ -1,5 +1,5 @@ import { Separator, input, password, select } from "@inquirer/prompts"; -import { createClientSubscriptionRepository } from "src/container"; +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"; @@ -13,7 +13,7 @@ import { export async function interactiveTargetsList( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -31,7 +31,7 @@ export async function interactiveTargetsList( export async function interactiveTargetsAdd( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, @@ -82,7 +82,7 @@ export async function interactiveTargetsAdd( export async function interactiveTargetsDel( connection: ConnectionConfig, ): Promise { - const repo = createClientSubscriptionRepository({ + const repo = createRepository({ bucketName: connection.bucketName, region: connection.region, profile: connection.profile, 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/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;