diff --git a/docs/LLMO-5566/plan.md b/docs/LLMO-5566/plan.md new file mode 100644 index 000000000..264a735a7 --- /dev/null +++ b/docs/LLMO-5566/plan.md @@ -0,0 +1,76 @@ +--- +ticket: LLMO-5566 +repo: adobe/spacecat-api-service +branch: feature/LLMO-5566-cloudfront-log-delivery-assume-role +generated: 2026-06-26 +revised: 2026-06-30 +--- + +# Implementation Plan: CloudFront CDN Log Delivery (LLMO-5566) + +> **Revised 2026-06-30.** `main` PR #2682 shipped the CloudFront onboarding wizard +> (`LlmoCloudFrontController`, `cdn-onboard/cloudfront/*`) while this branch was open, making this +> branch's parallel `edge-optimize/*` wizard redundant. This branch was reset onto `main` and +> rebuilt around the only net-new slice: **CloudWatch CDN log delivery**. The wizard endpoints and +> the org-scoped-externalId model from the original plan were dropped (main's per-session UUID +> externalId model wins). + +## Problem Statement + +SpaceCat's LLM Optimizer needs CDN access logs from customers' CloudFront distributions. There was +no automated way to configure cross-account CloudWatch Logs delivery from the customer's AWS account +into Adobe's `cdn-logs` S3 bucket. main's CloudFront onboarding wizard wires routing but does not +set up log delivery. + +## Solution Overview + +Add two endpoints to the existing `LlmoCloudFrontController`, reusing its connector-role flow: + +1. `POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery` — enable access-log forwarding for + a single distribution (idempotent). +2. `POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan` — idempotently enable forwarding for + all distributions in the account (bounded concurrency), for recovery/re-scan. + +The assume-role `externalId` is main's client-supplied per-session UUID +(`validateCloudfrontCredentials`). The delivery destination + source names are **org-scoped**, +derived server-side from the site's IMS org id (independent of the externalId). Both endpoints are +gated by `isLLMOAdministrator()` and registered in `INTERNAL_ROUTES` (not on the external FACS +surface). + +## Key Files Changed vs main + +| File | Change | +|------|--------| +| `src/controllers/llmo/llmo-cloudfront.js` | +2 methods: `enableCdnLogDelivery`, `rescanCdnLogDelivery` | +| `src/support/cdn-log-delivery.js` | CloudWatch Logs delivery-source + delivery creation (paginated, idempotent) | +| `src/routes/index.js` | +2 routes (`log-delivery`, `log-rescan`) | +| `src/routes/facs-capabilities.js`, `src/routes/required-capabilities.js` | register the 2 routes in INTERNAL_ROUTES | +| `docs/openapi/api.yaml`, `docs/openapi/llmo-api.yaml` | OpenAPI for the 2 endpoints | +| `package.json` | + `@aws-sdk/client-cloudwatch-logs` | +| `test/controllers/llmo/llmo-cloudfront.test.js` | unit tests for both endpoints | +| `test/support/cdn-log-delivery.test.js` | unit tests for the support module | + +## Design Notes + +- **No local `edge-optimize.js`** — the rescan lists distributions via tokowaka's + `CloudFrontEdgeClient.listDistributions()`, already used by sibling endpoints. +- **Bounded concurrency** — rescan runs `createCdnLogDelivery` in batches of + `CDN_LOG_RESCAN_CONCURRENCY` (5) to avoid CloudWatch Logs per-account throttling. +- **Error handling** — server misconfig (missing `CDN_LOG_DELIVERY_DEST_ACCOUNT_ID`) → 500; + actionable AWS errors surface as 4xx via `mutationErrorResponse` (consistent with sibling + endpoints); per-distribution failures report the AWS error *category* only (no raw messages/ARNs). + +## Runtime Dependency (follow-up) + +For log delivery to succeed, the connector role created by main's bootstrap CloudFormation template +(`customer-bootstrap-role.yaml`, S3-hosted) must grant `logs:PutDeliverySource`, +`logs:CreateDelivery`, `logs:GetDeliverySource`, `logs:DescribeDeliveries`. If main's template +predates log delivery, that template (and its `Metadata.AdobeLLMOptimizerPermissions` block) must be +updated — tracked separately from this code change. + +## Acceptance Criteria + +- Both endpoints reachable at the documented paths and gated by `isLLMOAdministrator()` +- Log delivery is idempotent (`{ alreadyExisted: true }` on repeat) +- Rescan respects the concurrency cap and never aborts on a single distribution failure +- Unit tests pass (`npm test`); OpenAPI validates (`npm run docs:lint`); route snapshot updated diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 3dc047474..c7dff9dd9 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -666,6 +666,10 @@ paths: $ref: './llmo-api.yaml#/site-llmo-cloudfront-plan' /sites/{siteId}/llmo/cdn-onboard/cloudfront/permissions: $ref: './llmo-api.yaml#/site-llmo-cloudfront-permissions' + /sites/{siteId}/llmo/cdn-onboard/cloudfront/log-delivery: + $ref: './llmo-api.yaml#/site-llmo-cloudfront-log-delivery' + /sites/{siteId}/llmo/cdn-onboard/cloudfront/log-rescan: + $ref: './llmo-api.yaml#/site-llmo-cloudfront-log-rescan' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 41c4f31d5..5b8970ec8 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -3451,6 +3451,107 @@ site-llmo-cloudfront-permissions: security: - api_key: [ ] +site-llmo-cloudfront-log-delivery: + post: + tags: + - llmo + summary: Enable CDN access-log forwarding for one CloudFront distribution + description: | + Enables CloudWatch Logs delivery of the selected CloudFront distribution's access logs to + Adobe's cross-account cdn-logs destination, via the customer's connector role. Idempotent — + returns `alreadyExisted: true` and mutates nothing when forwarding is already set up. The + assume-role `externalId` is the per-session value from bootstrap; the delivery destination is + org-scoped, resolved server-side from the site's IMS organization. + operationId: enableLlmoCloudFrontLogDelivery + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/cloudfront-distribution-request' + responses: + '200': + description: The CDN log-delivery result for the distribution. + content: + application/json: + schema: + type: object + properties: + created: + type: boolean + alreadyExisted: + type: boolean + deliverySourceName: + type: string + deliveryId: + type: string + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-cloudfront-log-rescan: + post: + tags: + - llmo + summary: Re-scan and enable CDN log forwarding for all distributions + description: | + Idempotently enables CloudWatch Logs delivery for every CloudFront distribution in the + customer account. Intended for recovery/re-scan: e.g. after a new distribution is added or + a missed setup. Each distribution is attempted independently — one failure never aborts the + rest — and the response summarizes per-distribution outcomes. + operationId: rescanLlmoCloudFrontLogDelivery + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/cloudfront-connector-request' + responses: + '200': + description: Summary of the log-delivery re-scan across all distributions. + content: + application/json: + schema: + type: object + properties: + scanned: + type: integer + created: + type: integer + alreadyExisted: + type: integer + failed: + type: integer + distributions: + type: array + items: + type: object + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + cloudfront-connector-request: type: object required: diff --git a/package-lock.json b/package-lock.json index c32ab3fdb..f877582e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@adobe/spacecat-shared-utils": "1.123.0", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-cloudwatch-logs": "3.1045.0", "@aws-sdk/client-iam": "3.1045.0", "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", @@ -11357,6 +11358,148 @@ "tslib": "^2.6.2" } }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1045.0.tgz", + "integrity": "sha512-8p8jQuiIteWVYF7NhNHTXv4I7w6ZsDUWa4S6F+j8XIu8x5t+f38RKusQCHN4z8YEAzHSmg/8eIXkagP6N4UNMw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.25.tgz", + "integrity": "sha512-UxTxrsZt0gEXxXzxh6li6Pon6oVaLGDX0Jjiq0tu3NLa+1CIvsooeXsHl68C/kTdomxHsh2t2kJGUjj+eFK0Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.24.tgz", + "integrity": "sha512-l9Id3VLD7DsgfSOhTrYHLYsAMTLBznb6JMSxIMARgn6WGO/r8dL1eqL70gDC7GGaaEvmSjk72tzI39K6KSHndA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.26.tgz", + "integrity": "sha512-MCsWf4rk3DA7phlr7mSLBYNtFz42cNFt34dc3/lZ9ndUEJMps94yxK/Ub5YiG74WdWm9zicR2j6e3cdQOqY6nA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.28.tgz", + "integrity": "sha512-qeVRYvT94sjxhIi0/DuNEqh1KVkT/nW20rhwCOOWmqz027GeJmVIujBIz+hVaAPSsk7V4gyRiFR0qXsKuRzZRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/types": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz", + "integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.23.tgz", + "integrity": "sha512-wwMlidhPc0VooxV+SIHySsie3kJcx99Zv+FgfA4LvkrkY1bL4EhxazYMWDG9hRD+/U1/qbcxpE/fcpbmhJYRyQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@smithy/core": "^3.27.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.25.tgz", + "integrity": "sha512-J6ZMP2XbSKcxDMwWVJy/ozB+DcnayoFInvny8z9irhh/ZvhDi3ihS5snzfYabVdZ4G0Fd80gJy4FV4jtnlAF7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.1069.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1069.0.tgz", @@ -12863,17 +13006,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", - "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "version": "3.974.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.24.tgz", + "integrity": "sha512-vWB/qJl21vxGKBkBN8fKPTVXgm14v/bUQWTtR5oikrfAZbIN2bxuSiCY5rRAMR4gs3vtR2Vw0aTfVDU4tdfIPg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.31", + "@aws-sdk/types": "^3.973.14", + "@aws-sdk/xml-builder": "^3.972.32", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.27.0", + "@smithy/signature-v4": "^5.5.3", + "@smithy/types": "^4.15.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -12882,12 +13025,12 @@ } }, "node_modules/@aws-sdk/core/node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz", + "integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -12895,12 +13038,12 @@ } }, "node_modules/@aws-sdk/core/node_modules/@aws-sdk/xml-builder": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", - "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.32.tgz", + "integrity": "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -16995,12 +17138,11 @@ } }, "node_modules/@smithy/core": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.0.tgz", - "integrity": "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz", + "integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -17379,12 +17521,12 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.0.tgz", - "integrity": "sha512-vW6UdK7e7gV2wU/tXRsPq4pMQMusb8VymdVOyIFNA1FtyRmEClRFkYDtYI8UcO/HM0wK3qqjvvQs3HOlbgMbdg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.0.tgz", + "integrity": "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.0", + "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, diff --git a/package.json b/package.json index fbf1fb8be..090fb46f0 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@adobe/spacecat-shared-utils": "1.123.0", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-cloudwatch-logs": "3.1045.0", "@aws-sdk/client-iam": "3.1045.0", "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", diff --git a/src/controllers/llmo/llmo-cloudfront.js b/src/controllers/llmo/llmo-cloudfront.js index d2ea326e5..65655422c 100644 --- a/src/controllers/llmo/llmo-cloudfront.js +++ b/src/controllers/llmo/llmo-cloudfront.js @@ -24,6 +24,7 @@ import TokowakaClient, { CloudFrontEdgeClient, } from '@adobe/spacecat-shared-tokowaka-client'; import AccessControlUtil from '../../support/access-control-util.js'; +import { createCdnLogDelivery, buildDeliveryDestinationArn } from '../../support/cdn-log-delivery.js'; // CloudFormation templates use intrinsic-function tags (!Ref/!Sub/!GetAtt/...) that plain YAML // rejects. This schema tolerates them (constructing each to its raw value) so the permissions @@ -46,6 +47,11 @@ const CFN_YAML_SCHEMA = yaml.DEFAULT_SCHEMA.extend( const TARGETED_PATHS_MAX_ENTRIES = 20; const TARGETED_PATHS_MAX_ENTRY_LENGTH = 256; +// Cap parallel createCdnLogDelivery calls during a rescan. CloudWatch Logs delivery APIs are +// per-account throttled (~10-25 TPS) and each distribution issues 1-3 calls, so a customer with +// many distributions would otherwise hammer the limit and fail most calls with ThrottlingException. +const CDN_LOG_RESCAN_CONCURRENCY = 5; + /** * Controller for the CloudFront "Optimize at Edge" onboarding wizard. Mirrors the structure of * the Cloudflare onboarding controller: it owns the multi-step, cross-account control-plane flow @@ -1131,6 +1137,161 @@ function LlmoCloudFrontController(ctx) { } }; + // Enable CDN access-log forwarding for a SINGLE CloudFront distribution to Adobe's cross-account + // cdn-logs destination (mutation, idempotent). The assume-role externalId is the per-session UUID + // from bootstrap (client-supplied, must match the connector role's trust policy); the delivery + // destination + source names are org-scoped, derived server-side from the site's IMS org id (NOT + // from the externalId). Returns { created, alreadyExisted, deliverySourceName, deliveryId }. + const enableCdnLogDelivery = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site, Organization } = dataAccess; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + const { + accountId, externalId, distributionId, error: credError, + } = validateCloudfrontCredentials(context, { requireDistribution: true }); + if (credError) { + return credError; + } + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'enable CDN log forwarding'); + if (error) { + return error; + } + + // The cdn-logs destination/source names are scoped by IMS org, resolved server-side from the + // site's organization — independent of the assume-role externalId. + const organization = await Organization.findById(site.getOrganizationId()); + const imsOrgId = organization?.getImsOrgId(); + if (!hasText(imsOrgId)) { + return badRequest('Site organization has no IMS org ID'); + } + + // Missing destination account is a server misconfiguration, not bad caller input → 500. + const adobeAccountId = env?.CDN_LOG_DELIVERY_DEST_ACCOUNT_ID; + if (!hasText(adobeAccountId)) { + return internalServerError('CDN log delivery destination account is not configured'); + } + const deliveryDestinationArn = buildDeliveryDestinationArn({ imsOrgId, adobeAccountId }); + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + let result; + try { + result = await createCdnLogDelivery(credentials, { + provider: 'cloudfront', + resourceId: distributionId, + accountId, + imsOrgId, + deliveryDestinationArn, + }); + } catch (deliveryError) { + // Adobe destination not provisioned yet → clear 4xx instead of a raw AWS error + retry. + if (deliveryError?.name === 'ResourceNotFoundException') { + return badRequest('Adobe log destination is not provisioned for this organization yet — run cdn-logs provisioning first'); + } + throw deliveryError; + } + log.info(auditLine(context, 'log-delivery', 'done', { + siteId, accountId, distributionId, alreadyExisted: result.alreadyExisted, + })); + return ok(result); + } catch (error) { + log.error(auditLine(context, 'log-delivery', 'error', { + siteId, accountId, distributionId, error: error.message, + })); + // Surface actionable AWS failures (AccessDenied/Throttling/…) as 4xx, like sibling endpoints. + return mutationErrorResponse(error, 'An unexpected error occurred'); + } + }; + + // Idempotently enable CDN log delivery for ALL distributions in the customer account. Intended + // for re-scan use: after a new distribution is added, or to recover from a missed setup. Each + // distribution is attempted independently (Promise.allSettled) so one failure never aborts the + // rest; the response summarizes created/alreadyExisted/failed per distribution. + const rescanCdnLogDelivery = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site, Organization } = dataAccess; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + const { accountId, externalId, error: credError } = validateCloudfrontCredentials(context); + if (credError) { + return credError; + } + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'rescan CDN log delivery'); + if (error) { + return error; + } + + const organization = await Organization.findById(site.getOrganizationId()); + const imsOrgId = organization?.getImsOrgId(); + if (!hasText(imsOrgId)) { + return badRequest('Site organization has no IMS org ID'); + } + + // Missing destination account is a server misconfiguration, not bad caller input → 500. + const adobeAccountId = env?.CDN_LOG_DELIVERY_DEST_ACCOUNT_ID; + if (!hasText(adobeAccountId)) { + return internalServerError('CDN log delivery destination account is not configured'); + } + const deliveryDestinationArn = buildDeliveryDestinationArn({ imsOrgId, adobeAccountId }); + + const { cloudFrontClient, credentials } = await assumeCloudFrontClient({ + accountId, externalId, roleName, + }); + const distributions = await cloudFrontClient.listDistributions(); + + // Run in bounded batches (CDN_LOG_RESCAN_CONCURRENCY) so a large account doesn't trip + // CloudWatch Logs per-account throttling. slice()/push() preserve order, so the per-index + // summary mapping below stays aligned with `distributions`. + const results = []; + for (let i = 0; i < distributions.length; i += CDN_LOG_RESCAN_CONCURRENCY) { + const batch = distributions.slice(i, i + CDN_LOG_RESCAN_CONCURRENCY); + // eslint-disable-next-line no-await-in-loop + const batchResults = await Promise.allSettled( + batch.map((dist) => createCdnLogDelivery(credentials, { + provider: 'cloudfront', + resourceId: dist.id, + accountId, + imsOrgId, + deliveryDestinationArn, + })), + ); + results.push(...batchResults); + } + + const summary = distributions.map((dist, i) => { + const outcome = results[i]; + if (outcome.status === 'fulfilled') { + return { distributionId: dist.id, ...outcome.value }; + } + // Report only the AWS error category (e.g. ThrottlingException) — never the raw message, + // which can leak ARNs / role names to the caller. + return { distributionId: dist.id, error: outcome.reason?.name || 'unknown error' }; + }); + + const createdCount = summary.filter((r) => r.created).length; + const alreadyExisted = summary.filter((r) => r.alreadyExisted).length; + const failed = summary.filter((r) => r.error).length; + log.info(`[cdn-onboard-cloudfront] log-rescan site ${siteId}: scanned ${distributions.length} ` + + `distributions — created=${createdCount}, alreadyExisted=${alreadyExisted}, failed=${failed}`); + return ok({ + scanned: distributions.length, + created: createdCount, + alreadyExisted, + failed, + distributions: summary, + }); + } catch (error) { + log.error(`Failed to rescan CDN log delivery for site ${siteId}:`, error); + return mutationErrorResponse(error, 'An unexpected error occurred'); + } + }; + return { createBootstrapUrl, connect, @@ -1148,6 +1309,8 @@ function LlmoCloudFrontController(ctx) { deploy, plan, getPermissions, + enableCdnLogDelivery, + rescanCdnLogDelivery, }; } diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index c6ba1ff3d..556d738b4 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -158,6 +158,8 @@ const routeFacsCapabilities = { 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/deploy', 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/plan', 'GET /sites/:siteId/llmo/cdn-onboard/cloudfront/permissions', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan', // LLMO Cloudflare onboarding — LLMO-admin manual provisioning, gated by // isLLMOAdministrator() with a caller-supplied x-cloudflare-token; not a FACS surface. 'GET /sites/:siteId/llmo/cdn-onboard/cloudflare/config', diff --git a/src/routes/index.js b/src/routes/index.js index 56366c535..17dc0c68b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -525,6 +525,8 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/deploy': llmoCloudFrontController.deploy, 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/plan': llmoCloudFrontController.plan, 'GET /sites/:siteId/llmo/cdn-onboard/cloudfront/permissions': llmoCloudFrontController.getPermissions, + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery': llmoCloudFrontController.enableCdnLogDelivery, + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan': llmoCloudFrontController.rescanCdnLogDelivery, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index caa301462..6cb87aaa4 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -146,6 +146,8 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/deploy', 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/plan', 'GET /sites/:siteId/llmo/cdn-onboard/cloudfront/permissions', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // LLMO Cloudflare onboarding - LLMO-admin self-service, gated by isLLMOAdministrator(); diff --git a/src/support/cdn-log-delivery.js b/src/support/cdn-log-delivery.js new file mode 100644 index 000000000..2a5c78ac7 --- /dev/null +++ b/src/support/cdn-log-delivery.js @@ -0,0 +1,231 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import crypto from 'crypto'; +import { + CloudWatchLogsClient, + PutDeliverySourceCommand, + CreateDeliveryCommand, + GetDeliverySourceCommand, + DescribeDeliveriesCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +// CDN vended-log delivery control plane and the Adobe cross-account destination both live in +// us-east-1 (see spacecat-auth-service cdn-logs provisioning). +export const CDN_LOG_DELIVERY_REGION = 'us-east-1'; + +const CDN_LOG_S3_SUFFIX_PATH = '/{yyyy}/{MM}/{dd}/{HH}'; + +// Supported CDN providers. CloudFront is the first; add a new provider here (resource-ARN shape, +// CloudWatch log type, source-name prefix, delivered record fields) without touching the +// delivery flow below. +export const CDN_PROVIDERS = { + cloudfront: { + logType: 'ACCESS_LOGS', + sourceNamePrefix: 'llmo-cf', + buildResourceArn: ({ accountId, resourceId }) => `arn:aws:cloudfront::${accountId}:distribution/${resourceId}`, + recordFields: [ + 'date', 'time', 'x-edge-location', 'cs-method', 'cs(Host)', 'cs-uri-stem', 'sc-status', + 'cs(Referer)', 'cs(User-Agent)', 'time-to-first-byte', 'sc-content-type', 'x-host-header', + ], + }, +}; + +export const DEFAULT_CDN_PROVIDER = 'cloudfront'; + +function getProviderConfig(provider) { + const config = CDN_PROVIDERS[provider]; + if (!config) { + throw new Error(`Unsupported CDN provider: ${provider}`); + } + return config; +} + +/** + * Normalize an IMS org id into the AWS-safe token used in cdn-logs resource names. Mirrors + * spacecat-auth-service `toSafeAwsName` — must match byte-for-byte so the destination resolves. + */ +export function toSafeAwsName(imsOrgId) { + return String(imsOrgId).replace(/@AdobeOrg$/, '').replace(/@/g, '').toLowerCase(); +} + +/** + * Build the cross-account delivery-destination ARN Adobe provisioned for this org's cdn-logs + * bucket (`cdn-logs-`). Provider-agnostic. + */ +export function buildDeliveryDestinationArn({ + imsOrgId, + adobeAccountId, + region = CDN_LOG_DELIVERY_REGION, +}) { + if (!hasText(imsOrgId)) { + throw new Error('imsOrgId is required'); + } + if (!/^[0-9]{12}$/.test(String(adobeAccountId))) { + throw new Error('adobeAccountId must be a 12-digit AWS account ID'); + } + const name = `cdn-logs-${toSafeAwsName(imsOrgId)}`; + return `arn:aws:logs:${region}:${adobeAccountId}:delivery-destination:${name}`; +} + +// CloudWatch Logs caps a delivery-source name at 60 characters. +const MAX_DELIVERY_SOURCE_NAME_LENGTH = 60; + +/** Per-resource delivery-source name, scoped by provider + org + resource. */ +export function buildDeliverySourceName({ + provider = DEFAULT_CDN_PROVIDER, + imsOrgId, + resourceId, +}) { + const { sourceNamePrefix } = getProviderConfig(provider); + const name = `${sourceNamePrefix}-${toSafeAwsName(imsOrgId)}-${resourceId}`; + if (name.length <= MAX_DELIVERY_SOURCE_NAME_LENGTH) { + return name; + } + // For unusually long org/resource ids, keep the readable head and append a deterministic hash + // of the full name. Determinism matters: the name is regenerated on every call and idempotency + // (GetDeliverySource) relies on it being byte-for-byte stable for the same inputs. + const suffix = crypto.createHash('sha256').update(name).digest('hex').slice(0, 12); + const head = name.slice(0, MAX_DELIVERY_SOURCE_NAME_LENGTH - suffix.length - 1); + return `${head}-${suffix}`; +} + +/** + * Enable CDN access-log forwarding to Adobe (diagram step 8): create the customer-account + * delivery source and link it to Adobe's cross-account destination so the CDN pushes logs to + * the cdn-logs S3 bucket. + * + * Idempotent — returns `{ created: false, alreadyExisted: true }` and mutates nothing when a + * delivery from this resource's source already exists. + * + * @param {object} credentials - temporary credentials from the connector role assume-role. + * @param {object} params + * @param {string} [params.provider] - CDN provider key (see CDN_PROVIDERS); defaults to cloudfront. + * @param {string} params.resourceId - the CDN resource id (e.g. CloudFront distribution id). + * @param {string} params.accountId - 12-digit customer AWS account id. + * @param {string} params.imsOrgId + * @param {string} params.deliveryDestinationArn - Adobe's cross-account destination ARN. + * @param {string} [params.region] + * @returns {Promise<{created: boolean, alreadyExisted: boolean, deliverySourceName: string, + * deliveryId: string|undefined}>} + */ +export async function createCdnLogDelivery(credentials, { + provider = DEFAULT_CDN_PROVIDER, + resourceId, + accountId, + imsOrgId, + deliveryDestinationArn, + region = CDN_LOG_DELIVERY_REGION, +}) { + const config = getProviderConfig(provider); + if (!hasText(resourceId)) { + throw new Error('resourceId is required'); + } + if (!/^[0-9]{12}$/.test(String(accountId))) { + throw new Error('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(imsOrgId)) { + throw new Error('imsOrgId is required'); + } + if (!hasText(deliveryDestinationArn)) { + throw new Error('deliveryDestinationArn is required'); + } + + const client = new CloudWatchLogsClient({ region, credentials }); + const deliverySourceName = buildDeliverySourceName({ provider, imsOrgId, resourceId }); + const resourceArn = config.buildResourceArn({ accountId, resourceId }); + + // Find the existing delivery for this source, if any (paginated). Returns it or undefined. + const findExistingDelivery = async () => { + let nextToken; + do { + // eslint-disable-next-line no-await-in-loop + const page = await client.send(new DescribeDeliveriesCommand({ + deliverySourceName, + ...(nextToken && { nextToken }), + })); + const match = (page.deliveries || []).find( + (d) => d.deliverySourceName === deliverySourceName, + ); + if (match) { + return match; + } + nextToken = page.nextToken; + } while (nextToken); + return undefined; + }; + + // No-op if forwarding is already enabled for this resource. + let sourceExists = false; + try { + await client.send(new GetDeliverySourceCommand({ name: deliverySourceName })); + sourceExists = true; + } catch (err) { + if (err?.name !== 'ResourceNotFoundException') { + throw err; + } + } + + if (sourceExists) { + const existing = await findExistingDelivery(); + if (existing) { + return { + created: false, + alreadyExisted: true, + deliverySourceName, + deliveryId: existing.id, + }; + } + } + + await client.send(new PutDeliverySourceCommand({ + name: deliverySourceName, + resourceArn, + logType: config.logType, + })); + + let response; + try { + response = await client.send(new CreateDeliveryCommand({ + deliverySourceName, + deliveryDestinationArn, + s3DeliveryConfiguration: { suffixPath: CDN_LOG_S3_SUFFIX_PATH }, + recordFields: config.recordFields, + })); + } catch (err) { + // TOCTOU: a concurrent enable/rescan for the same distribution can both pass the existence + // check above and race here; the losing CreateDelivery conflicts. Treat as already-enabled to + // preserve the idempotency contract instead of surfacing a 500. + if (err?.name === 'ConflictException' || err?.name === 'ResourceAlreadyExistsException') { + const existing = await findExistingDelivery(); + // deliveryId may be undefined here if DescribeDeliveries hasn't caught up to the winning + // CreateDelivery yet (eventual consistency). That is acceptable: delivery IS enabled — the + // id is informational, and the caller already treats this as a successful no-op. We don't + // retry-loop for the id since the idempotency outcome (alreadyExisted) is already correct. + return { + created: false, + alreadyExisted: true, + deliverySourceName, + deliveryId: existing?.id, + }; + } + throw err; + } + + return { + created: true, + alreadyExisted: false, + deliverySourceName, + deliveryId: response?.delivery?.id, + }; +} diff --git a/test/controllers/llmo/llmo-cloudfront.test.js b/test/controllers/llmo/llmo-cloudfront.test.js index 3c7c05170..bd86206b0 100644 --- a/test/controllers/llmo/llmo-cloudfront.test.js +++ b/test/controllers/llmo/llmo-cloudfront.test.js @@ -72,6 +72,7 @@ describe('LlmoCloudFrontController', () => { let mockContext; let mockSite; let mockConfig; + let mockOrganization; let mockDataAccess; let mockLog; let mockTokowakaClient; @@ -87,6 +88,8 @@ describe('LlmoCloudFrontController', () => { let verifyRoutingStub; let runDeployStepStub; let planDeployStub; + let createCdnLogDeliveryStub; + let buildDeliveryDestinationArnStub; // The control-plane functions are imported from '@adobe/spacecat-shared-tokowaka-client'; // the wrappers read the mutable outer stubs so each test can reassign them in beforeEach. @@ -211,6 +214,10 @@ describe('LlmoCloudFrontController', () => { calculateForwardedHost: calculateForwardedHostMock, ...getEdgeOptimizeStubs(), }, + '../../../src/support/cdn-log-delivery.js': { + createCdnLogDelivery: (...args) => createCdnLogDeliveryStub(...args), + buildDeliveryDestinationArn: (...args) => buildDeliveryDestinationArnStub(...args), + }, '../../../src/support/access-control-util.js': accessControlMock, }); @@ -228,6 +235,8 @@ describe('LlmoCloudFrontController', () => { verifyRoutingStub = sinon.stub(); runDeployStepStub = sinon.stub(); planDeployStub = sinon.stub(); + createCdnLogDeliveryStub = sinon.stub(); + buildDeliveryDestinationArnStub = sinon.stub(); mockTokowakaClient = { fetchMetaconfig: sinon.stub() }; LlmoCloudFrontController = await esmock( @@ -253,6 +262,9 @@ describe('LlmoCloudFrontController', () => { verifyRoutingStub = sinon.stub(); runDeployStepStub = sinon.stub(); planDeployStub = sinon.stub(); + createCdnLogDeliveryStub = sinon.stub(); + buildDeliveryDestinationArnStub = sinon.stub() + .returns('arn:aws:logs:us-east-1:111122223333:delivery-destination:cdn-logs-org'); mockLog = { info: sinon.stub(), @@ -265,8 +277,16 @@ describe('LlmoCloudFrontController', () => { getId: sinon.stub().returns(TEST_SITE_ID), getConfig: sinon.stub().returns(mockConfig), getBaseURL: sinon.stub().returns('https://www.example.com'), + getOrganizationId: sinon.stub().returns('test-org-id'), + }; + mockOrganization = { + getId: sinon.stub().returns('test-org-id'), + getImsOrgId: sinon.stub().returns('ABC123@AdobeOrg'), + }; + mockDataAccess = { + Site: { findById: sinon.stub().resolves(mockSite) }, + Organization: { findById: sinon.stub().resolves(mockOrganization) }, }; - mockDataAccess = { Site: { findById: sinon.stub().resolves(mockSite) } }; mockTokowakaClient.fetchMetaconfig = sinon.stub(); // Default: the chosen distribution (E2EXAMPLE123) serves the test site host (www.example.com), @@ -2287,4 +2307,321 @@ describe('LlmoCloudFrontController', () => { expect(result.status).to.equal(403); }); }); + + describe('enableCdnLogDelivery', () => { + let logDeliveryContext; + + beforeEach(() => { + assumeConnectorRoleStub.resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + createCdnLogDeliveryStub.resolves({ + created: true, + alreadyExisted: false, + deliverySourceName: 'llmo-cf-abc123-E2EXAMPLE123', + deliveryId: 'del-1', + }); + logDeliveryContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: { CDN_LOG_DELIVERY_DEST_ACCOUNT_ID: '111122223333' }, + }; + }); + + it('enables log forwarding and returns the delivery result', async () => { + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.created).to.equal(true); + expect(body.deliveryId).to.equal('del-1'); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + const callArgs = createCdnLogDeliveryStub.firstCall.args[1]; + expect(callArgs.provider).to.equal('cloudfront'); + expect(callArgs.resourceId).to.equal('E2EXAMPLE123'); + }); + + it('is a no-op when forwarding is already enabled', async () => { + createCdnLogDeliveryStub.resolves({ + created: false, + alreadyExisted: true, + deliverySourceName: 'llmo-cf-abc123-E2EXAMPLE123', + deliveryId: 'del-existing', + }); + + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.alreadyExisted).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.enableCdnLogDelivery({ + ...logDeliveryContext, + data: { ...logDeliveryContext.data, accountId: '123' }, + }); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('12-digit'); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.enableCdnLogDelivery({ + ...logDeliveryContext, + data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' }, + }); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('externalId'); + }); + + it('returns 400 when the distribution id is missing', async () => { + const result = await controller.enableCdnLogDelivery({ + ...logDeliveryContext, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + }); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('distributionId'); + }); + + it('returns 500 when the destination account is not configured (server misconfig)', async () => { + const result = await controller.enableCdnLogDelivery({ ...logDeliveryContext, env: {} }); + + expect(result.status).to.equal(500); + expect((await result.json()).message).to.include('not configured'); + }); + + it('returns 400 when the site organization has no IMS org id', async () => { + mockDataAccess.Organization.findById.resolves({ getImsOrgId: () => undefined }); + + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('IMS org'); + }); + + it('returns a clear error when the Adobe destination is not provisioned', async () => { + const notFoundErr = new Error('ResourceNotFoundException: delivery destination not found'); + notFoundErr.name = 'ResourceNotFoundException'; + createCdnLogDeliveryStub.rejects(notFoundErr); + + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('not provisioned'); + }); + + it('returns 500 when createCdnLogDelivery throws a non-ResourceNotFound error', async () => { + createCdnLogDeliveryStub.rejects(new Error('AccessDeniedException: not authorized')); + + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('An unexpected error occurred'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const result = await controllerWithAccessDenied(mockContext) + .enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const noAdmin = await esmock('../../../src/controllers/llmo/llmo-cloudfront.js', cfClientMocks(createMockAccessControlUtil(true, true, false))); + const result = await noAdmin(mockContext).enableCdnLogDelivery(logDeliveryContext); + + expect(result.status).to.equal(403); + }); + }); + + describe('rescanCdnLogDelivery', () => { + let rescanContext; + + beforeEach(() => { + assumeConnectorRoleStub.resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listDistributionsStub.resolves([{ id: 'E2EXAMPLE001' }, { id: 'E2EXAMPLE002' }]); + createCdnLogDeliveryStub.resolves({ + created: true, + alreadyExisted: false, + deliverySourceName: 'llmo-cf-abc123-E2EXAMPLE001', + deliveryId: 'del-1', + }); + rescanContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + }, + env: { CDN_LOG_DELIVERY_DEST_ACCOUNT_ID: '111122223333' }, + }; + }); + + it('scans all distributions and returns a summary', async () => { + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.scanned).to.equal(2); + expect(body.created).to.equal(2); + expect(body.alreadyExisted).to.equal(0); + expect(body.failed).to.equal(0); + expect(body.distributions).to.have.length(2); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(listDistributionsStub.calledOnce).to.equal(true); + expect(createCdnLogDeliveryStub.callCount).to.equal(2); + }); + + it('processes more distributions than the concurrency cap in order', async () => { + // 7 > CDN_LOG_RESCAN_CONCURRENCY (5) → exercises the multi-batch loop; order is preserved. + const ids = Array.from({ length: 7 }, (_, i) => `E2DIST${String(i).padStart(6, '0')}`); + listDistributionsStub.resolves(ids.map((id) => ({ id }))); + createCdnLogDeliveryStub.resolves({ created: true, alreadyExisted: false }); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.scanned).to.equal(7); + expect(body.created).to.equal(7); + expect(createCdnLogDeliveryStub.callCount).to.equal(7); + expect(body.distributions.map((d) => d.distributionId)).to.deep.equal(ids); + }); + + it('reports alreadyExisted when delivery already set up', async () => { + createCdnLogDeliveryStub.resolves({ created: false, alreadyExisted: true, deliveryId: 'del-x' }); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.created).to.equal(0); + expect(body.alreadyExisted).to.equal(2); + }); + + it('records failures per distribution (error category only) without aborting', async () => { + createCdnLogDeliveryStub.onFirstCall().resolves({ created: true, alreadyExisted: false }); + // A real AWS error carries the category in .name; the raw .message (with ARNs) is NOT leaked. + createCdnLogDeliveryStub.onSecondCall().rejects( + Object.assign(new Error('not authorized for arn:aws:logs:...'), { name: 'AccessDeniedException' }), + ); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.scanned).to.equal(2); + expect(body.created).to.equal(1); + expect(body.failed).to.equal(1); + expect(body.distributions[1].error).to.equal('AccessDeniedException'); + }); + + it('uses a custom role name when EDGE_OPTIMIZE_ROLE_NAME is set', async () => { + const ctx = { + ...rescanContext, + env: { ...rescanContext.env, EDGE_OPTIMIZE_ROLE_NAME: 'CustomConnectorRole' }, + }; + + const result = await controller.rescanCdnLogDelivery(ctx); + + expect(result.status).to.equal(200); + const callArgs = assumeConnectorRoleStub.firstCall.args[0]; + expect(callArgs.roleName).to.equal('CustomConnectorRole'); + }); + + it('falls back to "unknown error" when a rejection has no error name', async () => { + createCdnLogDeliveryStub.onFirstCall().resolves({ created: true, alreadyExisted: false }); + // An error with an empty name exercises the `|| 'unknown error'` fallback. (Note: sinon's + // .rejects('str') would set .name to that string, so we build the rejection explicitly.) + createCdnLogDeliveryStub.onSecondCall().rejects(Object.assign(new Error('boom'), { name: '' })); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.distributions[1].error).to.equal('unknown error'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.rescanCdnLogDelivery({ + ...rescanContext, + data: { ...rescanContext.data, accountId: '123' }, + }); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('12-digit'); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.rescanCdnLogDelivery({ + ...rescanContext, + data: { accountId: '120569600543' }, + }); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('externalId'); + }); + + it('returns 500 when the destination account is not configured (server misconfig)', async () => { + const result = await controller.rescanCdnLogDelivery({ ...rescanContext, env: {} }); + + expect(result.status).to.equal(500); + expect((await result.json()).message).to.include('not configured'); + }); + + it('returns 400 when the site organization has no IMS org id', async () => { + mockDataAccess.Organization.findById.resolves({ getImsOrgId: () => undefined }); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('IMS org'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const result = await controllerWithAccessDenied(mockContext) + .rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(403); + }); + + it('returns 500 when an unexpected error is thrown', async () => { + listDistributionsStub.rejects(new Error('NetworkError')); + + const result = await controller.rescanCdnLogDelivery(rescanContext); + + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('An unexpected error occurred'); + }); + }); }); diff --git a/test/it/postgres/llmo-cloudfront-log-delivery.test.js b/test/it/postgres/llmo-cloudfront-log-delivery.test.js new file mode 100644 index 000000000..97dede4fd --- /dev/null +++ b/test/it/postgres/llmo-cloudfront-log-delivery.test.js @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ctx } from './harness.js'; +import { resetPostgres } from './seed.js'; +import llmoCloudFrontLogDeliveryTests from '../shared/tests/llmo-cloudfront-log-delivery.js'; + +llmoCloudFrontLogDeliveryTests(() => ctx.httpClient, resetPostgres); diff --git a/test/it/shared/tests/llmo-cloudfront-log-delivery.js b/test/it/shared/tests/llmo-cloudfront-log-delivery.js new file mode 100644 index 000000000..df86cd49d --- /dev/null +++ b/test/it/shared/tests/llmo-cloudfront-log-delivery.js @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; + +import { + SITE_1_ID, + SITE_3_ID, + NON_EXISTENT_SITE_ID, +} from '../seed-ids.js'; + +/** + * Shared LLMO CloudFront CDN log-delivery endpoint tests. + * + * Endpoints under test (src/controllers/llmo/llmo-cloudfront.js): + * POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery + * POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan + * + * SCOPE — IT only exercises code paths that short-circuit BEFORE any external (AWS STS / + * CloudWatch Logs) call. Both handlers (a) validate the caller-supplied credentials + * (validateCloudfrontCredentials) and then (b) run the LLMO-admin access gate — both before + * assuming the connector role. The IT harness has no AWS mocking, so the success path and the + * org/destination resolution past the gate stay in the unit tests. What IS covered here: + * - access control: 404 (site not found), 403 (no org access), 403 (not an LLMO admin) + * - request-body validation: 400 (bad/missing accountId, externalId, distributionId) + * + * Validation runs BEFORE the gate, so the 400 cases use the llmoAdmin persona with bad input, + * while the 404/403 cases use VALID credentials so execution reaches the gate. None of these + * requests proceeds to an AWS call. + * + * @param {() => object} getHttpClient - Getter returning the initialized HTTP client + * @param {() => Promise} resetData - Truncates all data and re-seeds baseline + */ + +// SITE_1 belongs to ORG_1 (the llmoAdmin/user tenancy); SITE_3 belongs to ORG_2 (denied). +const VALID_ACCOUNT_ID = '120569600543'; +const VALID_EXTERNAL_ID = '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5'; +const VALID_DISTRIBUTION_ID = 'E2EXAMPLE123'; +const validCreds = { + accountId: VALID_ACCOUNT_ID, + externalId: VALID_EXTERNAL_ID, + distributionId: VALID_DISTRIBUTION_ID, +}; + +export default function llmoCloudFrontLogDeliveryTests(getHttpClient, resetData) { + describe('LLMO CloudFront CDN log delivery', () => { + before(() => resetData()); + + // ── access control (valid creds → execution reaches the gate before any AWS call) ── + + describe('access control', () => { + it('llmoAdmin: returns 404 for a non-existent site', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post( + `/sites/${NON_EXISTENT_SITE_ID}/llmo/cdn-onboard/cloudfront/log-delivery`, + validCreds, + ); + expect(res.status).to.equal(404); + }); + + it('llmoAdmin: returns 403 for a site in another org', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post( + `/sites/${SITE_3_ID}/llmo/cdn-onboard/cloudfront/log-delivery`, + validCreds, + ); + expect(res.status).to.equal(403); + }); + + it('user: returns 403 when the caller is not an LLMO administrator', async () => { + const http = getHttpClient(); + const res = await http.user.post( + `/sites/${SITE_1_ID}/llmo/cdn-onboard/cloudfront/log-delivery`, + validCreds, + ); + expect(res.status).to.equal(403); + }); + + it('admin: returns 403 — admin access does not grant the LLMO-admin role', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/sites/${SITE_1_ID}/llmo/cdn-onboard/cloudfront/log-rescan`, + { accountId: VALID_ACCOUNT_ID, externalId: VALID_EXTERNAL_ID }, + ); + expect(res.status).to.equal(403); + }); + }); + + // ── POST log-delivery — body validation runs before the gate / any external call ── + + describe('POST .../cloudfront/log-delivery', () => { + const path = `/sites/${SITE_1_ID}/llmo/cdn-onboard/cloudfront/log-delivery`; + + it('returns 400 when accountId is not a 12-digit AWS account id', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { ...validCreds, accountId: '123' }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when externalId is missing', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { + accountId: VALID_ACCOUNT_ID, distributionId: VALID_DISTRIBUTION_ID, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when distributionId is missing', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { + accountId: VALID_ACCOUNT_ID, externalId: VALID_EXTERNAL_ID, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when distributionId is not a valid CloudFront id', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { ...validCreds, distributionId: 'bad id' }); + expect(res.status).to.equal(400); + }); + }); + + // ── POST log-rescan — accountId + externalId required (no distribution) ──────────── + + describe('POST .../cloudfront/log-rescan', () => { + const path = `/sites/${SITE_1_ID}/llmo/cdn-onboard/cloudfront/log-rescan`; + + it('returns 400 when accountId is not a 12-digit AWS account id', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { + accountId: '123', externalId: VALID_EXTERNAL_ID, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when externalId is missing', async () => { + const http = getHttpClient(); + const res = await http.llmoAdmin.post(path, { accountId: VALID_ACCOUNT_ID }); + expect(res.status).to.equal(400); + }); + }); + }); +} diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 03d3bf383..ef9bc4755 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -1151,6 +1151,8 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/deploy', 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/plan', 'GET /sites/:siteId/llmo/cdn-onboard/cloudfront/permissions', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-delivery', + 'POST /sites/:siteId/llmo/cdn-onboard/cloudfront/log-rescan', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/cdn-log-delivery.test.js b/test/support/cdn-log-delivery.test.js new file mode 100644 index 000000000..0db9a612d --- /dev/null +++ b/test/support/cdn-log-delivery.test.js @@ -0,0 +1,256 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('cdn-log-delivery support', () => { + let mod; + let sendStub; + + // Tag each AWS command with a `type` so a sent command can be identified without instanceof + // (avoids defining multiple marker classes). `new Cmd(input)` returns the tagged object. + const makeCmd = (type) => function Cmd(input) { + return { type, input }; + }; + const GetDeliverySourceCommand = makeCmd('get'); + const DescribeDeliveriesCommand = makeCmd('describe'); + const PutDeliverySourceCommand = makeCmd('put'); + const CreateDeliveryCommand = makeCmd('create'); + + beforeEach(async () => { + sendStub = sinon.stub(); + mod = await esmock('../../src/support/cdn-log-delivery.js', { + '@aws-sdk/client-cloudwatch-logs': { + CloudWatchLogsClient: function CloudWatchLogsClient() { + this.send = (cmd) => sendStub(cmd); + }, + GetDeliverySourceCommand, + DescribeDeliveriesCommand, + PutDeliverySourceCommand, + CreateDeliveryCommand, + }, + }); + }); + + afterEach(() => sinon.restore()); + + describe('toSafeAwsName', () => { + it('strips @AdobeOrg, removes @, lowercases', () => { + expect(mod.toSafeAwsName('ABC123@AdobeOrg')).to.equal('abc123'); + expect(mod.toSafeAwsName('Foo@Bar@AdobeOrg')).to.equal('foobar'); + }); + }); + + describe('buildDeliveryDestinationArn', () => { + it('builds an org-scoped destination ARN', () => { + const arn = mod.buildDeliveryDestinationArn({ + imsOrgId: 'ABC123@AdobeOrg', + adobeAccountId: '111122223333', + }); + expect(arn).to.equal( + 'arn:aws:logs:us-east-1:111122223333:delivery-destination:cdn-logs-abc123', + ); + }); + + it('throws when imsOrgId is missing', () => { + expect(() => mod.buildDeliveryDestinationArn({ adobeAccountId: '111122223333' })) + .to.throw('imsOrgId is required'); + }); + + it('throws when adobeAccountId is not 12 digits', () => { + expect(() => mod.buildDeliveryDestinationArn({ imsOrgId: 'x@AdobeOrg', adobeAccountId: '123' })) + .to.throw('12-digit'); + }); + }); + + describe('buildDeliverySourceName', () => { + it('scopes the name by provider + org + resource', () => { + expect(mod.buildDeliverySourceName({ imsOrgId: 'ABC123@AdobeOrg', resourceId: 'E2X' })) + .to.equal('llmo-cf-abc123-E2X'); + }); + + it('throws for an unsupported provider', () => { + expect(() => mod.buildDeliverySourceName({ provider: 'nope', imsOrgId: 'x', resourceId: 'y' })) + .to.throw('Unsupported CDN provider'); + }); + + it('hashes-truncates to <=60 chars (deterministically) for long ids', () => { + const imsOrgId = `${'A'.repeat(40)}@AdobeOrg`; + const resourceId = 'E'.repeat(40); + const name1 = mod.buildDeliverySourceName({ imsOrgId, resourceId }); + const name2 = mod.buildDeliverySourceName({ imsOrgId, resourceId }); + + expect(name1.length).to.equal(60); + expect(name1).to.equal(name2); // deterministic — required for idempotency + expect(name1).to.match(/^llmo-cf-/); + // Different inputs must not collide on the truncated name. + const other = mod.buildDeliverySourceName({ imsOrgId, resourceId: 'F'.repeat(40) }); + expect(other).to.not.equal(name1); + }); + }); + + describe('createCdnLogDelivery', () => { + const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; + const baseParams = { + provider: 'cloudfront', + resourceId: 'E2EXAMPLE123', + accountId: '120569600543', + imsOrgId: 'ABC123@AdobeOrg', + deliveryDestinationArn: 'arn:aws:logs:us-east-1:111122223333:delivery-destination:cdn-logs-abc123', + }; + + const rnf = () => Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' }); + + it('creates source + delivery when none exists', async () => { + sendStub.callsFake((cmd) => { + if (cmd.type === 'get') { + return Promise.reject(rnf()); + } + if (cmd.type === 'create') { + return Promise.resolve({ delivery: { id: 'del-new' } }); + } + return Promise.resolve({}); + }); + + const result = await mod.createCdnLogDelivery(creds, baseParams); + + expect(result.created).to.equal(true); + expect(result.alreadyExisted).to.equal(false); + expect(result.deliveryId).to.equal('del-new'); + expect(result.deliverySourceName).to.equal('llmo-cf-abc123-E2EXAMPLE123'); + }); + + it('is idempotent when a delivery already exists (paginated)', async () => { + sendStub.callsFake((cmd) => { + if (cmd.type === 'describe') { + // First page: no match + nextToken; second page: the match. + if (!cmd.input.nextToken) { + return Promise.resolve({ deliveries: [{ deliverySourceName: 'other' }], nextToken: 'p2' }); + } + return Promise.resolve({ + deliveries: [{ deliverySourceName: 'llmo-cf-abc123-E2EXAMPLE123', id: 'del-existing' }], + }); + } + // GetDeliverySource resolves (source exists) → triggers the paginated describe lookup. + return Promise.resolve({}); + }); + + const result = await mod.createCdnLogDelivery(creds, baseParams); + + expect(result.created).to.equal(false); + expect(result.alreadyExisted).to.equal(true); + expect(result.deliveryId).to.equal('del-existing'); + }); + + it('treats a CreateDelivery ConflictException as already-existed (TOCTOU race)', async () => { + let describeCalls = 0; + sendStub.callsFake((cmd) => { + if (cmd.type === 'get') { + return Promise.reject(rnf()); // source does not exist yet → no early return + } + if (cmd.type === 'create') { + // A concurrent caller created the delivery between our check and this call. + return Promise.reject(Object.assign(new Error('exists'), { name: 'ConflictException' })); + } + if (cmd.type === 'describe') { + describeCalls += 1; + return Promise.resolve({ + deliveries: [{ deliverySourceName: 'llmo-cf-abc123-E2EXAMPLE123', id: 'del-raced' }], + }); + } + return Promise.resolve({}); + }); + + const result = await mod.createCdnLogDelivery(creds, baseParams); + + expect(result.created).to.equal(false); + expect(result.alreadyExisted).to.equal(true); + expect(result.deliveryId).to.equal('del-raced'); + expect(describeCalls).to.equal(1); // looked up the winner's delivery id after the conflict + }); + + it('on a conflict, returns alreadyExisted with undefined id when the raced delivery is not found', async () => { + sendStub.callsFake((cmd) => { + if (cmd.type === 'get') { + return Promise.reject(rnf()); // source absent → reach PutDeliverySource + CreateDelivery + } + if (cmd.type === 'create') { + return Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceAlreadyExistsException' })); + } + if (cmd.type === 'describe') { + // Paginate through pages with no matching deliverySourceName, then exhaust nextToken + // → findExistingDelivery returns undefined (its loop fall-through). + if (!cmd.input.nextToken) { + return Promise.resolve({ deliveries: [{ deliverySourceName: 'other' }], nextToken: 'p2' }); + } + return Promise.resolve({ deliveries: [{ deliverySourceName: 'another' }] }); + } + return Promise.resolve({}); + }); + + const result = await mod.createCdnLogDelivery(creds, baseParams); + + expect(result.created).to.equal(false); + expect(result.alreadyExisted).to.equal(true); + expect(result.deliveryId).to.equal(undefined); + }); + + it('rethrows a non-conflict error from CreateDelivery', async () => { + sendStub.callsFake((cmd) => { + if (cmd.type === 'get') { + return Promise.reject(rnf()); // source absent → reach CreateDelivery + } + if (cmd.type === 'create') { + return Promise.reject(Object.assign(new Error('bad input'), { name: 'ValidationException' })); + } + return Promise.resolve({}); + }); + + await expect(mod.createCdnLogDelivery(creds, baseParams)) + .to.be.rejectedWith('bad input'); + }); + + it('rethrows a non-ResourceNotFound error from GetDeliverySource', async () => { + sendStub.callsFake((cmd) => { + if (cmd.type === 'get') { + return Promise.reject(Object.assign(new Error('boom'), { name: 'AccessDeniedException' })); + } + return Promise.resolve({}); + }); + + await expect(mod.createCdnLogDelivery(creds, baseParams)) + .to.be.rejectedWith('boom'); + }); + + it('throws when resourceId is missing', async () => { + await expect(mod.createCdnLogDelivery(creds, { ...baseParams, resourceId: '' })) + .to.be.rejectedWith('resourceId is required'); + }); + + it('throws when accountId is not 12 digits', async () => { + await expect(mod.createCdnLogDelivery(creds, { ...baseParams, accountId: '123' })) + .to.be.rejectedWith('12-digit'); + }); + + it('throws when imsOrgId is missing', async () => { + await expect(mod.createCdnLogDelivery(creds, { ...baseParams, imsOrgId: '' })) + .to.be.rejectedWith('imsOrgId is required'); + }); + + it('throws when deliveryDestinationArn is missing', async () => { + await expect(mod.createCdnLogDelivery(creds, { ...baseParams, deliveryDestinationArn: '' })) + .to.be.rejectedWith('deliveryDestinationArn is required'); + }); + }); +});