diff --git a/.coverage b/.coverage index 45dd5301b..ade9ec2f3 100644 Binary files a/.coverage and b/.coverage differ diff --git a/README.md b/README.md index acc2d17f8..32f28aed0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Clone the repository ```shell git clone https://github.com/NHSDigital/nhs-notify-digital-letters.git cd nhs-notify-digital-letters -code protject.code-workspace +code project.code-workspace ``` Reopen with container diff --git a/package-lock.json b/package-lock.json index 47c400b90..e238fd024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", "utils/utils", + "utils/sender-management", "src/cloudevents", "tests/playwright" ], @@ -99,6 +100,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -206,6 +208,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -376,7 +379,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.928.0.tgz", "integrity": "sha512-Efenb8zV2fJJDXmp2NE4xj8Ymhp4gVJCkQ6ixhdrpfQXgd2PODO7a20C2+BhFM6aGmN3m6XWYJ64ZyhXF4pAyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -426,7 +428,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.928.0.tgz", "integrity": "sha512-e28J2uKjy2uub4u41dNnmzAu0AN3FGB+LRcLN2Qnwl9Oq3kIcByl5sM8ZD+vWpNG+SFUrUasBCq8cMnHxwXZ4w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@aws-sdk/xml-builder": "3.921.0", @@ -451,7 +452,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.928.0.tgz", "integrity": "sha512-tB8F9Ti0/NFyFVQX8UQtgRik88evtHpyT6WfXOB4bAY6lEnEHA0ubJZmk9y+aUeoE+OsGLx70dC3JUsiiCPJkQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -468,7 +468,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.928.0.tgz", "integrity": "sha512-67ynC/8UW9Y8Gn1ZZtC3OgcQDGWrJelHmkbgpmmxYUrzVhp+NINtz3wiTzrrBFhPH/8Uy6BxvhMfXhn0ptcMEQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -490,7 +489,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.928.0.tgz", "integrity": "sha512-WVWYyj+jox6mhKYp11mu8x1B6Xa2sLbXFHAv5K3Jg8CHvXYpePgTcYlCljq3d4XHC4Jl4nCcsdMtBahSpU9bAA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/credential-provider-env": "3.928.0", @@ -515,7 +513,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.928.0.tgz", "integrity": "sha512-SdXVjxZOIXefIR/NJx+lyXOrn4m0ScTAU2JXpLsFCkW2Cafo6vTqHUghyO8vak/XQ8PpPqpLXVpGbAYFuIPW6Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "3.928.0", "@aws-sdk/credential-provider-http": "3.928.0", @@ -539,7 +536,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.928.0.tgz", "integrity": "sha512-XL0juran8yhqwn0mreV+NJeHJOkcRBaExsvVn9fXWW37A4gLh4esSJxM2KbSNh0t+/Bk3ehBI5sL9xad+yRDuw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -557,7 +553,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.928.0.tgz", "integrity": "sha512-md/y+ePDsO1zqPJrsOyPs4ciKmdpqLL7B0dln1NhqZPnKIS5IBfTqZJ5tJ9eTezqc7Tn4Dbg6HiuemcGvZTeFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/client-sso": "3.928.0", "@aws-sdk/core": "3.928.0", @@ -577,7 +572,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.928.0.tgz", "integrity": "sha512-rd97nLY5e/nGOr73ZfsXD+H44iZ9wyGZTKt/2QkiBN3hot/idhgT9+XHsWhRi+o/dThQbpL8RkpAnpF+0ZGthw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/nested-clients": "3.928.0", @@ -596,7 +590,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.922.0.tgz", "integrity": "sha512-F7Qhwz/bs/Wkbu4SLwKbAeQKoZ7Bzo+JPpVzSqSJGxEely8KBAfsOItXRF8c0d06OEzyeSyml0S6/3TP8T5KUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "3.893.0", "@aws-sdk/types": "3.922.0", @@ -614,7 +607,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.922.0.tgz", "integrity": "sha512-HPquFgBnq/KqKRVkiuCt97PmWbKtxQ5iUNLEc6FIviqOoZTmaYG3EDsIbuFBz9C4RHJU4FKLmHL2bL3FEId6AA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/protocol-http": "^5.3.4", @@ -630,7 +622,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.922.0.tgz", "integrity": "sha512-AkvYO6b80FBm5/kk2E636zNNcNgjztNNUxpqVx+huyGn9ZqGTzS4kLqW2hO6CBe5APzVtPCtiQsXL24nzuOlAg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -645,7 +636,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.922.0.tgz", "integrity": "sha512-TtSCEDonV/9R0VhVlCpxZbp/9sxQvTTRKzIf8LxW3uXpby6Wl8IxEciBJlxmSkoqxh542WRcko7NYODlvL/gDA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@aws/lambda-invoke-store": "^0.1.1", @@ -662,7 +652,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.928.0.tgz", "integrity": "sha512-ESvcfLx5PtpdUM3ptCwb80toBTd3y5I4w5jaeOPHihiZr7jkRLE/nsaCKzlqscPs6UQ8xI0maav04JUiTskcHw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -681,7 +670,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.928.0.tgz", "integrity": "sha512-kXzfJkq2cD65KAHDe4hZCsnxcGGEWD5pjHqcZplwG4VFMa/iVn/mWrUY9QdadD2GBpXFNQbgOiKG3U2NkKu+4Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -731,7 +719,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.925.0.tgz", "integrity": "sha512-FOthcdF9oDb1pfQBRCfWPZhJZT5wqpvdAS5aJzB1WDZ+6EuaAhLzLH/fW1slDunIqq1PSQGG3uSnVglVVOvPHQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/config-resolver": "^4.4.2", @@ -748,7 +735,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.928.0.tgz", "integrity": "sha512-533NpTdUJNDi98zBwRp4ZpZoqULrAVfc0YgIy+8AZHzk0v7N+v59O0d2Du3YO6zN4VU8HU8766DgKiyEag6Dzg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/nested-clients": "3.928.0", @@ -767,7 +753,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.922.0.tgz", "integrity": "sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.8.1", "tslib": "^2.6.2" @@ -796,7 +781,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.922.0.tgz", "integrity": "sha512-4ZdQCSuNMY8HMlR1YN4MRDdXuKd+uQTeKIr5/pIM+g3TjInZoj8imvXudjcrFGA63UF3t92YVTkBq88mg58RXQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -813,7 +797,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.922.0.tgz", "integrity": "sha512-qOJAERZ3Plj1st7M4Q5henl5FRpE30uLm6L9edZqZXGR6c7ry9jzexWamWVpQ4H4xVAVmiO9dIEBAfbq4mduOA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -826,7 +809,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.928.0.tgz", "integrity": "sha512-s0jP67nQLLWVWfBtqTkZUkSWK5e6OI+rs+wFya2h9VLyWBFir17XSDI891s8HZKIVCEl8eBrup+hhywm4nsIAA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/middleware-user-agent": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -851,7 +833,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.921.0.tgz", "integrity": "sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.8.1", "fast-xml-parser": "5.2.5", @@ -866,7 +847,6 @@ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -876,7 +856,6 @@ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -890,7 +869,6 @@ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", @@ -908,7 +886,6 @@ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.0.tgz", "integrity": "sha512-vGSDXOJFZgOPTatSI1ly7Gwyy/d/R9zh2TO3y0JZ0uut5qQ88p9IaWaZYIWSSqtdekNM4CGok/JppxbAff4KcQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/middleware-serde": "^4.2.5", "@smithy/protocol-http": "^5.3.5", @@ -930,7 +907,6 @@ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", @@ -947,7 +923,6 @@ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", @@ -964,7 +939,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.7.tgz", "integrity": "sha512-i8Mi8OuY6Yi82Foe3iu7/yhBj1HBRoOQwBSsUNYglJTNSFaWYTNM2NauBBs/7pq2sqkLRqeUXA3Ogi2utzpUlQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.18.0", "@smithy/middleware-serde": "^4.2.5", @@ -984,7 +958,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.5.tgz", "integrity": "sha512-La1ldWTJTZ5NqQyPqnCNeH9B+zjFhrNoQIL1jTh4zuqXRlmXhxYHhMtI1/92OlnoAtp6JoN7kzuwhWoXrBwPqg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", @@ -999,7 +972,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1013,7 +985,6 @@ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", @@ -1029,7 +1000,6 @@ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", @@ -1046,7 +1016,6 @@ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1060,7 +1029,6 @@ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1074,7 +1042,6 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", @@ -1089,7 +1056,6 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1103,7 +1069,6 @@ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1117,7 +1082,6 @@ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.3.tgz", "integrity": "sha512-8tlueuTgV5n7inQCkhyptrB3jo2AO80uGrps/XTYZivv5MFQKKBj3CIWIGMI2fRY5LEduIiazOhAWdFknY1O9w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.18.0", "@smithy/middleware-endpoint": "^4.3.7", @@ -1136,7 +1100,6 @@ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1149,7 +1112,6 @@ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", @@ -1164,7 +1126,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.9.tgz", "integrity": "sha512-dgyribrVWN5qE5usYJ0m5M93mVM3L3TyBPZWe1Xl6uZlH2gzfQx3dz+ZCdW93lWqdedJRkOecnvbnoEEXRZ5VQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", @@ -1183,7 +1144,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", @@ -1198,7 +1158,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1212,7 +1171,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", @@ -1232,7 +1190,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", @@ -1259,6 +1216,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -1330,6 +1288,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -2074,6 +2033,7 @@ "node_modules/@aws-sdk/client-dynamodb": { "version": "3.914.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2716,6 +2676,7 @@ "node_modules/@aws-sdk/client-s3": { "version": "3.914.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -4052,6 +4013,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -4073,6 +4035,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5633,6 +5596,7 @@ "version": "30.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -5949,6 +5913,7 @@ "version": "15.5.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-glob": "3.3.1" } @@ -7275,7 +7240,6 @@ }, "node_modules/@types/yargs": { "version": "17.0.33", - "dev": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -7283,7 +7247,6 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -7570,6 +7533,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7733,7 +7697,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7741,7 +7704,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8032,6 +7994,7 @@ "version": "4.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/sinon": "^17.0.3", "sinon": "^18.0.1", @@ -8300,6 +8263,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -8542,7 +8506,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -8580,7 +8543,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8591,7 +8553,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -9050,7 +9011,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -9282,7 +9242,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9303,6 +9262,7 @@ "version": "9.37.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9496,6 +9456,7 @@ "version": "10.1.8", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9551,6 +9512,7 @@ "version": "4.4.4", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "debug": "^4.4.1", "eslint-import-context": "^0.1.8", @@ -9619,6 +9581,7 @@ "version": "2.32.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9651,6 +9614,7 @@ "version": "4.16.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -9758,6 +9722,7 @@ "version": "6.10.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -9845,6 +9810,7 @@ "version": "7.37.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -9876,6 +9842,7 @@ "version": "5.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10560,7 +10527,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10636,7 +10602,6 @@ }, "node_modules/get-tsconfig": { "version": "4.10.1", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -11214,7 +11179,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11543,6 +11507,7 @@ "version": "30.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13716,6 +13681,7 @@ "version": "26.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -13753,6 +13719,7 @@ "node_modules/jsep": { "version": "1.4.0", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -15363,7 +15330,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15424,7 +15390,6 @@ }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -15629,6 +15594,10 @@ "node": ">=10" } }, + "node_modules/sender-management": { + "resolved": "utils/sender-management", + "link": true + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -15906,7 +15875,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -16034,7 +16002,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -16216,6 +16183,7 @@ "version": "4.0.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16400,6 +16368,7 @@ "version": "10.9.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16474,7 +16443,6 @@ }, "node_modules/tsx": { "version": "4.20.6", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.25.0", @@ -16494,7 +16462,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -16609,6 +16576,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16621,6 +16589,7 @@ "version": "8.46.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.1", "@typescript-eslint/parser": "8.46.1", @@ -17041,7 +17010,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -17150,7 +17118,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -17174,7 +17141,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -17191,7 +17157,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -17393,6 +17358,78 @@ "uuid": "dist/bin/uuid" } }, + "utils/sender-management": { + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-ssm": "^3.914.0", + "@types/yargs": "^17.0.33", + "tsx": "^4.20.6", + "utils": "^0.0.1", + "yargs": "^17.7.2", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + } + }, + "utils/sender-management/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "utils/sender-management/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "utils/sender-management/node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "utils/utils": { "version": "0.0.1", "dependencies": { @@ -17471,6 +17508,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", diff --git a/package.json b/package.json index f5ac589d3..a4768185e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", "utils/utils", + "utils/sender-management", "src/cloudevents", "tests/playwright" ] diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index 524a33e56..887943f8b 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -4,8 +4,8 @@ sonar.host.url=https://sonarcloud.io sonar.qualitygate.wait=true sonar.sourceEncoding=UTF-8 sonar.sources=. -sonar.tests=tests/, src/asyncapigenerator/tests, src/cloudeventjekylldocs/tests, src/eventcatalogasyncapiimporter/tests, src/cloudevents/tools/builder/__tests__, src/cloudevents/tools/cache/__tests__, src/cloudevents/tools/generator/__tests__, lambdas/mesh-poll/src/__tests__, lambdas/ttl-create-lambda/src/__tests__, lambdas/ttl-poll-lambda/src/__tests__, utils/utils/src/__tests__ -sonar.test.inclusions=tests/**, src/**/tests/**, src/**/__tests__/**, lambdas/**/src/__tests__/**, utils/utils/src/__tests__/** +sonar.tests=tests/, src/asyncapigenerator/tests, src/cloudeventjekylldocs/tests, src/eventcatalogasyncapiimporter/tests, src/cloudevents/tools/builder/__tests__, src/cloudevents/tools/cache/__tests__, src/cloudevents/tools/generator/__tests__, lambdas/mesh-poll/src/__tests__, lambdas/ttl-create-lambda/src/__tests__, lambdas/ttl-poll-lambda/src/__tests__, utils/utils/src/__tests__, utils/sender-management/src/__tests__ +sonar.test.inclusions=tests/**, src/**/tests/**, src/**/__tests__/**, lambdas/**/src/__tests__/**, utils/utils/src/__tests__/**, utils/sender-management/src/__tests__/** sonar.terraform.provider.aws.version=5.54.1 sonar.cpd.exclusions=**.test.* sonar.coverage.exclusions=tests/**, src/**/tests/**, src/**/__tests__/**, **/*.dev.*, lambdas/**/src/__tests__/**, **/jest.config.ts, **/jest.config.cjs, scripts/**/*.*, docs/**/*.*, utils/utils/src/__tests__/**, src/asyncapigenerator/example_usage.py, src/asyncapigenerator/test_generator.py, src/eventcatalogasyncapiimporter/examples.py diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 0ad530c94..cf79926a7 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -80,6 +80,7 @@ sample_service_markdown sanitization schemaDir sed +senderId service_sorted setCachedSchema Someobject @@ -92,5 +93,6 @@ toolchain Trufflehog updateReadme Uploader +[Uu][Aa][Tt] uuid [Vv]alidator diff --git a/utils/sender-management/README.md b/utils/sender-management/README.md new file mode 100644 index 000000000..7a71c952e --- /dev/null +++ b/utils/sender-management/README.md @@ -0,0 +1,146 @@ +# sender-management + +TypeScript Library / CLI utility for managing comms manager sender configuration. These are currently stored in SSM Parameter Store. + +## Usage + +### CLI Usage + +From the repo root run: + +```bash +npm --prefix utils/sender-management run-script cli -- [options] +``` + +### Library Usage + +Install the package as `@sender-management` + +Instantiate an instance of the library as follows. The library should take an implementation of an `IParameterStore` to define how the library will interact with SSM (e.g. caching vs non-caching). + +```ts +import { SenderManagement } from '@sender-management'; + +const sm = SenderManagement({ parameterStore: new ParameterStore() }); +``` + +### Global Options + +#### CLI Options + +- `--environment` - The name of the environment to run the command on e.g. 'pr123', 'main', 'uat', 'prod'. Required. +- `--format` - print data in json or tabular format. Default is `table`. + +#### Library Options + +```ts +const sm = SenderManagement({ + parameterStore: new ParameterStore(), + configOverrides: { environment: 'pr123' }, +}); +``` + +## Commands + +### Sender Configuration Commands + +- [Put Sender](#put-sender) +- [List Senders](#list-senders) +- [Get Sender](#get-sender) +- [Delete Sender](#delete-sender) + +#### Put Sender + +Insert a new sender or update an existing one. Omit the `--sender-id` option to insert a new sender. Include it to update an existing sender. Note: the INT and PROD senderIds should be the same ID. + +##### Put Sender Options + +- `--sender-id` - the ID of the sender to update. (defaults to uuid. Should typically be excluded unless overwriting an existing sender). It cannot contain spaces. +- `--sender-name` - the display name of the sender. Will throw an error if this name is already taken. Unique across all the senders. (required) +- `--mesh-mailbox-sender-id` - the mesh mailbox id for this sender. Unique across all the senders. (required) +- `--mesh-mailbox-reports-id` - the mesh mailbox id used for reporting for this sender. It can be the same as mesh-mailbox-sender-id. (required) +- `--fallback-wait-time-seconds` - the fallback wait time to print letters. (required) (number) +- `--routing-config-id` - the routing configuration id. + +##### Put Sender Examples + +```bash +npm --prefix utils/sender-management run-script cli -- put-sender \ + --sender-name 'Derby & Burton Trust' \ + --mesh-mailbox-sender-id 'DerbyMailboxId' \ + --mesh-mailbox-reports-id 'DerbyMailboxReportsId' \ + --fallback-wait-time-seconds 100 \ + --routing-config-id 'abc123' \ + --environment 'pr123' +``` + +```bash +npm --prefix utils/sender-management run-script cli -- put-sender \ + --sender-id 'integration_test_sender' \ + --sender-name 'integration test sender' \ + --mesh-mailbox-sender-id '123456' \ + --mesh-mailbox-reports-id '123456' \ + --fallback-wait-time-seconds 100 \ + --routing-config-id 'abc123' \ + --environment 'pr123' +``` + +```ts +const sender = await sm.putSender({ + senderName: 'vaccs', + meshMailboxSenderId: 'meshMailbox1234', + meshMailboxReportsId: 'meshMailboxReport1234', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', +}); +``` + +#### List Senders + +Return a list of all existing senders + +##### List Senders Examples + +```bash +npm --prefix utils/sender-management run-script cli -- list-senders --environment pr123 +``` + +```ts +const senders = await sm.listSenders(); +``` + +#### Get Sender + +Return an individual sender by senderId + +##### Get Sender Examples + +```bash +npm --prefix utils/sender-management run-script cli -- get-sender \ + --sender-id 'integration_test_sender' \ + --environment 'pr123' +``` + +```ts +const sender = await sm.getSender({ + senderId: 'integration_test_sender', +}); +``` + +#### Delete Sender + +Delete an individual sender by senderId. + +##### Delete Sender Examples + +```bash +npm --prefix utils/sender-management run-script cli -- delete-sender \ + --sender-id 'integration_test_sender' \ + --environment pr123 +``` + +```ts +const sender = await sm.deleteSender({ + senderId: 'integration_test_sender', +}); +``` diff --git a/utils/sender-management/jest.config.ts b/utils/sender-management/jest.config.ts new file mode 100644 index 000000000..6302d44e8 --- /dev/null +++ b/utils/sender-management/jest.config.ts @@ -0,0 +1,14 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +config.coverageThreshold = { + global: { + branches: 84, + functions: 91, + lines: 90, + statements: -10, + }, +}; + +export default config; diff --git a/utils/sender-management/package.json b/utils/sender-management/package.json new file mode 100644 index 000000000..e008904ad --- /dev/null +++ b/utils/sender-management/package.json @@ -0,0 +1,30 @@ +{ + "dependencies": { + "@aws-sdk/client-ssm": "^3.914.0", + "@types/yargs": "^17.0.33", + "tsx": "^4.20.6", + "utils": "^0.0.1", + "yargs": "^17.7.2", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + }, + "main": "src/index.ts", + "name": "sender-management", + "private": true, + "scripts": { + "cli": "tsx ./src/entrypoint/cli/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "sideEffects": false, + "version": "0.0.1" +} diff --git a/utils/sender-management/src/__tests__/app/delete-sender.test.ts b/utils/sender-management/src/__tests__/app/delete-sender.test.ts new file mode 100644 index 000000000..cfd131750 --- /dev/null +++ b/utils/sender-management/src/__tests__/app/delete-sender.test.ts @@ -0,0 +1,31 @@ +import { mockDeep } from 'jest-mock-extended'; +import { AppDependencies, createApp } from '../../app'; +import { DeleteSenderCommandParameters } from '../../app/delete-sender'; + +function setup() { + const mocks = mockDeep({ + infra: { + senderRepository: { + deleteSender: jest.fn(), + }, + }, + }); + + return mocks; +} + +describe('deleteSender', () => { + it('deletes the sender from the sender repository by id', async () => { + const mocks = setup(); + + const app = createApp(mocks); + + const input: DeleteSenderCommandParameters = { senderId: 'input_id' }; + + await app.deleteSender(input); + + expect(mocks.infra.senderRepository.deleteSender).toHaveBeenCalledWith( + input.senderId, + ); + }); +}); diff --git a/utils/sender-management/src/__tests__/app/get-sender.test.ts b/utils/sender-management/src/__tests__/app/get-sender.test.ts new file mode 100644 index 000000000..b8a1a614f --- /dev/null +++ b/utils/sender-management/src/__tests__/app/get-sender.test.ts @@ -0,0 +1,43 @@ +import { mockDeep } from 'jest-mock-extended'; +import { Sender } from 'utils'; +import { AppDependencies, createApp } from '../../app'; +import { GetSenderCommandParameters } from '../../app/get-sender'; + +function setup() { + const sender: Sender = { + senderId: 'test_sender_id', + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_sender_id', + meshMailboxReportsId: 'test_sender_mesh_mailbox_reports_id', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }; + + const mocks = mockDeep({ + infra: { + senderRepository: { + getSender: jest.fn().mockResolvedValueOnce(sender), + }, + }, + }); + + return { mocks, data: { sender } }; +} + +describe('getSender', () => { + it('retrieves the sender from the sender repository by id and returns it', async () => { + const { data, mocks } = setup(); + + const app = createApp(mocks); + + const input: GetSenderCommandParameters = { senderId: 'input_id' }; + + const result = await app.getSender(input); + + expect(mocks.infra.senderRepository.getSender).toHaveBeenCalledWith( + input.senderId, + ); + + expect(result).toBe(data.sender); + }); +}); diff --git a/utils/sender-management/src/__tests__/app/list-senders.test.ts b/utils/sender-management/src/__tests__/app/list-senders.test.ts new file mode 100644 index 000000000..84106b88f --- /dev/null +++ b/utils/sender-management/src/__tests__/app/list-senders.test.ts @@ -0,0 +1,39 @@ +import { mockDeep } from 'jest-mock-extended'; +import { Sender } from 'utils'; +import { AppDependencies, createApp } from '../../app'; + +function setup() { + const senders: Sender[] = [ + { + senderId: 'test_sender_id', + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_sender_id', + meshMailboxReportsId: 'test_sender_mesh_mailbox_reports_id', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }, + ]; + + const mocks = mockDeep({ + infra: { + senderRepository: { + listSenders: jest.fn().mockResolvedValueOnce(senders), + }, + }, + }); + + return { mocks, data: { senders } }; +} + +describe('listSenders', () => { + it('retrieves senders from the sender repository and returns them', async () => { + const { data, mocks } = setup(); + + const app = createApp(mocks); + + const result = await app.listSenders(); + + expect(mocks.infra.senderRepository.listSenders).toHaveBeenCalled(); + expect(result).toBe(data.senders); + }); +}); diff --git a/utils/sender-management/src/__tests__/app/put-sender.test.ts b/utils/sender-management/src/__tests__/app/put-sender.test.ts new file mode 100644 index 000000000..eb161bcff --- /dev/null +++ b/utils/sender-management/src/__tests__/app/put-sender.test.ts @@ -0,0 +1,147 @@ +import { mockDeep } from 'jest-mock-extended'; +import { Sender } from 'utils'; +import { ConflictException, ValidationException } from 'domain/exceptions/'; +import { PutSenderCommandParameters } from '../../app/put-sender'; +import { AppDependencies, createApp } from '../../app'; + +const input: PutSenderCommandParameters = { + senderId: 'test_sender_id', + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_id', + meshMailboxReportsId: 'test_sender_mesh_workflow_id_suffix', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', +}; + +const sender: Sender = { + senderId: 'test_sender_id', + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_id', + meshMailboxReportsId: 'test_sender_mesh_workflow_id_suffix', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', +}; + +function setup(existingSenders: Sender[] = [], createSenderResponse = sender) { + const mocks = mockDeep({ + domain: { + sender: { + createSender: jest.fn(() => createSenderResponse), + }, + }, + infra: { + senderRepository: { + putSender: jest.fn(), + listSenders: jest.fn().mockResolvedValue(existingSenders), + }, + }, + }); + + return { mocks, data: { sender } }; +} + +describe('putSender', () => { + it('creates a new sender when not existing senders, stores it in the sender repository and returns it', async () => { + const { data, mocks } = setup([]); + + const app = createApp(mocks); + delete input.senderId; // simulate no senderId provided + const result = await app.putSender(input); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith(input); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledWith( + data.sender, + ); + expect(result).toBe(data.sender); + }); + + it('creates a new sender when existing senders, stores it in the sender repository and returns it', async () => { + const existingSender: Sender = { + ...sender, + senderId: 'existing_client_id', + meshMailboxSenderId: 'existing_mesh_mailbox_sender_id', + senderName: 'new_client_name', + }; + const { data, mocks } = setup([existingSender]); + + const app = createApp(mocks); + + const result = await app.putSender(input); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith(input); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledWith( + data.sender, + ); + expect(result).toBe(data.sender); + }); + + it('Updates sender when it exists, stores it in the sender repository and returns it', async () => { + const existingSender: Sender = { + ...sender, + meshMailboxSenderId: 'existing_mesh_mailbox_sender_id', + }; + const { data, mocks } = setup([existingSender]); + + const app = createApp(mocks); + + const result = await app.putSender(input); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith(input); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledWith( + data.sender, + ); + expect(result).toBe(data.sender); + }); + + it('throws an error when a different existing sender has the same mailbox sender ID', async () => { + const existingSender: Sender = { + ...sender, + senderId: 'existing_client_id', + senderName: 'new_client_name', + }; + const { mocks } = setup([existingSender]); + + const app = createApp(mocks); + + await expect(app.putSender(input)).rejects.toThrow(ConflictException); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith(input); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledTimes(0); + }); + + it('throws an error when a different existing sender has the same sender name', async () => { + const existingSender: Sender = { + ...sender, + senderId: 'existing_client_id', + meshMailboxSenderId: 'new_mesh_mailbox_sender_id', + }; + const { mocks } = setup([existingSender]); + + const app = createApp(mocks); + + await expect(app.putSender(input)).rejects.toThrow(ConflictException); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith(input); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledTimes(0); + }); + + it('throws an error when the incoming sender is not valid', async () => { + const newInvalidClient: Sender = { + ...sender, + }; + delete (newInvalidClient as Partial).meshMailboxSenderId; + + const { mocks } = setup([], newInvalidClient); + + const app = createApp(mocks); + + await expect(app.putSender(newInvalidClient)).rejects.toThrow( + ValidationException, + ); + + expect(mocks.domain.sender.createSender).toHaveBeenCalledWith( + newInvalidClient, + ); + expect(mocks.infra.senderRepository.putSender).toHaveBeenCalledTimes(0); + }); +}); diff --git a/utils/sender-management/src/__tests__/config/config.test.ts b/utils/sender-management/src/__tests__/config/config.test.ts new file mode 100644 index 000000000..2c3af52ac --- /dev/null +++ b/utils/sender-management/src/__tests__/config/config.test.ts @@ -0,0 +1,21 @@ +import { Config, loadConfig } from 'config/config'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn().mockReturnValue('default-unit-test-environment'), + }, +})); + +describe('loadConfig', () => { + it('should load default configuration', () => { + const config: Partial = {}; + const loadedConfig = loadConfig(config); + expect(loadedConfig.environment).toEqual('default-unit-test-environment'); + }); + + it('should load overriden configuration', () => { + const config: Partial = { environment: 'overrides-unit-test' }; + const loadedConfig = loadConfig(config); + expect(loadedConfig.environment).toEqual('overrides-unit-test'); + }); +}); diff --git a/utils/sender-management/src/__tests__/container.test.ts b/utils/sender-management/src/__tests__/container.test.ts new file mode 100644 index 000000000..fa8b63b9f --- /dev/null +++ b/utils/sender-management/src/__tests__/container.test.ts @@ -0,0 +1,65 @@ +import { createSenderManager } from '../container'; +import { createApp } from '../app'; +import { createDomain } from '../domain'; +import { createInfra } from '../infra'; +import { loadConfig } from '../config/config'; + +jest.mock('../app', () => ({ + createApp: jest.fn(), +})); + +jest.mock('../domain', () => ({ + createDomain: jest.fn(), +})); + +jest.mock('../infra', () => ({ + createInfra: jest.fn(), +})); + +jest.mock('../config/config', () => ({ + loadConfig: jest.fn(), +})); + +describe('createSenderManager', () => { + const mockParameterStore = { + getParameter: jest.fn(), + getAllParameters: jest.fn(), + addParameter: jest.fn(), + deleteParameter: jest.fn(), + clearCachedParameter: jest.fn(), + }; + const mockConfigOverrides = {}; + const mockConfig = { key: 'value' }; + const mockDomain = {}; + const mockInfra = {}; + const mockApp = {}; + + beforeEach(() => { + jest.clearAllMocks(); + + (loadConfig as jest.Mock).mockReturnValue(mockConfig); + (createDomain as jest.Mock).mockReturnValue(mockDomain); + (createInfra as jest.Mock).mockReturnValue(mockInfra); + (createApp as jest.Mock).mockReturnValue(mockApp); + }); + + it('should create the sender manager with the correct dependencies', () => { + const result = createSenderManager({ + configOverrides: mockConfigOverrides, + parameterStore: mockParameterStore, + }); + + expect(loadConfig).toHaveBeenCalledWith(mockConfigOverrides); + expect(createDomain).toHaveBeenCalled(); + expect(createInfra).toHaveBeenCalledWith({ + config: mockConfig, + parameterStore: mockParameterStore, + logger: expect.any(Object), + }); + expect(createApp).toHaveBeenCalledWith({ + domain: mockDomain, + infra: mockInfra, + }); + expect(result).toBe(mockApp); + }); +}); diff --git a/utils/sender-management/src/__tests__/domain/sender.test.ts b/utils/sender-management/src/__tests__/domain/sender.test.ts new file mode 100644 index 000000000..427ab91cc --- /dev/null +++ b/utils/sender-management/src/__tests__/domain/sender.test.ts @@ -0,0 +1,48 @@ +import { CreateSenderParameters, createSender } from '../../domain/sender'; + +const mockRandomUUID = 'test_random_uuid'; + +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(() => mockRandomUUID), +})); + +describe('createSender', () => { + it('creates a sender entity using senderId specified', () => { + const input: CreateSenderParameters = { + senderId: 'test_sender_id', + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_sender_id', + meshMailboxReportsId: 'test_sender_mesh_mailbox_reports_id', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }; + + expect(createSender(input)).toEqual({ + senderId: input.senderId, + senderName: input.senderName, + meshMailboxSenderId: input.meshMailboxSenderId, + meshMailboxReportsId: input.meshMailboxReportsId, + fallbackWaitTimeSeconds: input.fallbackWaitTimeSeconds, + routingConfigId: input.routingConfigId, + }); + }); + + it('creates a sender entity defaulting senderId to be an uuid', () => { + const input: CreateSenderParameters = { + senderName: 'test_sender_name', + meshMailboxSenderId: 'test_sender_mesh_mailbox_sender_id', + meshMailboxReportsId: 'test_sender_mesh_mailbox_reports_id', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }; + + expect(createSender(input)).toEqual({ + senderId: mockRandomUUID, + senderName: input.senderName, + meshMailboxSenderId: input.meshMailboxSenderId, + meshMailboxReportsId: input.meshMailboxReportsId, + fallbackWaitTimeSeconds: input.fallbackWaitTimeSeconds, + routingConfigId: input.routingConfigId, + }); + }); +}); diff --git a/utils/sender-management/src/__tests__/entrypoint/cli/index.test.ts b/utils/sender-management/src/__tests__/entrypoint/cli/index.test.ts new file mode 100644 index 000000000..a4ef4e754 --- /dev/null +++ b/utils/sender-management/src/__tests__/entrypoint/cli/index.test.ts @@ -0,0 +1,303 @@ +import { mockDeep } from 'jest-mock-extended'; +import { IParameterStore, Sender } from 'utils'; +import type { App } from 'app'; +import { SenderManagement } from '../../..'; +import { main } from '../../../entrypoint/cli'; + +// Store original argv +const originalArgv = process.argv; +const originalExitCode = process.exitCode; + +const mockParameterStore: IParameterStore = { + getParameter: jest.fn(), + getAllParameters: jest.fn(), + addParameter: jest.fn(), + deleteParameter: jest.fn(), + clearCachedParameter: jest.fn(), +}; + +const mockClientManagement: App = mockDeep({ + deleteSender: jest.fn(), + getSender: jest.fn(), + listSenders: jest.fn(), + putSender: jest.fn(), +}); + +jest.mock('utils', () => ({ + ParameterStore: jest.fn(() => mockParameterStore), +})); + +jest.mock('../../..', () => ({ + SenderManagement: jest.fn(() => mockClientManagement), +})); + +// Mock console methods +const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); +const mockConsoleTable = jest.spyOn(console, 'table').mockImplementation(); +const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + +describe('CLI entrypoint', () => { + const mockClient: Sender = { + senderId: 'test-sender-id', + senderName: 'Test Sender', + meshMailboxSenderId: 'test-sender', + meshMailboxReportsId: 'test-reports', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'test-routing', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + mockConsoleLog.mockRestore(); + mockConsoleTable.mockRestore(); + mockConsoleError.mockRestore(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + }); + + describe('delete-sender command', () => { + it('should delete a sender and print result as table by default', async () => { + process.argv = [ + 'node', + 'cli', + 'delete-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + ]; + + // Import and execute main + await main(); + + expect(SenderManagement).toHaveBeenCalledWith({ + parameterStore: mockParameterStore, + configOverrides: { environment: 'test' }, + }); + expect(mockClientManagement.deleteSender).toHaveBeenCalledWith({ + senderId: 'test-sender-id', + }); + expect(mockConsoleTable).toHaveBeenCalledWith([ + { senderId: 'test-sender-id' }, + ]); + }); + + it('should delete a sender and print result as JSON when format is json', async () => { + process.argv = [ + 'node', + 'cli', + 'delete-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + '--format', + 'json', + ]; + + await main(); + + expect(mockClientManagement.deleteSender).toHaveBeenCalledWith({ + senderId: 'test-sender-id', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + JSON.stringify({ senderId: 'test-sender-id' }, null, 2), + ); + }); + }); + + describe('get-sender command', () => { + it('should get a sender and print result as table by default', async () => { + (mockClientManagement.getSender as jest.Mock).mockResolvedValue( + mockClient, + ); + + process.argv = [ + 'node', + 'cli', + 'get-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + ]; + + await main(); + + expect(mockClientManagement.getSender).toHaveBeenCalledWith({ + senderId: 'test-sender-id', + }); + expect(mockConsoleTable).toHaveBeenCalledWith([mockClient]); + }); + + it('should get a sender and print result as JSON when format is json', async () => { + (mockClientManagement.getSender as jest.Mock).mockResolvedValue( + mockClient, + ); + + process.argv = [ + 'node', + 'cli', + 'get-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + '--format', + 'json', + ]; + + await main(); + + expect(mockClientManagement.getSender).toHaveBeenCalledWith({ + senderId: 'test-sender-id', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + JSON.stringify(mockClient, null, 2), + ); + }); + }); + + describe('list-senders command', () => { + it('should list senders and print result as table by default', async () => { + const senders = [mockClient]; + (mockClientManagement.listSenders as jest.Mock).mockResolvedValue( + senders, + ); + + process.argv = ['node', 'cli', 'list-senders', '--environment', 'test']; + + await main(); + + expect(mockClientManagement.listSenders).toHaveBeenCalled(); + expect(mockConsoleTable).toHaveBeenCalledWith(senders); + }); + + it('should list senders and print result as JSON when format is json', async () => { + const senders = [mockClient]; + (mockClientManagement.listSenders as jest.Mock).mockResolvedValue( + senders, + ); + + process.argv = [ + 'node', + 'cli', + 'list-senders', + '--environment', + 'test', + '--format', + 'json', + ]; + + await main(); + + expect(mockClientManagement.listSenders).toHaveBeenCalled(); + expect(mockConsoleLog).toHaveBeenCalledWith( + JSON.stringify(senders, null, 2), + ); + }); + }); + + describe('put-sender command', () => { + it('should put a sender and print result as table by default', async () => { + (mockClientManagement.putSender as jest.Mock).mockResolvedValue( + mockClient, + ); + + process.argv = [ + 'node', + 'cli', + 'put-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + '--sender-name', + 'Test Sender', + '--mesh-mailbox-sender-id', + 'test-sender', + '--mesh-mailbox-reports-id', + 'test-reports', + '--fallback-wait-time-seconds', + '300', + '--routing-config-id', + 'test-routing', + ]; + + await main(); + + expect(mockClientManagement.putSender).toHaveBeenCalledWith({ + senderId: 'test-sender-id', + senderName: 'Test Sender', + meshMailboxSenderId: 'test-sender', + meshMailboxReportsId: 'test-reports', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'test-routing', + }); + expect(mockConsoleTable).toHaveBeenCalledWith([mockClient]); + }); + + it('should put a sender without sender-id and print result as JSON when format is json', async () => { + (mockClientManagement.putSender as jest.Mock).mockResolvedValue( + mockClient, + ); + + process.argv = [ + 'node', + 'cli', + 'put-sender', + '--environment', + 'test', + '--sender-name', + 'Test Sender', + '--mesh-mailbox-sender-id', + 'test-sender', + '--mesh-mailbox-reports-id', + 'test-reports', + '--fallback-wait-time-seconds', + '300', + '--routing-config-id', + 'test-routing', + '--format', + 'json', + ]; + + await main(); + + expect(mockClientManagement.putSender).toHaveBeenCalledWith({ + senderId: undefined, + senderName: 'Test Sender', + meshMailboxSenderId: 'test-sender', + meshMailboxReportsId: 'test-reports', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'test-routing', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + JSON.stringify(mockClient, null, 2), + ); + }); + }); + + describe('error handling', () => { + it('should handle errors and set exit code to 1', async () => { + const error = new Error('Test error'); + (mockClientManagement.getSender as jest.Mock).mockRejectedValue(error); + + process.argv = [ + 'node', + 'cli', + 'get-sender', + '--environment', + 'test', + '--sender-id', + 'test-sender-id', + ]; + + // Expect main to throw the error + await expect(main()).rejects.toThrow('Test error'); + }); + }); +}); diff --git a/utils/sender-management/src/__tests__/infra/index.test.ts b/utils/sender-management/src/__tests__/infra/index.test.ts new file mode 100644 index 000000000..0f5af369f --- /dev/null +++ b/utils/sender-management/src/__tests__/infra/index.test.ts @@ -0,0 +1,11 @@ +import { Dependencies, createInfra } from 'infra'; +import { mock } from 'jest-mock-extended'; + +describe('createInfra', () => { + it('should create infra', () => { + const dependencies = mock(); + + const senderRepository = createInfra(dependencies); + expect(senderRepository).toBeDefined(); + }); +}); diff --git a/utils/sender-management/src/__tests__/infra/sender-repository/repository.test.ts b/utils/sender-management/src/__tests__/infra/sender-repository/repository.test.ts new file mode 100644 index 000000000..ee619f809 --- /dev/null +++ b/utils/sender-management/src/__tests__/infra/sender-repository/repository.test.ts @@ -0,0 +1,268 @@ +import { Parameter, ParameterNotFound } from '@aws-sdk/client-ssm'; +import { IParameterStore, Sender } from 'utils'; +import { mockDeep } from 'jest-mock-extended'; +import { SenderRepository } from '../../../infra/sender-repository/repository'; + +let logger: any; + +function setup() { + logger = { info: jest.fn(), error: jest.fn() }; + + const mocks = { + config: { + environment: 'test_environment', + }, + parameterStore: mockDeep({ + addParameter: jest.fn(), + getParameter: jest.fn().mockResolvedValue({ + Value: JSON.stringify({ + senderId: 'old_client_id_1', + senderName: 'old_client_name_1', + meshMailboxSenderId: 'old_client_mesh_mailbox_sender_id_1', + meshMailboxReportsId: 'old_client_mesh_mailbox_reports_id_1', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }), + }), + deleteParameter: jest.fn(), + }), + logger, + }; + + const senders: Sender[] = [ + { + senderId: 'old_client_id_1', + senderName: 'old_client_name_1', + meshMailboxSenderId: 'old_client_mesh_mailbox_sender_id_1', + meshMailboxReportsId: 'old_client_mesh_mailbox_reports_id_1', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }, + { + senderId: 'old_client_id_2', + senderName: 'old_client_name_2', + meshMailboxSenderId: 'old_client_mesh_mailbox_sender_id_2', + meshMailboxReportsId: 'old_client_mesh_mailbox_reports_id_2', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }, + ]; + + const newClient: Sender = { + senderId: 'new_client_id', + senderName: 'new_client_name', + meshMailboxSenderId: 'new_client_mesh_mailbox_sender_id', + meshMailboxReportsId: 'new_client_mesh_mailbox_reports_id', + fallbackWaitTimeSeconds: 300, + routingConfigId: '1234', + }; + + mocks.parameterStore.getAllParameters.mockResolvedValue( + senders.map((c) => ({ + Name: c.senderName, + Value: JSON.stringify(c), + })), + ); + + return { mocks, data: { senders, newClient } }; +} + +describe('putSender', () => { + it('inserts a new sender into the ssm parameter', async () => { + const { data, mocks } = setup(); + + const repository = new SenderRepository(mocks); + + await repository.putSender(data.newClient); + + expect(mocks.parameterStore.addParameter).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.addParameter).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/${data.newClient.senderId}`, + JSON.stringify(data.newClient), + ); + }); + + it('overwrites an existing sender with the same senderId', async () => { + const { data, mocks } = setup(); + + const repository = new SenderRepository(mocks); + + const [existingSender] = data.senders; + + const updatedClient = { + ...data.newClient, + senderId: existingSender.senderId, + }; + + await repository.putSender(updatedClient); + + expect(mocks.parameterStore.addParameter).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.addParameter).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/${existingSender.senderId}`, + JSON.stringify(updatedClient), + ); + }); +}); + +describe('listSenders', () => { + it('returns the list of senders from ssm parameter store', async () => { + const { data, mocks } = setup(); + + const repository = new SenderRepository(mocks); + + const result = await repository.listSenders(); + + expect(mocks.parameterStore.getAllParameters).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.getAllParameters).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/`, + { force: undefined }, + ); + + expect(result).toEqual(data.senders); + }); + + it('should not include any params that contain malformed senders', async () => { + const { data, mocks } = setup(); + + const invalidClientParams: Parameter[] = [ + { + Name: `/dl/${mocks.config.environment}/senders/sender-has-no-value`, + Version: 2, + }, + { + Name: `/dl/${mocks.config.environment}/senders/sender-is-not-json`, + Value: 'not-a-sender', + Version: 2, + }, + { + Name: `/dl/${mocks.config.environment}/senders/sender-is-missing-required-fields`, + Value: '{"senderId":"sender-id"}', + Version: 2, + }, + ]; + + mocks.parameterStore.getAllParameters.mockResolvedValue([ + ...data.senders.map((c) => ({ + Name: c.senderName, + Value: JSON.stringify(c), + })), + ...invalidClientParams, + ]); + + const repository = new SenderRepository(mocks); + + const result = await repository.listSenders(); + expect(result).toEqual(data.senders); + }); +}); + +describe('getSender', () => { + it('returns the sender with the given senderId', async () => { + const { data, mocks } = setup(); + + const [sender] = data.senders; + + const repository = new SenderRepository(mocks); + + const result = await repository.getSender(sender.senderId); + + expect(mocks.parameterStore.getParameter).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.getParameter).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/${sender.senderId}`, + ); + + expect(result).toEqual(sender); + }); + + it('returns null if a ParameterNotFound exception is raised', async () => { + expect.hasAssertions(); + + const { mocks } = setup(); + + const repository = new SenderRepository(mocks); + + mocks.parameterStore.getParameter.mockRejectedValueOnce( + new ParameterNotFound({ message: 'ParameterNotFound', $metadata: {} }), + ); + + const result = await repository.getSender('this-senderId-does-not-exist'); + + expect(result).toBeNull(); + }); + + it('raises other exceptions', async () => { + expect.hasAssertions(); + + const { mocks } = setup(); + + const repository = new SenderRepository(mocks); + + const e = new Error('Something went wrong'); + + mocks.parameterStore.getParameter.mockRejectedValueOnce(e); + + let caught: unknown; + try { + await repository.getSender('this-senderId-does-not-exist'); + } catch (error) { + caught = error; + } + expect(caught).toBe(e); + }); +}); + +describe('deleteSender', () => { + it('deletes the sender with the given senderId', async () => { + const { mocks } = setup(); + + const repository = new SenderRepository(mocks); + + await repository.deleteSender('some-id'); + + expect(mocks.parameterStore.deleteParameter).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.deleteParameter).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/some-id`, + ); + + expect(1).toEqual(1); + }); + + it('handles ParameterNotFound exceptions', async () => { + expect.hasAssertions(); + + const { mocks } = setup(); + + const repository = new SenderRepository(mocks); + + mocks.parameterStore.deleteParameter.mockRejectedValueOnce( + new ParameterNotFound({ message: 'ParameterNotFound', $metadata: {} }), + ); + + await repository.deleteSender('some-id'); + + expect(mocks.parameterStore.deleteParameter).toHaveBeenCalledTimes(1); + expect(mocks.parameterStore.deleteParameter).toHaveBeenCalledWith( + `/dl/${mocks.config.environment}/senders/some-id`, + ); + }); + + it('raises other exceptions', async () => { + expect.hasAssertions(); + + const { mocks } = setup(); + + const repository = new SenderRepository(mocks); + + const e = new Error('Something went wrong'); + + mocks.parameterStore.deleteParameter.mockRejectedValueOnce(e); + + let caught: unknown; + try { + await repository.deleteSender('some-id'); + } catch (error) { + caught = error; + } + expect(caught).toBe(e); + }); +}); diff --git a/utils/sender-management/src/app/delete-sender.ts b/utils/sender-management/src/app/delete-sender.ts new file mode 100644 index 000000000..3a5ec2a25 --- /dev/null +++ b/utils/sender-management/src/app/delete-sender.ts @@ -0,0 +1,12 @@ +import { Sender } from 'utils'; +import { AppDependencies } from './types'; + +export type DeleteSenderCommandParameters = Pick; + +export function createDeleteSenderCommand({ infra }: AppDependencies) { + return async function deleteSenderCommand( + params: DeleteSenderCommandParameters, + ): Promise { + await infra.senderRepository.deleteSender(params.senderId); + }; +} diff --git a/utils/sender-management/src/app/get-sender.ts b/utils/sender-management/src/app/get-sender.ts new file mode 100644 index 000000000..30ae3abb0 --- /dev/null +++ b/utils/sender-management/src/app/get-sender.ts @@ -0,0 +1,12 @@ +import { Sender } from 'utils'; +import { AppDependencies } from './types'; + +export type GetSenderCommandParameters = Pick; + +export function createGetSenderCommand({ infra }: AppDependencies) { + return async function getSenderCommand( + params: GetSenderCommandParameters, + ): Promise { + return infra.senderRepository.getSender(params.senderId); + }; +} diff --git a/utils/sender-management/src/app/index.ts b/utils/sender-management/src/app/index.ts new file mode 100644 index 000000000..9108a19e5 --- /dev/null +++ b/utils/sender-management/src/app/index.ts @@ -0,0 +1,17 @@ +import { createDeleteSenderCommand } from './delete-sender'; +import { createGetSenderCommand } from './get-sender'; +import { createListSendersCommand } from './list-senders'; +import { createPutSenderCommand } from './put-sender'; +import { AppDependencies } from './types'; + +export function createApp(dependencies: AppDependencies) { + return { + deleteSender: createDeleteSenderCommand(dependencies), + getSender: createGetSenderCommand(dependencies), + listSenders: createListSendersCommand(dependencies), + putSender: createPutSenderCommand(dependencies), + }; +} + +export type App = ReturnType; +export * from './types'; diff --git a/utils/sender-management/src/app/list-senders.ts b/utils/sender-management/src/app/list-senders.ts new file mode 100644 index 000000000..1359993af --- /dev/null +++ b/utils/sender-management/src/app/list-senders.ts @@ -0,0 +1,10 @@ +import { Sender } from 'utils'; +import { AppDependencies } from './types'; + +export function createListSendersCommand({ infra }: AppDependencies) { + return async function listSendersCommand(options?: { + skipCache?: boolean; + }): Promise { + return infra.senderRepository.listSenders(options); + }; +} diff --git a/utils/sender-management/src/app/put-sender.ts b/utils/sender-management/src/app/put-sender.ts new file mode 100644 index 000000000..f842a6f1c --- /dev/null +++ b/utils/sender-management/src/app/put-sender.ts @@ -0,0 +1,51 @@ +import { $Sender, Sender } from 'utils'; +import { CreateSenderParameters } from '../domain/sender'; +import { ConflictException, ValidationException } from '../domain/exceptions'; +import { AppDependencies } from './types'; + +export type PutSenderCommandParameters = CreateSenderParameters; + +export async function validateSender( + sender: Sender, + infra: AppDependencies['infra'], +): Promise { + try { + $Sender.parse(sender); + } catch (error) { + throw new ValidationException(`Invalid new sender data ${error}`); + } + + const existingSenders = await infra.senderRepository.listSenders(); + + const conflicts = existingSenders.filter((s) => { + return ( + s.senderId !== sender.senderId && + (s.meshMailboxSenderId === sender.meshMailboxSenderId || + s.senderName === sender.senderName) + ); + }); + + if (conflicts.length > 0) { + throw new ConflictException(`Failed to create/update sender. Found conflicts: ${conflicts.length} + ${conflicts + .map( + (s) => + `senderId:${s.senderId} senderName:${s.senderName} meshMailboxSenderId:${s.meshMailboxSenderId}\n`, + ) + .join(', ')}`); + } +} + +export function createPutSenderCommand({ domain, infra }: AppDependencies) { + return async function putSenderCommand( + params: PutSenderCommandParameters, + ): Promise { + const sender = domain.sender.createSender(params); + + await validateSender(sender, infra); + + await infra.senderRepository.putSender(sender); + + return sender; + }; +} diff --git a/utils/sender-management/src/app/types.ts b/utils/sender-management/src/app/types.ts new file mode 100644 index 000000000..adf94af53 --- /dev/null +++ b/utils/sender-management/src/app/types.ts @@ -0,0 +1,7 @@ +import { Domain } from '../domain'; +import { Infrastructure } from '../infra'; + +export type AppDependencies = { + domain: Domain; + infra: Infrastructure; +}; diff --git a/utils/sender-management/src/config/config.ts b/utils/sender-management/src/config/config.ts new file mode 100644 index 000000000..d0dcebd4f --- /dev/null +++ b/utils/sender-management/src/config/config.ts @@ -0,0 +1,12 @@ +import { defaultConfigReader } from 'utils'; + +export type Config = { + environment: string; +}; + +export function loadConfig(overrides: Partial = {}): Config { + return { + environment: + overrides.environment || defaultConfigReader.getValue('ENVIRONMENT'), + }; +} diff --git a/utils/sender-management/src/container.ts b/utils/sender-management/src/container.ts new file mode 100644 index 000000000..d37c9a4c2 --- /dev/null +++ b/utils/sender-management/src/container.ts @@ -0,0 +1,21 @@ +import { IParameterStore, logger } from 'utils'; +import { createApp } from './app'; +import { type Config, loadConfig } from './config/config'; +import { createDomain } from './domain'; +import { createInfra } from './infra'; + +type Dependencies = { + parameterStore: IParameterStore; + configOverrides?: Partial; +}; + +export function createSenderManager({ + configOverrides, + parameterStore, +}: Dependencies) { + const config = loadConfig(configOverrides); + const domain = createDomain(); + const infra = createInfra({ config, parameterStore, logger }); + + return createApp({ domain, infra }); +} diff --git a/utils/sender-management/src/domain/exceptions/base.ts b/utils/sender-management/src/domain/exceptions/base.ts new file mode 100644 index 000000000..f9502d63f --- /dev/null +++ b/utils/sender-management/src/domain/exceptions/base.ts @@ -0,0 +1 @@ +export class BaseException extends Error {} diff --git a/utils/sender-management/src/domain/exceptions/conflict.ts b/utils/sender-management/src/domain/exceptions/conflict.ts new file mode 100644 index 000000000..e4497a6be --- /dev/null +++ b/utils/sender-management/src/domain/exceptions/conflict.ts @@ -0,0 +1,5 @@ +import { BaseException } from './base'; + +export class ConflictException extends BaseException { + name = 'ConflictException'; +} diff --git a/utils/sender-management/src/domain/exceptions/index.ts b/utils/sender-management/src/domain/exceptions/index.ts new file mode 100644 index 000000000..7421d7484 --- /dev/null +++ b/utils/sender-management/src/domain/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from './conflict'; +export * from './validation'; diff --git a/utils/sender-management/src/domain/exceptions/validation.ts b/utils/sender-management/src/domain/exceptions/validation.ts new file mode 100644 index 000000000..ded0797ee --- /dev/null +++ b/utils/sender-management/src/domain/exceptions/validation.ts @@ -0,0 +1,5 @@ +import { BaseException } from './base'; + +export class ValidationException extends BaseException { + name = 'ValidationException'; +} diff --git a/utils/sender-management/src/domain/index.ts b/utils/sender-management/src/domain/index.ts new file mode 100644 index 000000000..840052f28 --- /dev/null +++ b/utils/sender-management/src/domain/index.ts @@ -0,0 +1,11 @@ +import { createSender } from './sender'; + +export function createDomain() { + return { + sender: { + createSender, + }, + }; +} + +export type Domain = ReturnType; diff --git a/utils/sender-management/src/domain/sender.ts b/utils/sender-management/src/domain/sender.ts new file mode 100644 index 000000000..58d9eaf2c --- /dev/null +++ b/utils/sender-management/src/domain/sender.ts @@ -0,0 +1,16 @@ +import { randomUUID } from 'node:crypto'; +import { Sender } from 'utils'; + +export type CreateSenderParameters = Omit & + Partial>; + +export function createSender(parameters: CreateSenderParameters): Sender { + return { + senderId: parameters.senderId || randomUUID(), + senderName: parameters.senderName, + meshMailboxSenderId: parameters.meshMailboxSenderId, + meshMailboxReportsId: parameters.meshMailboxReportsId, + fallbackWaitTimeSeconds: parameters.fallbackWaitTimeSeconds, + routingConfigId: parameters.routingConfigId, + }; +} diff --git a/utils/sender-management/src/entrypoint/cli/index.ts b/utils/sender-management/src/entrypoint/cli/index.ts new file mode 100644 index 000000000..101665a22 --- /dev/null +++ b/utils/sender-management/src/entrypoint/cli/index.ts @@ -0,0 +1,137 @@ +import { ParameterStore } from 'utils'; +import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs/yargs'; +import { SenderManagement } from '../..'; + +type PrintFormat = 'json' | 'table'; +type PrintFunction = (value: unknown) => void; + +function getPrinter(format: PrintFormat): PrintFunction { + /* eslint-disable no-console */ + if (format === 'json') { + return (value) => console.log(JSON.stringify(value, null, 2)); + } + + return (value) => console.table(Array.isArray(value) ? value : [value]); + /* eslint-enable no-console */ +} + +export async function main() { + let senderManagement: ReturnType; + let print: PrintFunction; + + function setGlobals(argv: { environment?: string; format: string }) { + senderManagement = SenderManagement({ + parameterStore: new ParameterStore(), + configOverrides: { environment: argv.environment }, + }); + + print = getPrinter(argv.format as PrintFormat); + } + + await yargs(hideBin(process.argv)) + .exitProcess(false) + .option('environment', { + type: 'string', + global: true, + demandOption: true, + }) + .option('format', { + type: 'string', + choices: ['json', 'table'], + default: 'table', + global: true, + demandOption: false, + }) + .middleware(setGlobals) + .command( + 'delete-sender', + 'delete a specific sender by id', + { + 'sender-id': { + type: 'string', + demandOption: true, + }, + }, + async (argv) => { + await senderManagement.deleteSender({ + senderId: argv.senderId, + }); + + print({ senderId: argv.senderId }); + }, + ) + .command( + 'get-sender', + 'return a specific sender by id', + { + 'sender-id': { + type: 'string', + demandOption: true, + }, + }, + async (argv) => { + const sender = await senderManagement.getSender({ + senderId: argv.senderId, + }); + + print(sender); + }, + ) + .command('list-senders', 'return a list of senders', {}, async () => { + const senders = await senderManagement.listSenders(); + + print(senders); + }) + .command( + 'put-sender', + 'create or update a sender', + { + 'sender-id': { + type: 'string', + demandOption: false, + }, + 'sender-name': { + type: 'string', + demandOption: true, + }, + 'mesh-mailbox-sender-id': { + type: 'string', + demandOption: true, + }, + 'mesh-mailbox-reports-id': { + type: 'string', + demandOption: true, + }, + 'fallback-wait-time-seconds': { + type: 'number', + demandOption: true, + }, + 'routing-config-id': { + type: 'string', + demandOption: false, + }, + }, + async (argv) => { + const sender = await senderManagement.putSender({ + senderId: argv.senderId, + senderName: argv.senderName, + meshMailboxSenderId: argv.meshMailboxSenderId, + meshMailboxReportsId: argv.meshMailboxReportsId, + fallbackWaitTimeSeconds: argv.fallbackWaitTimeSeconds, + routingConfigId: argv.routingConfigId, + }); + + print(sender); + }, + ) + .demandCommand(1) + .parse(); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); // eslint-disable-line no-console + process.exitCode = 1; + }); +} diff --git a/utils/sender-management/src/index.ts b/utils/sender-management/src/index.ts new file mode 100644 index 000000000..546ca43b4 --- /dev/null +++ b/utils/sender-management/src/index.ts @@ -0,0 +1,2 @@ +export { createSenderManager as SenderManagement } from './container'; +export { type App as ISenderManagement } from './app'; diff --git a/utils/sender-management/src/infra/index.ts b/utils/sender-management/src/infra/index.ts new file mode 100644 index 000000000..8d68d17d4 --- /dev/null +++ b/utils/sender-management/src/infra/index.ts @@ -0,0 +1,22 @@ +import { IParameterStore, Logger } from 'utils'; +import { SenderRepository } from './sender-repository'; +import type { ISenderRepository } from './interfaces'; +import type { Config } from '../config/config'; + +export type Dependencies = { + config: Config; + parameterStore: IParameterStore; + logger: Logger; +}; + +export type Infrastructure = { + senderRepository: ISenderRepository; +}; + +export function createInfra(dependencies: Dependencies): Infrastructure { + const senderRepository = new SenderRepository(dependencies); + + return { + senderRepository, + }; +} diff --git a/utils/sender-management/src/infra/interfaces.ts b/utils/sender-management/src/infra/interfaces.ts new file mode 100644 index 000000000..41ecb116e --- /dev/null +++ b/utils/sender-management/src/infra/interfaces.ts @@ -0,0 +1,8 @@ +import { Sender } from 'utils'; + +export interface ISenderRepository { + deleteSender(id: string): Promise; + getSender(id: string): Promise; + listSenders(options?: { skipCache?: boolean }): Promise; + putSender(sender: Sender): Promise; +} diff --git a/utils/sender-management/src/infra/sender-repository/index.ts b/utils/sender-management/src/infra/sender-repository/index.ts new file mode 100644 index 000000000..6dcbad91f --- /dev/null +++ b/utils/sender-management/src/infra/sender-repository/index.ts @@ -0,0 +1 @@ +export * from './repository'; diff --git a/utils/sender-management/src/infra/sender-repository/repository.ts b/utils/sender-management/src/infra/sender-repository/repository.ts new file mode 100644 index 000000000..9db4062b2 --- /dev/null +++ b/utils/sender-management/src/infra/sender-repository/repository.ts @@ -0,0 +1,101 @@ +import { ParameterNotFound } from '@aws-sdk/client-ssm'; +import { $Sender, IParameterStore, Logger, Sender } from 'utils'; +import { Config } from '../../config/config'; +import { ISenderRepository } from '../interfaces'; + +export type Dependencies = { + config: Config; + parameterStore: IParameterStore; + logger: Logger; +}; + +export class SenderRepository implements ISenderRepository { + private readonly config: Config; + + private readonly parameterStore: IParameterStore; + + private readonly logger: Logger; + + constructor({ config, logger, parameterStore }: Dependencies) { + this.config = config; + this.parameterStore = parameterStore; + this.logger = logger; + } + + async putSender(sender: Sender): Promise { + await this.parameterStore.addParameter( + this.getParameterName(sender.senderId), + JSON.stringify(sender), + ); + } + + async getSender(id: string): Promise { + try { + const parameter = await this.parameterStore.getParameter( + this.getParameterName(id), + ); + + if (!parameter?.Value) { + return null; + } + + return this.parseSenderParameter(parameter.Value); + } catch (error) { + if (error instanceof ParameterNotFound) { + this.logger.info({ description: `Parameter not found for ID ${id}` }); + return null; + } + + throw error; + } + } + + async deleteSender(id: string): Promise { + try { + await this.parameterStore.deleteParameter(this.getParameterName(id)); + } catch (error) { + if (!(error instanceof ParameterNotFound)) { + throw error; + } + } + } + + private get parameterPathPrefix(): string { + return `/dl/${this.config.environment}/senders/`; + } + + private getParameterName(id: string) { + return `${this.parameterPathPrefix}${id}`; + } + + async listSenders(options?: { skipCache?: boolean }): Promise { + const parameters = await this.parameterStore.getAllParameters( + this.parameterPathPrefix, + { force: options?.skipCache }, + ); + + return parameters.flatMap( + ({ Value }) => this.parseSenderParameter(Value) ?? [], + ); + } + + private parseSenderParameter(value: string | undefined): Sender | null { + if (!value) { + return null; + } + + try { + const senderJson: unknown = JSON.parse(value); + + return $Sender.parse(senderJson); + } catch (error) { + this.logger.error({ + description: 'Malformed sender found', + value, + err: error, + }); + } + + return null; + } +} diff --git a/utils/sender-management/tsconfig.json b/utils/sender-management/tsconfig.json new file mode 100644 index 000000000..de8ca2a7d --- /dev/null +++ b/utils/sender-management/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "exclude": [ + "node_modules" + ], + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "./jest.config.ts" + ] +} diff --git a/utils/utils/src/__tests__/validators/sender.test.ts b/utils/utils/src/__tests__/validators/sender.test.ts new file mode 100644 index 000000000..68dafb907 --- /dev/null +++ b/utils/utils/src/__tests__/validators/sender.test.ts @@ -0,0 +1,153 @@ +import { $Sender } from 'validators'; + +describe('Sender Validator', () => { + it('should validate a correct Sender object with senderId as uuid', () => { + const validSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(validSender)).not.toThrow(); + }); + + it('should validate a correct Sender object with senderId as non-uuid string', () => { + const validSender = { + senderId: 'testSender5', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(validSender)).not.toThrow(); + }); + + it('should validate a correct Sender object without routingConfigId', () => { + const validSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + }; + + expect(() => $Sender.parse(validSender)).not.toThrow(); + }); + + it('should throw an error when senderId has spaces', () => { + const invalidSender = { + senderId: 'Spaces are invalid', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when senderId has / as this is used for organisation of data in SSM', () => { + const invalidSender = { + senderId: 'Character/', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when senderId is missing', () => { + const invalidSender = { + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when senderName is missing', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when meshMailboxSenderId is missing', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when meshMailboxReportsId is missing', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when fallbackWaitTimeSeconds is missing', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when fallbackWaitTimeSeconds has wrong type', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: '300', + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); + + it('should throw an error when fallbackWaitTimeSeconds is negative', () => { + const invalidSender = { + senderId: '123e4567-e89b-12d3-a456-426614174000', + senderName: 'Test Sender', + meshMailboxSenderId: 'SENDER123', + meshMailboxReportsId: 'REPORTS123', + fallbackWaitTimeSeconds: -300, + routingConfigId: 'ROUTE123', + }; + + expect(() => $Sender.parse(invalidSender)).toThrow(); + }); +}); diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index 37af94cb9..738acf67a 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,3 +1,4 @@ export * from './cloud-event'; export * from './ttl-dynamodb-record'; export * from './ttl-item-event'; +export * from './sender'; diff --git a/utils/utils/src/types/sender.ts b/utils/utils/src/types/sender.ts new file mode 100644 index 000000000..f0a045b0d --- /dev/null +++ b/utils/utils/src/types/sender.ts @@ -0,0 +1,11 @@ +/** + * Sender represents a use-case such as Vaccs or GP Reg + */ +export type Sender = { + senderId: string; + senderName: string; + meshMailboxSenderId: string; + meshMailboxReportsId: string; + fallbackWaitTimeSeconds: number; + routingConfigId?: string; +}; diff --git a/utils/utils/src/validators/index.ts b/utils/utils/src/validators/index.ts index 135d7c75b..399f1d4a0 100644 --- a/utils/utils/src/validators/index.ts +++ b/utils/utils/src/validators/index.ts @@ -1 +1,2 @@ export * from './validate-iso-date'; +export * from './sender'; diff --git a/utils/utils/src/validators/sender.ts b/utils/utils/src/validators/sender.ts new file mode 100644 index 000000000..db6f70c22 --- /dev/null +++ b/utils/utils/src/validators/sender.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const $Sender = z.object({ + senderId: z + .string() + .min(5) + .max(255) + .regex(/^[\w-]+$/), + senderName: z.string(), + meshMailboxSenderId: z.string(), + meshMailboxReportsId: z.string(), + fallbackWaitTimeSeconds: z.number().positive().int(), + routingConfigId: z.string().optional(), +});