diff --git a/package-lock.json b/package-lock.json index c657c5a61..26d868cd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@adobe/spacecat-shared-drs-client": "1.4.2", "@adobe/spacecat-shared-gpt-client": "1.6.22", "@adobe/spacecat-shared-http-utils": "1.25.2", - "@adobe/spacecat-shared-ims-client": "1.12.5", + "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/e492902c02de47c81e3593786cb6ee524b53e1e6/adobe-spacecat-shared-ims-client-1.12.5.tgz", "@adobe/spacecat-shared-launchdarkly-client": "^1.1.0", "@adobe/spacecat-shared-rum-api-client": "2.40.12", "@adobe/spacecat-shared-scrape-client": "2.6.2", @@ -5829,8 +5829,8 @@ }, "node_modules/@adobe/spacecat-shared-ims-client": { "version": "1.12.5", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-ims-client/-/spacecat-shared-ims-client-1.12.5.tgz", - "integrity": "sha512-F12HJe58IO1E/Chk2K91xSry9qGvL5N7fwWs+tzJoD2v5bnMYKY/YiVaeQdHrKBLYW3j58TeKAmJJMGMCiOoyQ==", + "resolved": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/e492902c02de47c81e3593786cb6ee524b53e1e6/adobe-spacecat-shared-ims-client-1.12.5.tgz", + "integrity": "sha512-z8E1ed47BGQ6M0kqYy0zRbpy/lYzNwLatnwIYHn01MnzwwhK/J/KAy3aqgRrkKyYKwY4E37ReOlQSMXzBtRDPw==", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.3.0", diff --git a/package.json b/package.json index da87a84fc..760d5a76c 100644 --- a/package.json +++ b/package.json @@ -79,10 +79,10 @@ "@adobe/spacecat-shared-brand-client": "1.1.41", "@adobe/spacecat-shared-content-client": "1.8.23", "@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0", + "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/e492902c02de47c81e3593786cb6ee524b53e1e6/adobe-spacecat-shared-ims-client-1.12.5.tgz", "@adobe/spacecat-shared-drs-client": "1.4.2", "@adobe/spacecat-shared-gpt-client": "1.6.22", "@adobe/spacecat-shared-http-utils": "1.25.2", - "@adobe/spacecat-shared-ims-client": "1.12.5", "@adobe/spacecat-shared-launchdarkly-client": "^1.1.0", "@adobe/spacecat-shared-rum-api-client": "2.40.12", "@adobe/spacecat-shared-scrape-client": "2.6.2", diff --git a/src/controllers/llmo/llmo-utils.js b/src/controllers/llmo/llmo-utils.js index a40bf6402..187b80c7d 100644 --- a/src/controllers/llmo/llmo-utils.js +++ b/src/controllers/llmo/llmo-utils.js @@ -13,8 +13,8 @@ // LLMO constants export const LLMO_SHEETDATA_SOURCE_URL = 'https://main--project-elmo-ui-data--adobe.aem.live'; -// Supported CDN / log source types. Aligned with auth-service (cdn-logs-infrastructure/common.js). -export const LOG_SOURCES = { +// Supported CDN types. Aligned with auth-service (cdn-logs-infrastructure/common.js). +export const CDN_TYPES = { BYOCDN_FASTLY: 'byocdn-fastly', BYOCDN_AKAMAI: 'byocdn-akamai', BYOCDN_CLOUDFRONT: 'byocdn-cloudfront', @@ -27,20 +27,6 @@ export const LOG_SOURCES = { COMMERCE_FASTLY: 'commerce-fastly', }; -// Per-CDN strategies for edge optimize routing. -export const EDGE_OPTIMIZE_CDN_STRATEGIES = { - [LOG_SOURCES.AEM_CS_FASTLY]: { - buildUrl: (cdnConfig, domain) => { - const base = cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, ''); - return `${base}/${domain}/edgeoptimize`; - }, - buildBody: (enabled) => ({ enabled }), - method: 'POST', - }, -}; - -export const EDGE_OPTIMIZE_CDN_TYPES = Object.keys(EDGE_OPTIMIZE_CDN_STRATEGIES); - // Apply filters to data arrays with case-insensitive exact matching export const applyFilters = (rawData, filterFields) => { const data = { ...rawData }; diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 00e3e5b1b..6fcc68635 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -13,6 +13,7 @@ import { gunzipSync } from 'zlib'; import { ok, badRequest, forbidden, createResponse, notFound, internalServerError, + unauthorized, } from '@adobe/spacecat-shared-http-utils'; import { SPACECAT_USER_AGENT, @@ -31,11 +32,23 @@ import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/confi import crypto from 'crypto'; import { getDomain } from 'tldts'; import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; -import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; +import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; +import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; import { UnauthorizedProductError } from '../../support/errors.js'; -import { exchangePromiseToken } from '../../support/utils.js'; +import { + probeSiteAndResolveDomain, + parseEdgeRoutingConfig, + callCdnRoutingApi, + EDGE_OPTIMIZE_CDN_STRATEGIES, + SUPPORTED_EDGE_ROUTING_CDN_TYPES, + OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE, + EDGE_OPTIMIZE_MARKING_DELAY_SECONDS, + detectCdnForDomain, + getHostnameWithoutWww, +} from '../../support/edge-routing-utils.js'; import { triggerBrandProfileAgent } from '../../support/brand-profile-trigger.js'; +import { getImsTokenFromCookie, authorizeEdgeCdnRouting } from '../../support/edge-routing-auth.js'; import { applyFilters, applyInclusions, @@ -43,8 +56,6 @@ import { applyGroups, applyMappings, LLMO_SHEETDATA_SOURCE_URL, - EDGE_OPTIMIZE_CDN_STRATEGIES, - EDGE_OPTIMIZE_CDN_TYPES, } from './llmo-utils.js'; import { LLMO_SHEET_MAPPINGS } from './llmo-mappings.js'; import { @@ -1150,6 +1161,7 @@ function LlmoController(ctx) { * Creates or updates Tokowaka edge optimization configuration * - Updates site's tokowaka meta-config in S3 * - Updates site's tokowakaEnabled in site config + * - Optional `cdnType`: if CDN type is supported - does CDN routing * @param {object} context - Request context * @returns {Promise} Created/updated edge config */ @@ -1159,7 +1171,7 @@ function LlmoController(ctx) { const { authInfo: { profile } } = context.attributes; const { Site } = dataAccess; const { - enhancements, tokowakaEnabled, forceFail, patches = {}, prerender, + enhancements, tokowakaEnabled, forceFail, patches = {}, prerender, cdnType, enabled, } = context.data || {}; log.info(`createOrUpdateEdgeConfig request received for site ${siteId}, data=${JSON.stringify(context.data)}`); @@ -1184,6 +1196,10 @@ function LlmoController(ctx) { return badRequest('prerender field must be an object with allowList property that is an array'); } + if (enabled !== undefined && typeof enabled !== 'boolean') { + return badRequest('enabled field must be a boolean'); + } + try { // Get site const site = await Site.findById(siteId); @@ -1275,6 +1291,146 @@ function LlmoController(ctx) { } } + let cdnTypeNormalized = null; + if (hasText(cdnType)) { + const cdnTypeTrimmed = cdnType.toLowerCase().trim(); + cdnTypeNormalized = SUPPORTED_EDGE_ROUTING_CDN_TYPES.includes(cdnTypeTrimmed) + ? cdnTypeTrimmed : null; + if (!cdnTypeNormalized) { + log.info(`[edge-optimize-routing-failed] cdnType: ${cdnType} not eligible for automated routing`); + } else { + // Verify the requested CDN type matches the domain's actual CDN via DNS + try { + const hostname = getHostnameWithoutWww(baseURL, log); + const detectedCdn = await detectCdnForDomain(hostname); + if (!detectedCdn || detectedCdn !== cdnTypeNormalized) { + log.error(`[edge-optimize-routing-failed] Requested cdnType: '${cdnTypeNormalized}', detected CDN: '${detectedCdn}'`); + return badRequest(`Requested CDN type '${cdnTypeNormalized}' does not match the detected CDN for this domain`); + } + } catch (detectError) { + log.info(`[edge-optimize-config] CDN auto-detection failed for site ${siteId}: ${detectError.message}`); + } + } + } + // CDN routing — only when cdnType is provided + if (cdnTypeNormalized) { + // Exchange promise token from cookie for an IMS user token + let imsUserToken; + try { + imsUserToken = await getImsTokenFromCookie(context); + log.info(`[edge-optimize-routing] IMS user token obtained for site ${siteId}`); + } catch (tokenError) { + log.error(`[edge-optimize-routing-failed] Failed to get IMS user token for site ${siteId}: ${tokenError.message}`); + return createResponse({ message: tokenError.message }, tokenError.status ?? 401); + } + + // Authorization: paid (LLMO product context) or trial (LLMO Admin IMS group) + const org = await site.getOrganization(); + const imsOrgId = org.getImsOrgId(); + try { + await authorizeEdgeCdnRouting( + context, + { + org, + imsOrgId, + imsUserToken, + siteId, + }, + log, + ); + } catch (authErr) { + log.error(`[edge-optimize-routing-failed] Failed to authorize CDN routing for site ${siteId}: ${authErr.message}`); + return createResponse({ message: authErr.message }, authErr.status ?? 403); + } + + // Restrict to production environment + if (env?.ENV && env.ENV !== 'prod') { + log.error(`[edge-optimize-routing-failed] CDN routing is not available in ${env.ENV} environment`); + return createResponse({ message: `CDN routing is not available in ${env.ENV} environment` }, 400); + } + + let cdnConfig; + try { + cdnConfig = parseEdgeRoutingConfig(env?.EDGE_OPTIMIZE_ROUTING_CONFIG, cdnTypeNormalized); + } catch (parseError) { + if (parseError instanceof SyntaxError) { + log.error(`[edge-optimize-routing-failed] EDGE_OPTIMIZE_ROUTING_CONFIG invalid JSON: ${parseError.message}`); + return internalServerError('Failed to parse routing config.'); + } + log.error(`[edge-optimize-routing-failed] ${parseError.message}`); + return createResponse({ message: 'API is missing mandatory environment variable' }, 503); + } + + const strategy = EDGE_OPTIMIZE_CDN_STRATEGIES[cdnTypeNormalized]; + const routingEnabled = enabled ?? true; + + // Probe the live site to resolve the canonical domain for the CDN API call + const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL; + const effectiveBaseUrl = isValidUrl(overrideBaseURL) ? overrideBaseURL : baseURL; + const probeUrl = effectiveBaseUrl.startsWith('http') ? effectiveBaseUrl : `https://${effectiveBaseUrl}`; + log.info(`[edge-optimize-routing] Probing site ${probeUrl}`); + let domain; + try { + domain = await probeSiteAndResolveDomain(probeUrl, log); + } catch (probeError) { + log.error(`[edge-optimize-routing-failed] CDN routing update failed for site ${siteId}: ${probeError.message}`); + return badRequest(probeError.message); + } + + // Obtain org-scoped SP token for the CDN API call + let spToken; + try { + const imsEdgeClient = new ImsClient({ + imsHost: env.IMS_HOST, + clientId: env.IMS_EDGE_CLIENT_ID, + clientSecret: env.IMS_EDGE_CLIENT_SECRET, + scope: env.IMS_EDGE_SCOPE, + }, log); + const spTokenData = await imsEdgeClient.getServiceAccessTokenOrgScopedV3(imsOrgId); + spToken = spTokenData.access_token; + log.info(`[edge-optimize-routing] Service Principal token obtained for site ${siteId}`); + } catch (tokenError) { + log.warn(`[edge-optimize-routing-failed] Failed to obtain SP token for site ${siteId}: ${tokenError.message}`); + return unauthorized('Authentication failed with upstream IMS service'); + } + + // Call CDN API with the SP token + try { + await callCdnRoutingApi(strategy, cdnConfig, domain, spToken, routingEnabled, log); + } catch (cdnError) { + log.error(`[edge-optimize-routing-failed] CDN routing update failed for site ${siteId}: ${cdnError.message}`); + return internalServerError('Failed to update CDN routing'); + } + + log.info(`[edge-optimize-routing] CDN routing updated for site ${siteId}, domain ${domain}`); + + if (routingEnabled) { + // Trigger the import worker job to detect when edge-optimize goes live and stamp + // edgeOptimizeConfig.enabled. Delayed by 5 minutes to allow CDN propagation. + try { + await context.sqs.sendMessage( + env.IMPORT_WORKER_QUEUE_URL, + { type: OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE }, + undefined, + { delaySeconds: EDGE_OPTIMIZE_MARKING_DELAY_SECONDS }, + ); + log.info('[edge-optimize-routing] Queued edge-optimize enabled marking for site' + + ` ${siteId} (delay: ${EDGE_OPTIMIZE_MARKING_DELAY_SECONDS}s)`); + } catch (sqsError) { + log.warn(`[edge-optimize-routing-failed] Failed to queue edge-optimize enabled marking for site ${siteId}: ${sqsError.message}`); + } + } else { + // Routing disabled — record the disabled state immediately in site config. + const updatedEdgeConfig = currentConfig.getEdgeOptimizeConfig() || {}; + currentConfig.updateEdgeOptimizeConfig({ + ...updatedEdgeConfig, + enabled: false, + }); + await saveSiteConfig(site, currentConfig, log, 'marking edge optimize disabled'); + log.info(`[edge-optimize-routing] Marked edge optimize as disabled for site ${siteId}`); + } + } + return ok({ ...metaconfig, }); @@ -1478,190 +1634,6 @@ function LlmoController(ctx) { } }; - // Returns the hostname from a URL: lowercase with leading www. stripped. - function getHostnameWithoutWww(url, log) { - try { - const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`); - let hostname = urlObj.hostname.toLowerCase(); - if (hostname.startsWith('www.')) { - hostname = hostname.slice(4); - } - return hostname; - } catch (error) { - log.error(`Error getting hostname from URL ${url}: ${error.message}`); - throw new Error(`Error getting hostname from URL ${url}: ${error.message}`); - } - } - - /** - * POST /sites/{siteId}/llmo/edge-optimize-routing - * Updates edge optimize routing for the site via the internal CDN API. - * - Requires x-promise-token header and request body cdnType. - * - Probes the site with custom User-Agent (2xx continues; 301: if Location domain - * normalizes to same as probe URL domain, use Location domain for CDN API; otherwise break). - * - Exchanges promise token for IMS user token, then calls internal CDN API. - * @param {object} context - Request context (context.request for headers) - * @returns {Promise} - */ - const updateEdgeOptimizeCDNRouting = async (context) => { - const { log, dataAccess, env } = context; - const { siteId } = context.params; - const { Site } = dataAccess; - const { cdnType, enabled = true } = context.data || {}; - const promiseToken = context.request?.headers?.get?.('x-promise-token'); - log.info(`Edge optimize routing update request received for site ${siteId}`); - - if (env?.ENV && env.ENV !== 'prod') { - return createResponse( - { message: `API is not available in ${env?.ENV} environment` }, - 400, - ); - } - - if (!hasText(promiseToken)) { - return badRequest('x-promise-token header is required and must be a non-empty string'); - } - - if (!hasText(cdnType)) { - return badRequest('cdnType is required and must be a non-empty string'); - } - const cdnTypeTrimmed = cdnType.toLowerCase().trim(); - const cdnTypeNormalized = EDGE_OPTIMIZE_CDN_TYPES.includes(cdnTypeTrimmed) - ? cdnTypeTrimmed - : null; - - if (!cdnTypeNormalized) { - return badRequest(`cdnType must be one of: ${EDGE_OPTIMIZE_CDN_TYPES.join(', ')}`); - } - - let routingConfig; - try { - routingConfig = JSON.parse(env?.EDGE_OPTIMIZE_ROUTING_CONFIG); - } catch (parseError) { - log.error(`EDGE_OPTIMIZE_ROUTING_CONFIG invalid JSON: ${parseError.message}`); - return internalServerError('Failed to parse routing config.'); - } - - const cdnConfig = routingConfig[cdnTypeNormalized]; - if (!isObject(cdnConfig) || !isValidUrl(cdnConfig.cdnRoutingUrl)) { - log.error(`EDGE_OPTIMIZE_ROUTING_CONFIG missing entry or invalid URL for cdnType: ${cdnTypeNormalized}`); - return createResponse( - { message: 'API is missing mandatory environment variable' }, - 503, - ); - } - - const strategy = EDGE_OPTIMIZE_CDN_STRATEGIES[cdnTypeNormalized]; - - if (enabled !== undefined && typeof enabled !== 'boolean') { - return badRequest('enabled field must be a boolean'); - } - - const site = await Site.findById(siteId); - if (!site) { - return notFound('Site not found'); - } - - if (!await accessControlUtil.hasAccess(site)) { - return forbidden('User does not have access to this site'); - } - - const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL; - const effectiveBaseUrl = isValidUrl(overrideBaseURL) ? overrideBaseURL : site.getBaseURL(); - log.info(`Effective base URL for site ${siteId}: ${effectiveBaseUrl}`); - - const probeUrl = effectiveBaseUrl.startsWith('http') ? effectiveBaseUrl : `https://${effectiveBaseUrl}`; - let probeResponse; - try { - log.info(`Probing site ${probeUrl}`); - probeResponse = await fetch(probeUrl, { - method: 'GET', - headers: { 'User-Agent': 'AdobeEdgeOptimize-Test AdobeEdgeOptimize/1.0' }, - signal: AbortSignal.timeout(5000), - }); - } catch (probeError) { - log.error(`Error probing site ${siteId}: ${probeError.message}`); - return badRequest(`Error probing site: ${probeError.message}`); - } - let domain; - if (probeResponse.ok) { - domain = calculateForwardedHost(probeUrl, log); - } else if (probeResponse.status === 301) { - const locationValue = probeResponse.headers.get('location'); - let probeHostname; - let locationHostname; - try { - probeHostname = getHostnameWithoutWww(probeUrl, log); - locationHostname = getHostnameWithoutWww(locationValue, log); - } catch (hostError) { - log.error(`Invalid URL for 301 domain check: ${hostError.message}`); - return badRequest(hostError.message); - } - if (probeHostname !== locationHostname) { - const msg = `Site ${probeUrl} returned 301 to ${locationValue}; domain ` - + `(${locationHostname}) does not match probe domain (${probeHostname})`; - log.error(`CDN routing update failed: ${msg}`); - return badRequest(msg); - } - domain = calculateForwardedHost(locationValue, log); - log.info(`Probe returned 301; using Location domain ${domain} for CDN API`); - } else { - const msg = `Site ${probeUrl} did not return 2xx or 301 for` - + ` User-Agent AdobeEdgeOptimize-Test (got ${probeResponse.status})`; - log.error(`CDN routing update failed: ${msg}, url=${probeUrl}`); - return badRequest(msg); - } - - let imsUserToken; - try { - log.debug(`Getting IMS user token for site ${siteId}`); - imsUserToken = await exchangePromiseToken(context, promiseToken); - log.info('IMS user token obtained successfully'); - } catch (tokenError) { - log.warn(`Fetching IMS user token for site ${siteId} failed: ${tokenError.status} ${tokenError.message}`); - return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); - } - - try { - const cdnUrl = strategy.buildUrl(cdnConfig, domain); - const cdnBody = strategy.buildBody(enabled); - log.info(`Calling CDN API for domain ${domain} at ${cdnUrl} with enabled: ${enabled}`); - const cdnResponse = await fetch(cdnUrl, { - method: strategy.method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${imsUserToken}`, - }, - body: JSON.stringify(cdnBody), - signal: AbortSignal.timeout(5000), - }); - - if (!cdnResponse.ok) { - const body = await cdnResponse.text(); - log.error(`CDN API failed for site ${siteId}, domain ${domain}: ${cdnResponse.status} ${body}`); - if (cdnResponse.status === 401 || cdnResponse.status === 403) { - return createResponse( - { message: 'User is not authorized to update CDN routing' }, - cdnResponse.status, - ); - } - return createResponse( - { message: `Upstream call failed with status ${cdnResponse.status}` }, - 500, - ); - } - - log.info(`Edge optimize routing updated for site ${siteId}, domain ${domain}`); - return ok({ enabled, domain, cdnType: cdnTypeNormalized }); - } catch (error) { - log.error(`Edge optimize routing update failed for site ${siteId}: ${error.message}`); - if (error.status) { - return createResponse({ message: error.message }, error.status); - } - return internalServerError(cleanupHeaderValue(error.message)); - } - }; - const markOpportunitiesReviewed = async (context) => { const { log } = context; @@ -1908,7 +1880,6 @@ function LlmoController(ctx) { getStrategy, saveStrategy, checkEdgeOptimizeStatus, - updateEdgeOptimizeCDNRouting, markOpportunitiesReviewed, updateQueryIndex, }; diff --git a/src/routes/index.js b/src/routes/index.js index 83d8c2286..17a04534a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -424,7 +424,6 @@ export default function getRouteHandlers( 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, - 'POST /sites/:siteId/llmo/edge-optimize-routing': llmoController.updateEdgeOptimizeCDNRouting, 'PUT /sites/:siteId/llmo/opportunities-reviewed': llmoController.markOpportunitiesReviewed, 'GET /llmo/agentic-traffic/global': llmoMysticatController.getAgenticTrafficGlobal, 'POST /llmo/agentic-traffic/global': llmoMysticatController.postAgenticTrafficGlobal, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index cc7d101e8..9f1d31b87 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -73,7 +73,6 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/offboard', 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', - 'POST /sites/:siteId/llmo/edge-optimize-routing', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-routing-auth.js b/src/support/edge-routing-auth.js new file mode 100644 index 000000000..bbf32b07e --- /dev/null +++ b/src/support/edge-routing-auth.js @@ -0,0 +1,162 @@ +/* + * Copyright 2025 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 { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; +import { hasText } from '@adobe/spacecat-shared-utils'; +import { exchangePromiseToken, getCookieValue } from './utils.js'; + +// IMS service codes that represent the LLMO/Elmo product in the user's productContexts. +// TODO: replace placeholder with the real IMS service code once confirmed. +export const LLMO_IMS_SERVICE_CODES = ['dx_llmo']; + +// Name of the LLMO Admin IMS group used for trial customer authorization. +export const LLMO_ADMIN_GROUP_NAME = 'LLMO Admin'; + +/** + * Reads the promiseToken cookie from the request and exchanges it for an IMS user access token. + * + * @param {object} context - The request context. + * @returns {Promise} The IMS user access token. + * @throws {Error} With a `status` property (400 or 401) on failure. + */ +export async function getImsTokenFromCookie(context) { + const promiseToken = getCookieValue(context, 'promiseToken'); + if (!hasText(promiseToken)) { + const err = new Error('promiseToken cookie is required for CDN routing'); + err.status = 400; + throw err; + } + + try { + return await exchangePromiseToken(context, promiseToken); + } catch (tokenError) { + context.log?.error?.('Authentication failed with upstream IMS service', tokenError); + const err = new Error('Authentication failed with upstream IMS service'); + err.status = 401; + throw err; + } +} + +/** + * Checks whether a paid user's IMS profile contains the LLMO product context. + * + * @param {object} imsUserProfile - The IMS user profile (result of getImsUserProfile). + * @returns {boolean} + */ +export function hasPaidLlmoProductContext(imsUserProfile) { + const productContexts = imsUserProfile?.productContexts; + if (!Array.isArray(productContexts) || productContexts.length === 0) { + return false; + } + return productContexts.some( + (ctx) => LLMO_IMS_SERVICE_CODES.includes(ctx?.serviceCode), + ); +} + +/** + * Authorizes a user to perform CDN routing changes for LLMO edge optimize. + * + * Authorization rules: + * - Paid tier: the user's IMS profile must contain an LLMO product context. + * - Trial tier: the user must be a member of the LLMO Admin IMS group in the org. + * + * @param {object} context - The request context (must have imsClient, dataAccess). + * @param {object} params + * @param {object} params.org - The site's organization entity. + * @param {string} params.imsOrgId - The IMS org ID. + * @param {string} params.imsUserToken - The IMS user access token (from promiseToken exchange). + * @param {string} params.userEmail - The authenticated user's email. + * @param {string} params.siteId - Site ID (for log context). + * @param {object} log - Logger. + * @returns {Promise} Resolves if authorized. + * @throws {Error} With a `status` property (403 or 500) if not authorized or on unexpected error. + */ +export async function authorizeEdgeCdnRouting(context, { + org, imsOrgId, imsUserToken, siteId, +}, log) { + const { Entitlement: EntitlementCollection } = context.dataAccess; + + // Fetch the LLMO entitlement for this org + let entitlement; + try { + entitlement = await EntitlementCollection.findByOrganizationIdAndProductCode( + org.getId(), + EntitlementModel.PRODUCT_CODES.LLMO, + ); + } catch (err) { + log.warn(`[edge-routing-auth] Failed to fetch entitlement for org ${org.getId()}: ${err.message}`); + } + + const tier = entitlement?.getTier(); + const isPaid = tier === EntitlementModel.TIERS.PAID; + const isTrial = tier === EntitlementModel.TIERS.FREE_TRIAL; + log.info(`[edge-routing-auth] Site ${siteId} has entitlement tier '${tier}'`); + + if (isPaid) { + // Paid: validate LLMO product context in the user's IMS profile + let imsUserProfile; + try { + imsUserProfile = await context.imsClient.getImsUserProfile(imsUserToken); + } catch (profileErr) { + log.warn(`[edge-routing-auth] Failed to fetch IMS profile for site ${siteId}: ${profileErr.message}`); + const err = new Error('Failed to validate user permissions'); + err.status = 403; + throw err; + } + + if (!hasPaidLlmoProductContext(imsUserProfile)) { + log.warn(`[edge-routing-auth] Paid user lacks LLMO product context for site ${siteId}`); + const err = new Error('User does not have LLMO product access'); + err.status = 403; + throw err; + } + + return; + } + + if (isTrial) { + // Trial: validate LLMO Admin IMS group membership via user's org list + if (!hasText(imsOrgId)) { + const err = new Error('Only LLMO administrators or LLMO Admin group members can configure CDN routing'); + err.status = 403; + throw err; + } + + let isGroupMember = false; + try { + const orgs = await context.imsClient.getImsUserOrganizations(imsUserToken); + const matchingOrg = orgs.find((o) => `${o.orgRef?.ident}@${o.orgRef?.authSrc}` === imsOrgId); + if (matchingOrg) { + const adminName = LLMO_ADMIN_GROUP_NAME.toLowerCase(); + isGroupMember = matchingOrg.groups?.some( + (g) => g.groupName?.toLowerCase() === adminName, + ) ?? false; + } + } catch (groupErr) { + log.warn(`[edge-routing-auth] IMS group check failed for site ${siteId}: ${groupErr.message}`); + } + + if (!isGroupMember) { + const err = new Error(`Only ${LLMO_ADMIN_GROUP_NAME} group members can configure CDN routing`); + err.status = 403; + throw err; + } + + return; + } + + // Unknown or missing tier + log.warn(`[edge-routing-auth] Unrecognized entitlement tier '${tier}' for site ${siteId}`); + const err = new Error('Site does not have an LLMO entitlement'); + err.status = 403; + throw err; +} diff --git a/src/support/edge-routing-utils.js b/src/support/edge-routing-utils.js new file mode 100644 index 000000000..f4bd294ea --- /dev/null +++ b/src/support/edge-routing-utils.js @@ -0,0 +1,223 @@ +/* + * Copyright 2025 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 { promises as dns } from 'dns'; +import { isObject, isValidUrl, tracingFetch as fetch } from '@adobe/spacecat-shared-utils'; +import { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; +import { CDN_TYPES } from '../controllers/llmo/llmo-utils.js'; + +// Per-CDN strategies for edge optimize routing. +export const EDGE_OPTIMIZE_CDN_STRATEGIES = { + [CDN_TYPES.AEM_CS_FASTLY]: { + buildUrl: (cdnConfig, domain) => { + const base = cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, ''); + return `${base}/${domain}/edgeoptimize`; + }, + buildBody: (enabled) => ({ enabled }), + method: 'POST', + }, +}; + +export const SUPPORTED_EDGE_ROUTING_CDN_TYPES = Object.keys(EDGE_OPTIMIZE_CDN_STRATEGIES); + +// Import worker job type for edge optimize enabled detection. +// The import worker handler iterates all opted-in sites and stamps edgeOptimizeConfig.enabled +// when Tokowaka confirms the site is serving edge-optimized content. +export const OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE = 'optimize-at-edge-enabled-marking'; + +// Delay (seconds) before triggering the edge-optimize enabled marking job after CDN routing update. +// Gives the CDN API time to propagate before Tokowaka detects the change. +export const EDGE_OPTIMIZE_MARKING_DELAY_SECONDS = 300; + +const EDGE_OPTIMIZE_USER_AGENT = 'AdobeEdgeOptimize-Test AdobeEdgeOptimize/1.0'; +const PROBE_TIMEOUT_MS = 5000; +const CDN_CALL_TIMEOUT_MS = 5000; + +/** + * Strips the leading "www." from a URL's hostname. + * + * @param {string} url - The URL to parse (with or without scheme). + * @param {object} log - Logger. + * @returns {string} Lowercased hostname without "www." prefix. + * @throws {Error} If the URL is unparseable. + */ +export function getHostnameWithoutWww(url, log) { + try { + const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`); + let hostname = urlObj.hostname.toLowerCase(); + if (hostname.startsWith('www.')) { + hostname = hostname.slice(4); + } + return hostname; + } catch (error) { + log.error(`Error getting hostname from URL ${url}: ${error.message}`); + throw new Error(`Error getting hostname from URL ${url}: ${error.message}`); + } +} + +/** + * Probes the site URL and resolves the canonical domain for CDN API calls. + * + * - 2xx: returns the forwarded host derived from the probe URL. + * - 301 to the same root domain: returns the forwarded host from the Location header. + * - 301 to a different root domain: throws. + * - Any other status: throws. + * - Network/timeout error: propagates the thrown error. + * + * @param {string} siteUrl - The URL to probe (must include scheme). + * @param {object} log - Logger. + * @returns {Promise} The resolved domain string. + * @throws {Error} On unexpected probe response or domain mismatch. + */ +export async function probeSiteAndResolveDomain(siteUrl, log) { + const probeResponse = await fetch(siteUrl, { + method: 'GET', + headers: { 'User-Agent': EDGE_OPTIMIZE_USER_AGENT }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + + if (probeResponse.ok) { + return calculateForwardedHost(siteUrl, log); + } + + if (probeResponse.status === 301) { + const locationValue = probeResponse.headers.get('location'); + const probeHostname = getHostnameWithoutWww(siteUrl, log); + const locationHostname = getHostnameWithoutWww(locationValue, log); + + if (probeHostname !== locationHostname) { + throw new Error( + `Site ${siteUrl} returned 301 to ${locationValue}; domain ` + + `(${locationHostname}) does not match probe domain (${probeHostname})`, + ); + } + + log.info(`[edge-routing-utils] Probe returned 301; using Location domain ${locationValue}`); + return calculateForwardedHost(locationValue, log); + } + + throw new Error( + `Site ${siteUrl} did not return 2xx or 301 for` + + ` User-Agent AdobeEdgeOptimize-Test (got ${probeResponse.status})`, + ); +} + +/** + * Parses EDGE_OPTIMIZE_ROUTING_CONFIG and returns the config entry for the given CDN type. + * + * @param {string} configJson - Raw JSON string from the environment variable. + * @param {string} cdnTypeNormalized - The normalised CDN type key. + * @returns {object} The CDN config entry (e.g. { cdnRoutingUrl: '...' }). + * @throws {SyntaxError} If the JSON is malformed. + * @throws {Error} If the entry is missing or has an invalid cdnRoutingUrl. + */ +export function parseEdgeRoutingConfig(configJson, cdnTypeNormalized) { + const routingConfig = JSON.parse(configJson); + const cdnConfig = routingConfig[cdnTypeNormalized]; + if (!isObject(cdnConfig) || !isValidUrl(cdnConfig.cdnRoutingUrl)) { + throw new Error( + `EDGE_OPTIMIZE_ROUTING_CONFIG missing entry or invalid URL for cdnType: ${cdnTypeNormalized}`, + ); + } + return cdnConfig; +} + +/** + * Calls the CDN routing API with the given strategy and SP token. + * + * @param {object} strategy - The CDN strategy ({ buildUrl, buildBody, method }). + * @param {object} cdnConfig - The CDN config entry from parseEdgeRoutingConfig. + * @param {string} domain - The resolved canonical domain. + * @param {string} spToken - The Service Principal access token. + * @param {boolean} routingEnabled - Whether to enable or disable CDN routing. + * @param {object} log - Logger. + * @returns {Promise} Resolves on success. + * @throws {Error} On network/timeout failure. + */ +export async function callCdnRoutingApi( + strategy, + cdnConfig, + domain, + spToken, + routingEnabled, + log, +) { + const cdnUrl = strategy.buildUrl(cdnConfig, domain); + const cdnBody = strategy.buildBody(routingEnabled); + log.info(`[edge-routing-utils] Calling CDN API at ${cdnUrl} with enabled: ${routingEnabled}`); + + const cdnResponse = await fetch(cdnUrl, { + method: strategy.method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${spToken}`, + }, + body: JSON.stringify(cdnBody), + signal: AbortSignal.timeout(CDN_CALL_TIMEOUT_MS), + }); + + if (!cdnResponse.ok) { + const body = await cdnResponse.text(); + log.error(`[edge-routing-utils] CDN API failed for domain ${domain}: ${cdnResponse.status} ${body}`); + throw new Error(`Upstream call failed with status ${cdnResponse.status}`, cdnResponse.status); + } +} + +const AEM_CS_FASTLY_CNAME_PATTERNS = [ + 'cdn.adobeaemcloud.com', + 'adobeaemcloud.com', + 'fastly.net', +]; +const AEM_CS_FASTLY_IPS = new Set([ + '146.75.123.10', + '151.101.195.10', + '151.101.67.10', + '151.101.3.10', + '151.101.107.10', +]); + +async function checkHost(host) { + const cnames = await dns.resolveCname(host).catch(() => []); + if (cnames.some((c) => AEM_CS_FASTLY_CNAME_PATTERNS.some((pattern) => c.includes(pattern)))) { + return CDN_TYPES.AEM_CS_FASTLY; + } + + const ips = await dns.resolve4(host).catch(() => []); + if (ips.some((ip) => AEM_CS_FASTLY_IPS.has(ip))) { + return CDN_TYPES.AEM_CS_FASTLY; + } + + return null; +} + +/** + * Detects whether a domain is using AEM Cloud Service Managed CDN (Fastly) + * by checking DNS CNAME and A records. + * + * Returns 'aem-cs-fastly' if the domain resolves to the known CS Fastly + * CNAME or IP addresses, otherwise returns null. + * + * Never throws — DNS failures are treated as undetected. + * + * @param {string} domain - Hostname to check (e.g. 'example.com') + * @returns {Promise} CDN identifier or null + */ +export async function detectCdnForDomain(domain) { + try { + return await checkHost(`www.${domain}`) ?? await checkHost(domain); + } catch (err) { + // DNS errors are treated as undetected — never break callers + /* eslint-disable-next-line no-console -- no logger in this util; surface for ops debugging */ + console.error('detectCdnForDomain error', err); + } + return null; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 476f9efba..4adaf4007 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -16,7 +16,7 @@ import sinonChai from 'sinon-chai'; import esmock from 'esmock'; import { S3Client } from '@aws-sdk/client-s3'; import { llmoConfig } from '@adobe/spacecat-shared-utils'; -import { LOG_SOURCES } from '../../../src/controllers/llmo/llmo-utils.js'; +import { CDN_TYPES as LOG_SOURCES } from '../../../src/controllers/llmo/llmo-utils.js'; import { UnauthorizedProductError } from '../../../src/support/errors.js'; use(sinonChai); @@ -83,6 +83,17 @@ describe('LlmoController', () => { let fetchWithTimeoutStub; let postLlmoAlertStub; let postSlackMessageStub; + let getServicePrincipalTokenStub; + let isUserInImsGroupStub; + let getOrgGroupsStub; + let getImsUserProfileStub; + let getImsUserOrganizationsStub; + let probeSiteAndResolveDomainStub; + let callCdnRoutingApiStub; + let getImsTokenFromCookieStub; + let edgeRoutingAuthReal; + let detectCdnForDomainStub; + let authorizeEdgeCdnRoutingStub; const mockHttpUtils = { ok: (data, headers = {}) => ({ @@ -110,6 +121,10 @@ describe('LlmoController', () => { status: 500, json: async () => ({ message }), }), + unauthorized: (message) => ({ + status: 401, + json: async () => ({ message }), + }), }; // Common mocks needed for all esmock instances @@ -160,6 +175,13 @@ describe('LlmoController', () => { }, }); + edgeRoutingAuthReal = await import('../../../src/support/edge-routing-auth.js'); + getImsTokenFromCookieStub = sinon.stub().resolves('test-ims-user-token'); + detectCdnForDomainStub = sinon.stub().resolves(LOG_SOURCES.AEM_CS_FASTLY); + authorizeEdgeCdnRoutingStub = sinon.stub().callsFake((ctx, params, log) => ( + edgeRoutingAuthReal.authorizeEdgeCdnRouting(ctx, params, log) + )); + // Set up esmock once for all tests LlmoController = await esmock('../../../src/controllers/llmo/llmo.js', { '../../../src/controllers/llmo/llmo-config-metadata.js': { @@ -194,6 +216,17 @@ describe('LlmoController', () => { exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), fetchWithTimeout: (...args) => fetchWithTimeoutStub(...args), }, + '../../../src/support/edge-routing-auth.js': { + getImsTokenFromCookie: (...args) => getImsTokenFromCookieStub(...args), + authorizeEdgeCdnRouting: (...args) => authorizeEdgeCdnRoutingStub(...args), + }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); + }, + }, '../../../src/support/brand-profile-trigger.js': { triggerBrandProfileAgent: (...args) => triggerBrandProfileAgentStub(...args), }, @@ -242,6 +275,44 @@ describe('LlmoController', () => { '../../../src/utils/slack/base.js': { postSlackMessage: (...args) => postSlackMessageStub(...args), }, + '../../../src/support/edge-routing-utils.js': { + probeSiteAndResolveDomain: (...args) => probeSiteAndResolveDomainStub(...args), + callCdnRoutingApi: (...args) => callCdnRoutingApiStub(...args), + detectCdnForDomain: (...args) => detectCdnForDomainStub(...args), + getHostnameWithoutWww(url, log) { + try { + const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`); + let hostname = urlObj.hostname.toLowerCase(); + if (hostname.startsWith('www.')) { + hostname = hostname.slice(4); + } + return hostname; + } catch (error) { + log.error(`Error getting hostname from URL ${url}: ${error.message}`); + throw new Error(`Error getting hostname from URL ${url}: ${error.message}`); + } + }, + parseEdgeRoutingConfig: (json, cdnType) => { + const parsed = JSON.parse(json); + const config = parsed[cdnType]; + if (!config || typeof config !== 'object' || !config.cdnRoutingUrl || !/^https?:\/\//.test(config.cdnRoutingUrl)) { + throw new Error(`EDGE_OPTIMIZE_ROUTING_CONFIG missing entry or invalid URL for cdnType: ${cdnType}`); + } + return config; + }, + EDGE_OPTIMIZE_CDN_STRATEGIES: { + 'aem-cs-fastly': { + buildUrl: (cdnConfig, domain) => `${cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, '')}/${domain}/edgeoptimize`, + buildBody: (enabled) => ({ enabled }), + method: 'POST', + }, + }, + EDGE_OPTIMIZE_CDN_TYPES: ['aem-cs-fastly'], + SUPPORTED_EDGE_ROUTING_CDN_TYPES: ['aem-cs-fastly'], + OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE: 'optimize-at-edge-enabled-marking', + EDGE_OPTIMIZE_MARKING_DELAY_SECONDS: 300, + LOG_SOURCES, + }, }); // Create controller with access denied for access control tests @@ -467,6 +538,7 @@ describe('LlmoController', () => { mockEnv = { LLMO_HLX_API_KEY: TEST_API_KEY, AUDIT_JOBS_QUEUE_URL: TEST_QUEUE_URL, + IMPORT_WORKER_QUEUE_URL: 'https://sqs.us-east-1.amazonaws.com/123456789/spacecat-import-jobs', SLACK_LLMO_ALERTS_CHANNEL_ID: 'C123456789', SLACK_BOT_TOKEN: 'xoxb-test-token', SLACK_LLMO_EDGE_OPTIMIZE_TEAM: 'U123456789,U234567890,U345678901,U456789012,U567890123,U678901234,U789012345,U890123456,U901234567', @@ -495,6 +567,14 @@ describe('LlmoController', () => { hasOrganization: () => true, hasScope: () => true, getScopes: () => [{ name: 'user' }], + profile: { + email: 'test@example.com', + sub: 'test-user-id', + trial_email: 'trial@example.com', + first_name: 'Test', + last_name: 'User', + provider: 'GOOGLE', + }, getProfile: () => ({ email: 'test@example.com', trial_email: 'trial@example.com', @@ -506,8 +586,29 @@ describe('LlmoController', () => { }, }, pathInfo: { method: 'GET', suffix: '/llmo/sheet-data', headers: {} }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + getImsUserProfile: (...args) => getImsUserProfileStub(...args), + getImsUserOrganizations: (...args) => getImsUserOrganizationsStub(...args), + }, }; + getServicePrincipalTokenStub = sinon.stub().resolves({ access_token: 'sp-access-token' }); + isUserInImsGroupStub = sinon.stub().resolves(false); + getOrgGroupsStub = sinon.stub().resolves([{ groupName: 'LLMO Admin', ident: 99999 }]); + getImsUserProfileStub = sinon.stub().resolves({ productContexts: [{ serviceCode: 'dx_llmo' }] }); + getImsUserOrganizationsStub = sinon.stub().resolves([]); + getImsTokenFromCookieStub.reset(); + getImsTokenFromCookieStub.resolves('test-ims-user-token'); + probeSiteAndResolveDomainStub = sinon.stub().resolves('www.example.com'); + detectCdnForDomainStub.reset(); + detectCdnForDomainStub.resolves(LOG_SOURCES.AEM_CS_FASTLY); + authorizeEdgeCdnRoutingStub.resetBehavior(); + authorizeEdgeCdnRoutingStub.callsFake((ctx, params, log) => ( + edgeRoutingAuthReal.authorizeEdgeCdnRouting(ctx, params, log) + )); + callCdnRoutingApiStub = sinon.stub().resolves(); tracingFetchStub = sinon.stub(); fetchWithTimeoutStub = sinon.stub(); readConfigStub = sinon.stub(); @@ -4231,6 +4332,45 @@ describe('LlmoController', () => { describe('createOrUpdateEdgeConfig', () => { let edgeConfigContext; + const trialEdgeRoutingUtilsForCdnAuth = () => ({ + probeSiteAndResolveDomain: sinon.stub().resolves('www.example.com'), + callCdnRoutingApi: sinon.stub().resolves(), + detectCdnForDomain: sinon.stub().resolves(LOG_SOURCES.AEM_CS_FASTLY), + getHostnameWithoutWww(url, log) { + try { + const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`); + let hostname = urlObj.hostname.toLowerCase(); + if (hostname.startsWith('www.')) { + hostname = hostname.slice(4); + } + return hostname; + } catch (error) { + log.error(`Error getting hostname from URL ${url}: ${error.message}`); + throw new Error(`Error getting hostname from URL ${url}: ${error.message}`); + } + }, + parseEdgeRoutingConfig: (json, cdnType) => { + const parsed = JSON.parse(json); + const config = parsed[cdnType]; + if (!config || typeof config !== 'object' || !config.cdnRoutingUrl || !/^https?:\/\//.test(config.cdnRoutingUrl)) { + throw new Error(`EDGE_OPTIMIZE_ROUTING_CONFIG missing entry or invalid URL for cdnType: ${cdnType}`); + } + return config; + }, + EDGE_OPTIMIZE_CDN_STRATEGIES: { + 'aem-cs-fastly': { + buildUrl: (cdnConfig, domain) => `${cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, '')}/${domain}/edgeoptimize`, + buildBody: (enabled) => ({ enabled }), + method: 'POST', + }, + }, + EDGE_OPTIMIZE_CDN_TYPES: ['aem-cs-fastly'], + SUPPORTED_EDGE_ROUTING_CDN_TYPES: ['aem-cs-fastly'], + OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE: 'optimize-at-edge-enabled-marking', + EDGE_OPTIMIZE_MARKING_DELAY_SECONDS: 300, + LOG_SOURCES, + }); + beforeEach(() => { mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ enabled: false }); mockConfig.updateEdgeOptimizeConfig = sinon.stub(); @@ -4267,7 +4407,7 @@ describe('LlmoController', () => { 'https://www.example.com', TEST_SITE_ID, sinon.match({ tokowakaEnabled: true }), - sinon.match({ lastModifiedBy: 'tokowaka-edge-optimize-config' }), + sinon.match({ lastModifiedBy: 'test@example.com' }), ); expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( sinon.match({ opted: sinon.match.number }), @@ -4619,7 +4759,7 @@ describe('LlmoController', () => { 'https://www.example.com', TEST_SITE_ID, sinon.match({ enhancements: true }), - sinon.match({ lastModifiedBy: 'tokowaka-edge-optimize-config' }), + sinon.match({ lastModifiedBy: 'test@example.com' }), ); expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( sinon.match({ opted: sinon.match.number }), @@ -4634,6 +4774,31 @@ describe('LlmoController', () => { expect(responseBody.message).to.include('User does not have access to this site'); }); + it('returns 403 when user is not an LLMO administrator', async () => { + const hasAccessStub = sinon.stub().resolves(true); + const isOwnerStub = sinon.stub().resolves(true); + const NonAdminController = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': { + default: { + fromContext: () => ({ + hasAccess: hasAccessStub, + hasAdminAccess: () => false, + isLLMOAdministrator: () => false, + isOwnerOfSite: isOwnerStub, + }), + }, + }, + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + ...getCommonMocks(), + }); + const result = await NonAdminController(mockContext) + .createOrUpdateEdgeConfig(edgeConfigContext); + expect(result.status).to.equal(403); + const responseBody = await result.json(); + expect(responseBody.message).to.equal('Only LLMO administrators can update the edge optimize config'); + expect(isOwnerStub).to.not.have.been.called; + }); + it('should handle site not found failure', async () => { mockDataAccess.Site.findById.resolves(null); const result = await controller.createOrUpdateEdgeConfig(edgeConfigContext); @@ -4642,41 +4807,29 @@ describe('LlmoController', () => { expect(responseBody.message).to.include('Site not found'); }); - it('should return 403 when user is not LLMO administrator', async () => { - const postLlmoAlertStubLocal = sinon.stub().resolves(); - const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { - '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), - '@adobe/spacecat-shared-http-utils': mockHttpUtils, - '@adobe/spacecat-shared-tokowaka-client': { - default: { - createFrom: () => mockTokowakaClient, - }, - calculateForwardedHost: (url) => { - try { - const u = new URL(url.startsWith('http') ? url : `https://${url}`); - const h = u.hostname; - const dots = (h.match(/\./g) || []).length; - return dots === 1 ? `www.${h}` : h; - } catch (e) { - throw new Error(`Error calculating forwarded host from URL ${url}: ${e.message}`); - } - }, - }, - '../../../src/controllers/llmo/llmo-onboarding.js': { - validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), - generateDataFolder: sinon.stub().returns('test-folder'), - performLlmoOnboarding: sinon.stub().resolves({}), - performLlmoOffboarding: sinon.stub().resolves({}), - postLlmoAlert: (...args) => postLlmoAlertStubLocal(...args), - }, + it('returns 403 for CDN routing when paid user lacks LLMO product context in IMS profile', async () => { + mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getId: sinon.stub().returns('entitlement-123'), + getProductCode: sinon.stub().returns('LLMO'), + getTier: sinon.stub().returns('PAID'), }); + getImsUserProfileStub.resolves({ productContexts: [{ serviceCode: 'other_product' }] }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['k'] }); + mockTokowakaClient.updateMetaconfig.resolves({ apiKeys: ['k'] }); + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ opted: Date.now() }); - const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); - const result = await controllerNoAdmin.createOrUpdateEdgeConfig(edgeConfigContext); + const result = await controller.createOrUpdateEdgeConfig({ + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, + pathInfo: { + ...edgeConfigContext.pathInfo, + headers: { cookie: 'promiseToken=test-token' }, + }, + }); expect(result.status).to.equal(403); const responseBody = await result.json(); - expect(responseBody.message).to.equal('Only LLMO administrators can update the edge optimize config'); + expect(responseBody.message).to.include('LLMO product access'); }); // Note: Slack notification functionality uses postLlmoAlert() from llmo-onboarding.js @@ -4972,6 +5125,506 @@ describe('LlmoController', () => { expect(calledMessage).to.not.include('cc:'); }); + // ── Trial admin authorization (authorizeEdgeCdnRouting FREE_TRIAL + IMS org groups) ── + + it('returns 403 when trial user org has no LLMO Admin IMS group', async () => { + mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getId: sinon.stub().returns('entitlement-123'), + getProductCode: sinon.stub().returns('LLMO'), + getTier: sinon.stub().returns('FREE_TRIAL'), + }); + const trialOrg = { + getId: sinon.stub().returns(TEST_ORG_ID), + getImsOrgId: sinon.stub().returns('12345@AdobeOrg'), + }; + mockSite.getOrganization.resolves(trialOrg); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['k'] }); + mockTokowakaClient.updateMetaconfig.resolves({ apiKeys: ['k'] }); + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ opted: Date.now() }); + getImsUserOrganizationsStub.resolves([{ + orgRef: { ident: '12345', authSrc: 'AdobeOrg' }, + groups: [{ groupName: 'Administrators' }], + }]); + const ctx = { + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, + pathInfo: { + ...edgeConfigContext.pathInfo, + headers: { cookie: 'promiseToken=test-token' }, + }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + getImsUserProfile: (...args) => getImsUserProfileStub(...args), + getImsUserOrganizations: (...args) => getImsUserOrganizationsStub(...args), + }, + }; + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, true), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem: sinon.stub().returnsArg(0) }, + }, + '@adobe/spacecat-shared-tokowaka-client': { + default: { createFrom: () => mockTokowakaClient }, + calculateForwardedHost: (url) => new URL(url).hostname, + }, + '../../../src/controllers/llmo/llmo-onboarding.js': { + validateSiteNotOnboarded: sinon.stub().resolves({}), + generateDataFolder: sinon.stub().returns('test-folder'), + performLlmoOnboarding: sinon.stub().resolves({}), + performLlmoOffboarding: sinon.stub().resolves({}), + postLlmoAlert: sinon.stub().resolves(), + }, + '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, + '../../../src/support/edge-routing-auth.js': { + getImsTokenFromCookie: sinon.stub().resolves('test-ims-user-token'), + authorizeEdgeCdnRouting: edgeRoutingAuthReal.authorizeEdgeCdnRouting, + }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); + }, + }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), + }); + const controllerNoAdmin = LlmoControllerNoAdmin(ctx); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); + expect(result.status).to.equal(403); + expect((await result.json()).message).to.include('LLMO Admin group members'); + }); + + it('returns 403 when trial user has no matching IMS org in organization list', async () => { + mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getId: sinon.stub().returns('entitlement-123'), + getProductCode: sinon.stub().returns('LLMO'), + getTier: sinon.stub().returns('FREE_TRIAL'), + }); + const trialOrg = { + getId: sinon.stub().returns(TEST_ORG_ID), + getImsOrgId: sinon.stub().returns('12345@AdobeOrg'), + }; + mockSite.getOrganization.resolves(trialOrg); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['k'] }); + mockTokowakaClient.updateMetaconfig.resolves({ apiKeys: ['k'] }); + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ opted: Date.now() }); + getImsUserOrganizationsStub.resolves([{ + orgRef: { ident: '99999', authSrc: 'AdobeOrg' }, + groups: [{ groupName: 'LLMO Admin' }], + }]); + const ctx = { + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, + pathInfo: { + ...edgeConfigContext.pathInfo, + headers: { cookie: 'promiseToken=test-token' }, + }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + getImsUserProfile: (...args) => getImsUserProfileStub(...args), + getImsUserOrganizations: (...args) => getImsUserOrganizationsStub(...args), + }, + }; + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, true), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem: sinon.stub().returnsArg(0) }, + }, + '@adobe/spacecat-shared-tokowaka-client': { + default: { createFrom: () => mockTokowakaClient }, + calculateForwardedHost: (url) => new URL(url).hostname, + }, + '../../../src/controllers/llmo/llmo-onboarding.js': { + validateSiteNotOnboarded: sinon.stub().resolves({}), + generateDataFolder: sinon.stub().returns('test-folder'), + performLlmoOnboarding: sinon.stub().resolves({}), + performLlmoOffboarding: sinon.stub().resolves({}), + postLlmoAlert: sinon.stub().resolves(), + }, + '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, + '../../../src/support/edge-routing-auth.js': { + getImsTokenFromCookie: sinon.stub().resolves('test-ims-user-token'), + authorizeEdgeCdnRouting: edgeRoutingAuthReal.authorizeEdgeCdnRouting, + }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); + }, + }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), + }); + const controllerNoAdmin = LlmoControllerNoAdmin(ctx); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); + expect(result.status).to.equal(403); + expect((await result.json()).message).to.include('LLMO Admin group members'); + }); + + it('returns 403 when getImsUserOrganizations throws (trial admin path)', async () => { + mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getId: sinon.stub().returns('entitlement-123'), + getProductCode: sinon.stub().returns('LLMO'), + getTier: sinon.stub().returns('FREE_TRIAL'), + }); + const trialOrg = { + getId: sinon.stub().returns(TEST_ORG_ID), + getImsOrgId: sinon.stub().returns('12345@AdobeOrg'), + }; + mockSite.getOrganization.resolves(trialOrg); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['k'] }); + mockTokowakaClient.updateMetaconfig.resolves({ apiKeys: ['k'] }); + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ opted: Date.now() }); + getImsUserOrganizationsStub.rejects(new Error('IMS API error')); + const ctx = { + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, + pathInfo: { + ...edgeConfigContext.pathInfo, + headers: { cookie: 'promiseToken=test-token' }, + }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + getImsUserProfile: (...args) => getImsUserProfileStub(...args), + getImsUserOrganizations: (...args) => getImsUserOrganizationsStub(...args), + }, + }; + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, true), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem: sinon.stub().returnsArg(0) }, + }, + '@adobe/spacecat-shared-tokowaka-client': { + default: { createFrom: () => mockTokowakaClient }, + calculateForwardedHost: (url) => new URL(url).hostname, + }, + '../../../src/controllers/llmo/llmo-onboarding.js': { + validateSiteNotOnboarded: sinon.stub().resolves({}), + generateDataFolder: sinon.stub().returns('test-folder'), + performLlmoOnboarding: sinon.stub().resolves({}), + performLlmoOffboarding: sinon.stub().resolves({}), + postLlmoAlert: sinon.stub().resolves(), + }, + '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, + '../../../src/support/edge-routing-auth.js': { + getImsTokenFromCookie: sinon.stub().resolves('test-ims-user-token'), + authorizeEdgeCdnRouting: edgeRoutingAuthReal.authorizeEdgeCdnRouting, + }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); + }, + }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), + }); + const controllerNoAdmin = LlmoControllerNoAdmin(ctx); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); + expect(result.status).to.equal(403); + }); + + // ── cdnType / enabled input validation ───────────────────────────────────── + + it('skips automated CDN routing when cdnType is not supported for routing (logs only)', async () => { + const result = await controller.createOrUpdateEdgeConfig({ + ...edgeConfigContext, + data: { cdnType: 'unknown-cdn' }, + }); + expect(result.status).to.equal(200); + expect(callCdnRoutingApiStub).to.not.have.been.called; + expect(mockLog.info).to.have.been.calledWith( + sinon.match(/not eligible for automated routing/), + ); + }); + + it('returns 400 when enabled is not a boolean', async () => { + const result = await controller.createOrUpdateEdgeConfig({ + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: 'yes' }, + }); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.equal('enabled field must be a boolean'); + }); + + // ── CDN routing sub-tests ─────────────────────────────────────────────────── + describe('CDN routing', () => { + const routingConfigFastly = JSON.stringify({ + [LOG_SOURCES.AEM_CS_FASTLY]: { cdnRoutingUrl: 'https://internal-cdn.example.com' }, + }); + + function makeRoutingCtx(overrides = {}) { + const { + data: overrideData, + env: overrideEnv, + pathInfo: overridePathInfo, + ...restOverrides + } = overrides; + const mergedHeaders = { + ...(edgeConfigContext.pathInfo?.headers || {}), + ...(overridePathInfo?.headers || {}), + }; + if (!('cookie' in mergedHeaders)) { + mergedHeaders.cookie = 'promiseToken=test-token'; + } + return { + ...edgeConfigContext, + ...restOverrides, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, ...overrideData }, + env: { + ...edgeConfigContext.env, + ENV: 'prod', + EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly, + ...overrideEnv, + }, + pathInfo: { + ...(edgeConfigContext.pathInfo || {}), + ...(overridePathInfo || {}), + headers: mergedHeaders, + }, + }; + } + + beforeEach(() => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ opted: Date.now() }); + mockConfig.updateEdgeOptimizeConfig = sinon.stub(); + mockSite.getBaseURL = sinon.stub().returns('https://example.com'); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['k'] }); + mockTokowakaClient.updateMetaconfig.resolves({ apiKeys: ['k'] }); + mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getId: sinon.stub().returns('entitlement-123'), + getProductCode: sinon.stub().returns('LLMO'), + getTier: sinon.stub().returns('PAID'), + }); + getImsUserProfileStub.resolves({ productContexts: [{ serviceCode: 'dx_llmo' }] }); + detectCdnForDomainStub.resetHistory(); + detectCdnForDomainStub.resolves(LOG_SOURCES.AEM_CS_FASTLY); + }); + + it('returns 400 when DNS-detected CDN does not match requested cdnType', async () => { + detectCdnForDomainStub.resolves('akamai'); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('does not match the detected CDN'); + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Requested cdnType: 'aem-cs-fastly', detected CDN: 'akamai'/), + ); + }); + + it('returns 400 when CDN auto-detection returns no match', async () => { + detectCdnForDomainStub.resolves(null); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('does not match the detected CDN'); + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Requested cdnType: 'aem-cs-fastly', detected CDN: 'null'/), + ); + }); + + it('logs when hostname extraction fails during CDN auto-detection', async () => { + mockSite.getBaseURL = sinon.stub().returns('https://['); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + expect(mockLog.info).to.have.been.calledWith( + sinon.match(/CDN auto-detection failed/), + ); + }); + + it('returns 400 when ENV is not prod', async () => { + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ env: { ENV: 'stage', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly } }), + ); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('not available in stage'); + }); + + it('returns 500 when EDGE_OPTIMIZE_ROUTING_CONFIG is invalid JSON', async () => { + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ env: { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: 'not-json' } }), + ); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('Failed to parse routing config.'); + }); + + it('returns 503 when routing config has no entry for cdnType', async () => { + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ env: { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: '{}' } }), + ); + expect(result.status).to.equal(503); + }); + + it('returns 400 when site probe throws', async () => { + probeSiteAndResolveDomainStub.rejects(new Error('Connection refused')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('Connection refused'); + }); + + it('returns 400 when probe returns non-2xx non-301', async () => { + probeSiteAndResolveDomainStub.rejects(new Error('did not return 2xx or 301')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('did not return 2xx'); + }); + + it('returns 400 when probe returns 301 but domains do not match', async () => { + probeSiteAndResolveDomainStub.rejects(new Error('does not match probe domain')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('does not match'); + }); + + it('returns 401 when SP token request fails', async () => { + getServicePrincipalTokenStub.rejects(new Error('IMS error')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(401); + expect((await result.json()).message).to.equal('Authentication failed with upstream IMS service'); + }); + + it('returns 500 when CDN API returns an error response', async () => { + callCdnRoutingApiStub.rejects(new Error('Upstream call failed with status 403')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('Failed to update CDN routing'); + }); + + it('returns 500 when CDN API fails with a non-403 status', async () => { + callCdnRoutingApiStub.rejects(new Error('Upstream call failed with status 503')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('Failed to update CDN routing'); + }); + + it('returns 400 when promiseToken cookie is missing for CDN routing', async () => { + getImsTokenFromCookieStub.callsFake((ctx) => ( + edgeRoutingAuthReal.getImsTokenFromCookie(ctx) + )); + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ pathInfo: { headers: { cookie: '' } } }), + ); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('promiseToken cookie is required'); + }); + + it('returns 200 with routing data when CDN routing succeeds', async () => { + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.include.key('apiKeys'); + expect(body).to.not.have.property('domain'); + expect(body).to.not.have.property('cdnType'); + expect(body).to.not.have.property('enabled'); + // opted field updated, but enabled flag NOT written (import worker handles it) + expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledOnce; + expect(mockConfig.updateEdgeOptimizeConfig.firstCall.args[0]).to.not.have.property('enabled'); + expect(mockContext.sqs.sendMessage).to.have.been.calledWith( + mockEnv.IMPORT_WORKER_QUEUE_URL, + { type: 'optimize-at-edge-enabled-marking' }, + undefined, + { delaySeconds: 300 }, + ); + }); + + it('returns 200 and logs when SQS enqueue for enabled marking fails', async () => { + mockContext.sqs.sendMessage.rejects(new Error('sqs unavailable')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + expect(mockLog.warn).to.have.been.calledWith( + sinon.match(/Failed to queue edge-optimize enabled marking/), + ); + }); + + it('returns 200 with routing data when CDN routing disabled (enabled=false)', async () => { + const routingData = { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: false }; + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns(null); + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ data: routingData }), + ); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.not.have.key('enabled'); + // enabled=false is written directly to site config, no SQS message + expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledTwice; + expect(mockConfig.updateEdgeOptimizeConfig.secondCall.args[0]).to.include({ + enabled: false, + }); + expect(mockContext.sqs.sendMessage).to.not.have.been.called; + }); + + it('uses valid overrideBaseURL from fetch config for CDN probe URL', async () => { + mockConfig.getFetchConfig = sinon.stub().returns({ + overrideBaseURL: 'https://override.example.com', + }); + mockSite.getConfig = sinon.stub().returns(mockConfig); + await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(probeSiteAndResolveDomainStub).to.have.been.calledWith( + 'https://override.example.com', + sinon.match.object, + ); + }); + + it('prefixes CDN probe URL with https when site base URL has no scheme', async () => { + mockSite.getBaseURL = sinon.stub().returns('www.example.com'); + await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(probeSiteAndResolveDomainStub).to.have.been.calledWith( + 'https://www.example.com', + sinon.match.object, + ); + }); + + it('uses tokenError.status when getImsTokenFromCookie fails with a status', async () => { + const tokenErr = new Error('IMS cookie exchange failed'); + tokenErr.status = 503; + getImsTokenFromCookieStub.rejects(tokenErr); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(503); + expect((await result.json()).message).to.equal('IMS cookie exchange failed'); + }); + + it('uses authErr.status when authorizeEdgeCdnRouting fails with a status', async () => { + const authErr = new Error('Custom auth failure'); + authErr.status = 502; + authorizeEdgeCdnRoutingStub.rejects(authErr); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(502); + expect((await result.json()).message).to.equal('Custom auth failure'); + }); + + it('returns 401 when getImsTokenFromCookie fails without a status', async () => { + getImsTokenFromCookieStub.rejects(new Error('token failure')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(401); + }); + + it('returns 403 when authorizeEdgeCdnRouting fails without a status', async () => { + authorizeEdgeCdnRoutingStub.rejects(new Error('not authorized')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(403); + }); + + it('returns 200 with routing on 301 redirect to same root domain', async () => { + probeSiteAndResolveDomainStub.resolves('www.example.com'); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.apiKeys).to.deep.equal(['k']); + expect(callCdnRoutingApiStub).to.have.been.calledOnce; + }); + + it('returns 500 when CDN call throws a plain error without status semantics', async () => { + callCdnRoutingApiStub.rejects(new Error('Network error')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('Failed to update CDN routing'); + }); + }); // end describe('CDN routing') + it('should call hasAccess before isLLMOAdministrator before isOwnerOfSite', async () => { const hasAccessStub = sinon.stub().resolves(true); const isLLMOAdminStub = sinon.stub().returns(true); @@ -5226,6 +5879,26 @@ describe('LlmoController', () => { expect(responseBody[0].domain).to.equal('staging.lovesac.com'); }); + it('should accept multiple staging domains on the same apex as production', async () => { + stageConfigContext.data = { stagingDomains: ['staging.lovesac.com', 'preview.lovesac.com'] }; + mockDataAccess.Site.findByBaseURL + .onFirstCall().resolves(null) + .onSecondCall().resolves(null); + mockDataAccess.Site.create + .onFirstCall().resolves(mockStageSite) + .onSecondCall().resolves({ + ...mockStageSite, + getId: sinon.stub().returns('stage-site-2'), + getBaseURL: sinon.stub().returns('https://preview.lovesac.com'), + }); + + const result = await controller.createOrUpdateStageEdgeConfig(stageConfigContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody).to.be.an('array').with.lengthOf(2); + }); + it('should use default lastModifiedBy when profile.email is missing', async () => { stageConfigContext.attributes.authInfo = { profile: {} }; @@ -5718,415 +6391,6 @@ describe('LlmoController', () => { }); }); - describe('enableEdgeOptimize', () => { - let enableEdgeContext; - const validSiteId = '12345678-1234-4123-8123-123456789012'; - const FAKE_PROMISE_TOKEN = 'fake-promise-token'; - const routingConfigFastly = JSON.stringify({ - [LOG_SOURCES.AEM_CS_FASTLY]: { cdnRoutingUrl: 'https://internal-cdn.example.com' }, - }); - - function mockRequestWithPromiseToken(token = FAKE_PROMISE_TOKEN) { - return { - headers: { - get: (name) => (name === 'x-promise-token' ? token : null), - }, - }; - } - - beforeEach(() => { - enableEdgeContext = { - ...mockContext, - params: { siteId: validSiteId }, - data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, - request: mockRequestWithPromiseToken(), - }; - mockDataAccess.Site.findById.resetBehavior(); - mockDataAccess.Site.findById.resolves(mockSite); - mockSite.getBaseURL = sinon.stub().returns('https://example.com'); - mockSite.getConfig = sinon.stub().returns(mockConfig); - mockConfig.getFetchConfig = sinon.stub().returns({}); - }); - - it('returns 500 when EDGE_OPTIMIZE_ROUTING_CONFIG is not set (invalid JSON)', async () => { - const ctxNoConfig = { ...enableEdgeContext, env: { ENV: 'prod' } }; - const result = await controller.updateEdgeOptimizeCDNRouting(ctxNoConfig); - expect(result.status).to.equal(500); - expect((await result.json()).message).to.equal('Failed to parse routing config.'); - }); - - it('returns 503 when EDGE_OPTIMIZE_ROUTING_CONFIG has no entry for cdnType', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: '{}' }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(503); - expect((await result.json()).message).to.include('API is missing mandatory environment variable'); - }); - - it('returns 400 when x-promise-token header is missing', async () => { - enableEdgeContext.data = { cdnType: LOG_SOURCES.AEM_CS_FASTLY }; - enableEdgeContext.request = mockRequestWithPromiseToken(''); - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('x-promise-token header is required'); - }); - - it('returns 400 when x-promise-token header is undefined (null/undefined branch)', async () => { - enableEdgeContext.data = { cdnType: LOG_SOURCES.AEM_CS_FASTLY }; - enableEdgeContext.request = { headers: { get: () => undefined } }; - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('x-promise-token header is required'); - }); - - it('returns 400 when cdnType is missing', async () => { - enableEdgeContext.data = {}; - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('cdnType is required'); - }); - - it('returns 400 when context.data is undefined and no promise token in header', async () => { - const ctxNoData = { ...enableEdgeContext, data: undefined, request: mockRequestWithPromiseToken('') }; - ctxNoData.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controller.updateEdgeOptimizeCDNRouting(ctxNoData); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('x-promise-token header is required'); - }); - - it('returns 400 when cdnType is not supported', async () => { - enableEdgeContext.data = { cdnType: 'unknown' }; - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('cdnType must be one of'); - }); - - it('returns 400 when ENV is set and not prod', async () => { - const ctxNonProd = { ...enableEdgeContext, env: { ENV: 'stage', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly } }; - const result = await controller.updateEdgeOptimizeCDNRouting(ctxNonProd); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.equal('API is not available in stage environment'); - }); - - it('returns 400 when enabled is not a boolean', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - enableEdgeContext.data = { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: 'true' }; - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.equal('enabled field must be a boolean'); - }); - - it('returns 404 when site not found', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockDataAccess.Site.findById.resolves(null); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(404); - expect((await result.json()).message).to.equal('Site not found'); - }); - - it('returns 403 when user does not have access to site', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - const result = await controllerWithAccessDenied(mockContext) - .updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(403); - expect((await result.json()).message).to.equal('User does not have access to this site'); - }); - - it('returns 401 when exchangePromiseToken fails', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - tracingFetchStub.onFirstCall().resolves({ ok: true }); - exchangePromiseTokenStub.rejects(new Error('Missing promise token')); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(401); - expect((await result.json()).message).to.equal('Authentication failed with upstream IMS service'); - }); - - it('returns 401 when exchangePromiseToken throws without status and message', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - tracingFetchStub.onFirstCall().resolves({ ok: true }); - exchangePromiseTokenStub.rejects(Object.assign(new Error(), { message: '', status: undefined })); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(401); - expect((await result.json()).message).to.equal('Authentication failed with upstream IMS service'); - }); - - it('returns 400 when site probe returns non-200 and non-301', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: false, status: 404 }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('did not return 2xx'); - }); - - it('returns 400 when probe returns 301 without Location header', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: () => null }, - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.match(/hostname|URL|null/i); - }); - - it('returns 400 when probe returns 301 with invalid Location header', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://example.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://ex ample.com/' : null) }, - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('Error getting hostname'); - }); - - it('returns 400 when probe returns 301 but probe URL is invalid for domain check', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://['); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://www.example.com/' : null) }, - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.include('Error getting hostname from URL'); - }); - - it('returns 400 when probe returns 301 and Location domain does not match probe domain', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://example.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://other-domain.com/' : null) }, - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - const body = await result.json(); - expect(body.message).to.include('domain'); - expect(body.message).to.include('does not match'); - expect(body.message).to.include('301'); - }); - - it('301 with Location without scheme exercises hostname-without-www branch', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://example.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'example.com' : null) }, - }); - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - try { - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect((await result.json()).domain).to.be.a('string'); - } catch (e) { - expect(e.message).to.include('locationUrl'); - } - }); - - it('returns 200 with domain from Location when probe returns 301 and root domains match (www vs non-www)', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://example.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://www.example.com/' : null) }, - }); - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - try { - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect(await result.json()).to.deep.include({ - enabled: true, - domain: 'www.example.com', - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - }); - } catch (e) { - expect(e.message).to.include('locationUrl'); - } - }); - - it('returns 200 with domain from Location when probe returns 301 with adobe.com -> www.adobe.com', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockSite.getBaseURL.returns('https://adobe.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://www.adobe.com/' : null) }, - }); - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - try { - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect((await result.json()).domain).to.equal('www.adobe.com'); - } catch (e) { - expect(e.message).to.include('locationUrl'); - } - }); - - it('returns 400 when site probe fetch throws', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().rejects(new Error('Network error')); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(400); - expect((await result.json()).message).to.equal('Error probing site: Network error'); - }); - - it('returns 500 when CDN API returns 503', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ - ok: false, - status: 503, - statusText: 'X', - text: () => Promise.resolve(''), - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(500); - expect((await result.json()).message).to.include('Upstream call failed with status 503'); - }); - - it('returns Forbidden when CDN API returns 403', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ - ok: false, - status: 403, - statusText: 'X', - text: () => Promise.resolve(''), - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(403); - expect((await result.json()).message).to.equal('User is not authorized to update CDN routing'); - }); - - it('returns Unauthorized when CDN API returns 401', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ - ok: false, - status: 401, - statusText: 'X', - text: () => Promise.resolve(''), - }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(401); - expect((await result.json()).message).to.equal('User is not authorized to update CDN routing'); - }); - - it('returns 200 with enabled, domain and cdnType when probe and CDN succeed', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - enableEdgeContext.data = { - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - enabled: true, - }; - mockSite.getBaseURL.returns('example.com'); - mockConfig.getFetchConfig.returns({}); - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect(await result.json()).to.deep.equal({ - enabled: true, - domain: 'www.example.com', - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - }); - expect(tracingFetchStub.firstCall.args[0]).to.equal('https://example.com'); - }); - - it('defaults enabled to true when context.data has only cdnType', async () => { - const ctxOnlyCdnType = { - ...enableEdgeContext, - data: { - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - enabled: true, - }, - }; - ctxOnlyCdnType.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - const result = await controller.updateEdgeOptimizeCDNRouting(ctxOnlyCdnType); - expect(result.status).to.equal(200); - expect(await result.json()).to.deep.equal({ - enabled: true, - domain: 'www.example.com', - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - }); - expect(tracingFetchStub.secondCall.args[1].body).to.equal(JSON.stringify({ enabled: true })); - }); - - it('returns 200 using overrideBaseURL from site config when valid', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - mockConfig.getFetchConfig.returns({ overrideBaseURL: 'https://override.example.com' }); - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect((await result.json()).domain).to.equal('override.example.com'); - }); - - it('returns 200 with enabled false when data.enabled is false', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - enableEdgeContext.data = { - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - enabled: false, - }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(200); - expect(await result.json()).to.deep.equal({ - enabled: false, - domain: 'www.example.com', - cdnType: LOG_SOURCES.AEM_CS_FASTLY, - }); - expect(tracingFetchStub.secondCall.args[1].body).to.equal(JSON.stringify({ enabled: false })); - }); - - it('returns error.status when thrown error has status property', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - const err = new Error('CDN request failed'); - err.status = 418; - tracingFetchStub.onSecondCall().rejects(err); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(418); - expect((await result.json()).message).to.equal('CDN request failed'); - }); - - it('returns 500 when unexpected error has no status property', async () => { - enableEdgeContext.env = { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly }; - exchangePromiseTokenStub.resolves({ access_token: 'fake-token' }); - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().rejects(new Error('Network error')); - const result = await controller.updateEdgeOptimizeCDNRouting(enableEdgeContext); - expect(result.status).to.equal(500); - expect((await result.json()).message).to.equal('Network error'); - }); - }); - describe('checkEdgeOptimizeStatus', () => { let edgeStatusContext; const validSiteId = '12345678-1234-4123-8123-123456789012'; diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 3ddcf4c27..246e3e9b0 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -200,7 +200,7 @@ describe('getRouteHandlers', () => { getPaidTrafficByPageTypePlatformCampaign: sinon.stub(), getPaidTrafficByUrlPageTypeCampaignDevice: sinon.stub(), getPaidTrafficByUrlPageTypePlatformCampaignDevice: sinon.stub(), - getPaidTrafficPageTypePlatformCampaignDevice: sinon.stub(), + getPaidTrafficByPageTypePlatformCampaignDevice: sinon.stub(), getPaidTrafficByUrlPageTypeDevice: sinon.stub(), getPaidTrafficByUrlPageTypeCampaign: sinon.stub(), getPaidTrafficByUrlPageTypePlatform: sinon.stub(), @@ -300,7 +300,6 @@ describe('getRouteHandlers', () => { getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, - updateEdgeOptimizeCDNRouting: () => null, getStrategy: () => null, saveStrategy: () => null, getDemoBrandPresence: () => null, @@ -541,7 +540,7 @@ describe('getRouteHandlers', () => { expect(staticRoutes['GET /trial-users/email-preferences']).to.equal(mockTrialUserController.getEmailPreferences); expect(staticRoutes['PATCH /trial-users/email-preferences']).to.equal(mockTrialUserController.updateEmailPreferences); - expect(dynamicRoutes).to.have.all.keys( + const expectedDynamicRouteKeys = [ 'GET /audits/latest/:auditType', 'POST /configurations/:version/restore', 'GET /configurations/:version', @@ -803,10 +802,9 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-config', 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/strategy', 'PUT /sites/:siteId/llmo/strategy', - 'GET /sites/:siteId/llmo/edge-optimize-status', - 'POST /sites/:siteId/llmo/edge-optimize-routing', 'PUT /sites/:siteId/llmo/opportunities-reviewed', 'GET /sites/:siteId/user-activities', 'POST /sites/:siteId/user-activities', @@ -859,7 +857,8 @@ describe('getRouteHandlers', () => { 'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead', 'PATCH /contact-sales-leads/:contactSalesLeadId', 'POST /sites/:siteId/autofix-checks', - ); + ]; + expect(Object.keys(dynamicRoutes)).to.have.members(expectedDynamicRouteKeys); expect(dynamicRoutes['GET /audits/latest/:auditType'].handler).to.equal(mockAuditsController.getAllLatest); expect(dynamicRoutes['GET /audits/latest/:auditType'].paramNames).to.deep.equal(['auditType']); @@ -1072,8 +1071,6 @@ describe('getRouteHandlers', () => { expect(dynamicRoutes['POST /sites/:siteId/llmo/edge-optimize-config/stage'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['GET /sites/:siteId/llmo/edge-optimize-status'].handler).to.equal(mockLlmoController.checkEdgeOptimizeStatus); expect(dynamicRoutes['GET /sites/:siteId/llmo/edge-optimize-status'].paramNames).to.deep.equal(['siteId']); - expect(dynamicRoutes['POST /sites/:siteId/llmo/edge-optimize-routing'].handler).to.equal(mockLlmoController.updateEdgeOptimizeCDNRouting); - expect(dynamicRoutes['POST /sites/:siteId/llmo/edge-optimize-routing'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['PUT /sites/:siteId/llmo/opportunities-reviewed'].handler).to.equal(mockLlmoController.markOpportunitiesReviewed); expect(dynamicRoutes['PUT /sites/:siteId/llmo/opportunities-reviewed'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['GET /sites/:siteId/llmo/strategy'].handler).to.equal(mockLlmoController.getStrategy); diff --git a/test/support/edge-routing-auth.test.js b/test/support/edge-routing-auth.test.js new file mode 100644 index 000000000..60e71e110 --- /dev/null +++ b/test/support/edge-routing-auth.test.js @@ -0,0 +1,259 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-env mocha */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; +import { + authorizeEdgeCdnRouting, + hasPaidLlmoProductContext, +} from '../../src/support/edge-routing-auth.js'; + +use(chaiAsPromised); +use(sinonChai); + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const utilsModulePath = path.join(testDir, '../../src/support/utils.js'); + +describe('edge-routing-auth', () => { + let sandbox; + let log; + let getCookieValueStub; + let exchangePromiseTokenStub; + let getImsTokenFromCookie; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + log = { info: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub() }; + getCookieValueStub = sandbox.stub(); + exchangePromiseTokenStub = sandbox.stub(); + + const authMocked = await esmock('../../src/support/edge-routing-auth.js', { + [utilsModulePath]: { + getCookieValue: (...args) => getCookieValueStub(...args), + exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), + }, + '@adobe/spacecat-shared-utils': { + hasText: (str) => typeof str === 'string' && str.trim().length > 0, + }, + '@adobe/spacecat-shared-data-access': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { PAID: 'PAID', FREE_TRIAL: 'FREE_TRIAL', PLG: 'PLG' }, + }, + }, + }); + getImsTokenFromCookie = authMocked.getImsTokenFromCookie; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getImsTokenFromCookie', () => { + it('throws 400 when promiseToken cookie is missing', async () => { + getCookieValueStub.returns(null); + try { + await getImsTokenFromCookie({ pathInfo: { headers: {} } }); + expect.fail('expected throw'); + } catch (e) { + expect(e.status).to.equal(400); + expect(e.message).to.include('promiseToken cookie is required'); + } + }); + + it('throws 401 when token exchange fails', async () => { + getCookieValueStub.returns('ptok'); + exchangePromiseTokenStub.rejects(new Error('ims down')); + const ctxLog = { error: sandbox.stub() }; + try { + await getImsTokenFromCookie({ + pathInfo: { headers: { cookie: 'promiseToken=ptok' } }, + log: ctxLog, + }); + expect.fail('expected throw'); + } catch (e) { + expect(e.status).to.equal(401); + expect(e.message).to.equal('Authentication failed with upstream IMS service'); + expect(ctxLog.error).to.have.been.calledWith( + 'Authentication failed with upstream IMS service', + sinon.match.instanceOf(Error), + ); + } + }); + + it('returns access token when exchange succeeds', async () => { + getCookieValueStub.returns('ptok'); + exchangePromiseTokenStub.resolves('user-token'); + const token = await getImsTokenFromCookie({}); + expect(token).to.equal('user-token'); + }); + }); + + describe('hasPaidLlmoProductContext', () => { + it('returns false when productContexts missing or empty', () => { + expect(hasPaidLlmoProductContext({})).to.equal(false); + expect(hasPaidLlmoProductContext({ productContexts: [] })).to.equal(false); + }); + + it('returns true when dx_llmo service code is present', () => { + expect(hasPaidLlmoProductContext({ + productContexts: [{ serviceCode: 'dx_llmo' }], + })).to.equal(true); + }); + }); + + describe('authorizeEdgeCdnRouting', () => { + const org = { getId: () => 'org-1' }; + const baseCtx = () => ({ + dataAccess: { + Entitlement: { + findByOrganizationIdAndProductCode: sandbox.stub().resolves({ + getTier: () => 'PAID', + }), + }, + }, + imsClient: { + getImsUserProfile: sandbox.stub().resolves({ productContexts: [{ serviceCode: 'dx_llmo' }] }), + getImsUserOrganizations: sandbox.stub().resolves([]), + }, + }); + + it('allows paid users with LLMO product context', async () => { + await expect( + authorizeEdgeCdnRouting(baseCtx(), { + org, imsOrgId: 'x@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.fulfilled; + }); + + it('rejects when entitlement lookup throws (treated as no tier)', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.rejects(new Error('db')); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: 'x@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Site does not have an LLMO entitlement'); + }); + + it('rejects paid users when IMS profile fetch fails', async () => { + const ctx = baseCtx(); + ctx.imsClient.getImsUserProfile.rejects(new Error('ims')); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: 'x@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Failed to validate user permissions'); + }); + + it('rejects paid users without LLMO product context', async () => { + const ctx = baseCtx(); + ctx.imsClient.getImsUserProfile.resolves({ productContexts: [{ serviceCode: 'other' }] }); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: 'x@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('User does not have LLMO product access'); + }); + + it('rejects trial when ims org id is missing', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'FREE_TRIAL', + }); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: '', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Only LLMO administrators or LLMO Admin group members'); + }); + + it('rejects trial when user is not in LLMO Admin group', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'FREE_TRIAL', + }); + ctx.imsClient.getImsUserOrganizations.resolves([{ + orgRef: { ident: '12345', authSrc: 'AdobeOrg' }, + groups: [{ groupName: 'Other' }], + }]); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: '12345@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Only LLMO Admin group members can configure CDN routing'); + }); + + it('rejects trial when matching org has no groups array', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'FREE_TRIAL', + }); + ctx.imsClient.getImsUserOrganizations.resolves([{ + orgRef: { ident: '12345', authSrc: 'AdobeOrg' }, + }]); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: '12345@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Only LLMO Admin group members can configure CDN routing'); + }); + + it('allows trial users in LLMO Admin group for matching org', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'FREE_TRIAL', + }); + ctx.imsClient.getImsUserOrganizations.resolves([{ + orgRef: { ident: '12345', authSrc: 'AdobeOrg' }, + groups: [{ groupName: 'LLMO Admin' }], + }]); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: '12345@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.fulfilled; + }); + + it('rejects trial when getImsUserOrganizations throws', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'FREE_TRIAL', + }); + ctx.imsClient.getImsUserOrganizations.rejects(new Error('ims')); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: '12345@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Only LLMO Admin group members can configure CDN routing'); + }); + + it('rejects unknown entitlement tier', async () => { + const ctx = baseCtx(); + ctx.dataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves({ + getTier: () => 'PLG', + }); + await expect( + authorizeEdgeCdnRouting(ctx, { + org, imsOrgId: 'x@AdobeOrg', imsUserToken: 't', siteId: 's1', + }, log), + ).to.be.rejectedWith('Site does not have an LLMO entitlement'); + }); + }); +}); diff --git a/test/support/edge-routing-utils.test.js b/test/support/edge-routing-utils.test.js new file mode 100644 index 000000000..1ac668f6f --- /dev/null +++ b/test/support/edge-routing-utils.test.js @@ -0,0 +1,326 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-env mocha */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import { CDN_TYPES } from '../../src/controllers/llmo/llmo-utils.js'; + +use(chaiAsPromised); +use(sinonChai); + +describe('edge-routing-utils', () => { + let sandbox; + let log; + let fetchStub; + let calculateForwardedHostStub; + let edgeUtils; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + log = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + + fetchStub = sandbox.stub(); + calculateForwardedHostStub = sandbox.stub(); + + edgeUtils = await esmock('../../src/support/edge-routing-utils.js', { + '@adobe/spacecat-shared-utils': { + isObject: (v) => v !== null && typeof v === 'object' && !Array.isArray(v), + isValidUrl: (v) => { + try { + return Boolean(new URL(v)); + } catch { + return false; + } + }, + tracingFetch: fetchStub, + }, + '@adobe/spacecat-shared-tokowaka-client': { + calculateForwardedHost: calculateForwardedHostStub, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getHostnameWithoutWww', () => { + it('returns hostname as-is when no www prefix', () => { + expect(edgeUtils.getHostnameWithoutWww('https://example.com', log)).to.equal('example.com'); + }); + + it('strips www prefix', () => { + expect(edgeUtils.getHostnameWithoutWww('https://www.example.com', log)).to.equal('example.com'); + }); + + it('adds https scheme when missing', () => { + expect(edgeUtils.getHostnameWithoutWww('example.com', log)).to.equal('example.com'); + }); + + it('lowercases the hostname', () => { + expect(edgeUtils.getHostnameWithoutWww('https://WWW.EXAMPLE.COM', log)).to.equal('example.com'); + }); + + it('throws and logs on invalid URL', () => { + expect(() => edgeUtils.getHostnameWithoutWww('not a url !!', log)).to.throw('Error getting hostname from URL'); + expect(log.error).to.have.been.calledOnce; + }); + }); + + describe('probeSiteAndResolveDomain', () => { + it('returns calculated domain on 2xx response', async () => { + fetchStub.resolves({ ok: true, status: 200 }); + calculateForwardedHostStub.returns('example.com'); + + const domain = await edgeUtils.probeSiteAndResolveDomain('https://example.com', log); + + expect(domain).to.equal('example.com'); + expect(calculateForwardedHostStub).to.have.been.calledWith('https://example.com', log); + }); + + it('returns calculated domain from Location header on 301 to same root domain', async () => { + fetchStub.resolves({ + ok: false, + status: 301, + headers: { get: (n) => (n === 'location' ? 'https://www.example.com/' : null) }, + }); + calculateForwardedHostStub.returns('www.example.com'); + + const domain = await edgeUtils.probeSiteAndResolveDomain('https://example.com', log); + + expect(domain).to.equal('www.example.com'); + expect(calculateForwardedHostStub).to.have.been.calledWith('https://www.example.com/', log); + }); + + it('throws when 301 redirects to a different root domain', async () => { + fetchStub.resolves({ + ok: false, + status: 301, + headers: { get: (n) => (n === 'location' ? 'https://other-domain.com/' : null) }, + }); + + await expect(edgeUtils.probeSiteAndResolveDomain('https://example.com', log)) + .to.be.rejectedWith('does not match probe domain'); + }); + + it('throws when probe returns non-2xx non-301 status', async () => { + fetchStub.resolves({ ok: false, status: 404 }); + + await expect(edgeUtils.probeSiteAndResolveDomain('https://example.com', log)) + .to.be.rejectedWith('did not return 2xx or 301'); + }); + + it('propagates network errors from fetch', async () => { + fetchStub.rejects(new Error('Connection refused')); + + await expect(edgeUtils.probeSiteAndResolveDomain('https://example.com', log)) + .to.be.rejectedWith('Connection refused'); + }); + }); + + describe('EDGE_OPTIMIZE_CDN_STRATEGIES AEM_CS_FASTLY', () => { + it('buildUrl trims trailing slashes and appends domain path', async () => { + const mod = await import('../../src/support/edge-routing-utils.js'); + const s = mod.EDGE_OPTIMIZE_CDN_STRATEGIES[CDN_TYPES.AEM_CS_FASTLY]; + expect(s.buildUrl({ cdnRoutingUrl: 'https://cdn.example.com///' }, 'mysite.com')) + .to.equal('https://cdn.example.com/mysite.com/edgeoptimize'); + expect(s.buildBody(true)).to.deep.equal({ enabled: true }); + expect(s.method).to.equal('POST'); + }); + }); + + describe('parseEdgeRoutingConfig', () => { + it('returns cdnConfig for a valid entry', () => { + const configJson = JSON.stringify({ + 'aem-cs-fastly': { cdnRoutingUrl: 'https://cdn.example.com' }, + }); + const result = edgeUtils.parseEdgeRoutingConfig(configJson, 'aem-cs-fastly'); + expect(result).to.deep.equal({ cdnRoutingUrl: 'https://cdn.example.com' }); + }); + + it('throws SyntaxError on invalid JSON', () => { + expect(() => edgeUtils.parseEdgeRoutingConfig('not-json', 'aem-cs-fastly')) + .to.throw(SyntaxError); + }); + + it('throws when cdnType entry is missing from config', () => { + const configJson = JSON.stringify({ 'other-cdn': { cdnRoutingUrl: 'https://cdn.example.com' } }); + expect(() => edgeUtils.parseEdgeRoutingConfig(configJson, 'aem-cs-fastly')) + .to.throw('missing entry or invalid URL'); + }); + + it('throws when cdnRoutingUrl is not a valid URL', () => { + const configJson = JSON.stringify({ 'aem-cs-fastly': { cdnRoutingUrl: 'not-a-url' } }); + expect(() => edgeUtils.parseEdgeRoutingConfig(configJson, 'aem-cs-fastly')) + .to.throw('missing entry or invalid URL'); + }); + + it('throws when cdnConfig entry is not an object', () => { + const configJson = JSON.stringify({ 'aem-cs-fastly': 'string-value' }); + expect(() => edgeUtils.parseEdgeRoutingConfig(configJson, 'aem-cs-fastly')) + .to.throw('missing entry or invalid URL'); + }); + }); + + describe('callCdnRoutingApi', () => { + const strategy = { + buildUrl: (cdnConfig, domain) => `${cdnConfig.cdnRoutingUrl}/${domain}/edgeoptimize`, + buildBody: (enabled) => ({ enabled }), + method: 'POST', + }; + const cdnConfig = { cdnRoutingUrl: 'https://cdn.example.com' }; + const domain = 'example.com'; + const spToken = 'test-sp-token'; + + it('resolves without error on successful CDN API response', async () => { + fetchStub.resolves({ ok: true }); + + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.fulfilled; + + expect(fetchStub).to.have.been.calledOnce; + const [url, opts] = fetchStub.firstCall.args; + expect(url).to.equal('https://cdn.example.com/example.com/edgeoptimize'); + expect(JSON.parse(opts.body)).to.deep.equal({ enabled: true }); + expect(opts.headers.Authorization).to.equal('Bearer test-sp-token'); + }); + + it('throws Error mentioning status when CDN responds 403', async () => { + fetchStub.resolves({ + ok: false, + status: 403, + text: sandbox.stub().resolves('forbidden'), + }); + + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /403/); + }); + + it('throws Error mentioning status when CDN responds 401', async () => { + fetchStub.resolves({ + ok: false, + status: 401, + text: sandbox.stub().resolves('unauthorized'), + }); + + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /401/); + }); + + it('throws Error mentioning status on other non-OK CDN responses', async () => { + fetchStub.resolves({ + ok: false, + status: 503, + text: sandbox.stub().resolves('unavailable'), + }); + + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /503/); + }); + + it('propagates network errors from fetch', async () => { + fetchStub.rejects(new Error('Network failure')); + + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith('Network failure'); + }); + + it('passes enabled=false to the CDN body when routing is disabled', async () => { + fetchStub.resolves({ ok: true }); + + await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, false, log); + + expect(JSON.parse(fetchStub.firstCall.args[1].body)).to.deep.equal({ enabled: false }); + }); + }); + + describe('detectCdnForDomain', () => { + let dnsPromises; + let edgeUtilsDns; + + beforeEach(async () => { + dnsPromises = { + resolveCname: sandbox.stub(), + resolve4: sandbox.stub(), + }; + edgeUtilsDns = await esmock('../../src/support/edge-routing-utils.js', { + dns: { promises: dnsPromises }, + '@adobe/spacecat-shared-utils': { + isObject: (v) => v !== null && typeof v === 'object' && !Array.isArray(v), + isValidUrl: (v) => { + try { + return Boolean(new URL(v)); + } catch { + return false; + } + }, + tracingFetch: sandbox.stub(), + }, + '@adobe/spacecat-shared-tokowaka-client': { + calculateForwardedHost: sandbox.stub(), + }, + }); + }); + + it('returns null when DNS yields no AEM CS Fastly signals', async () => { + dnsPromises.resolveCname.resolves([]); + dnsPromises.resolve4.resolves([]); + const result = await edgeUtilsDns.detectCdnForDomain('example.com'); + expect(result).to.equal(null); + }); + + it('returns aem-cs-fastly when www host CNAME matches Adobe AEM cloud pattern', async () => { + dnsPromises.resolveCname.withArgs('www.example.com').resolves(['origin.example.cdn.adobeaemcloud.com']); + dnsPromises.resolveCname.withArgs('example.com').resolves([]); + dnsPromises.resolve4.resolves([]); + const result = await edgeUtilsDns.detectCdnForDomain('example.com'); + expect(result).to.equal(CDN_TYPES.AEM_CS_FASTLY); + }); + + it('returns aem-cs-fastly when A record matches known Fastly IP', async () => { + dnsPromises.resolveCname.resolves([]); + dnsPromises.resolve4.callsFake(async (host) => ( + host === 'www.example.com' ? ['146.75.123.10'] : [] + )); + const result = await edgeUtilsDns.detectCdnForDomain('example.com'); + expect(result).to.equal(CDN_TYPES.AEM_CS_FASTLY); + }); + }); + + describe('detectCdnForDomain (integration)', () => { + it('returns null when domain stringification throws', async () => { + const mod = await import('../../src/support/edge-routing-utils.js'); + const bad = { + toString() { + throw new Error('bad domain'); + }, + }; + const result = await mod.detectCdnForDomain(bad); + expect(result).to.equal(null); + }); + }); +}); diff --git a/test/support/slack/commands/onboard-status.test.js b/test/support/slack/commands/onboard-status.test.js index 22d27b173..60a1059cd 100644 --- a/test/support/slack/commands/onboard-status.test.js +++ b/test/support/slack/commands/onboard-status.test.js @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +/* eslint-env mocha */ + import { use, expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai';