From 1c34bf260d898cc59d252baf0037b5f286dea7db Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Tue, 3 Mar 2026 12:25:07 +0530 Subject: [PATCH 1/7] fix: add more edge logging --- src/controllers/suggestions.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 5253227e2..1e405719c 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -1281,7 +1281,6 @@ function SuggestionsController(ctx, sqs, env) { const domainWideSuggestions = []; const failedSuggestions = []; let coveredSuggestionsCount = 0; - // Check each requested suggestion (basic validation only) suggestionIds.forEach((suggestionId, index) => { const suggestion = allSuggestions.find((s) => s.getId() === suggestionId); @@ -1322,6 +1321,8 @@ function SuggestionsController(ctx, sqs, env) { validSuggestions.push(suggestion); } }); + context.log.info(`[edge-deploy] validSuggestions count: ${validSuggestions.length} + , Failed suggestions count: ${failedSuggestions.length}`); // Filter out validSuggestions that are covered by domain-wide suggestions // in the same deployment @@ -1422,6 +1423,8 @@ function SuggestionsController(ctx, sqs, env) { // Add ineligible suggestions to failed list ineligibleSuggestions.forEach((item) => { + context.log.info(`[edge-deploy-failed] site: ${apexBaseUrl}, ${opportunity.getType()}` + + ` suggestion ${item.suggestion.getId()} is ineligible: ${item.reason}`); failedSuggestions.push({ uuid: item.suggestion.getId(), index: suggestionIds.indexOf(item.suggestion.getId()), @@ -1447,6 +1450,7 @@ function SuggestionsController(ctx, sqs, env) { // Handle domain-wide suggestions separately if (isNonEmptyArray(domainWideSuggestions)) { + context.log.info(`[edge-deploy] domainWideSuggestions count: ${domainWideSuggestions.length}`); try { const tokowakaClient = TokowakaClient.createFrom(context); const baseURL = site.getBaseURL(); @@ -1621,7 +1625,7 @@ function SuggestionsController(ctx, sqs, env) { // Add to succeeded suggestions list for response succeededSuggestions.push(...skippedSuggestionEntities); } catch (error) { - context.log.error(`Error marking skipped suggestions: ${error.message}`, error); + context.log.error(`[edge-deploy-failed] site: ${apexBaseUrl}, Error marking skipped suggestions: ${error.message}`, error); // Add to failed if we couldn't mark them context.skippedDueToSameBatchDomainWide.forEach((skipped) => { failedSuggestions.push({ @@ -1746,7 +1750,8 @@ function SuggestionsController(ctx, sqs, env) { } } }); - + context.log.info(`[edge-rollback] validSuggestions count: ${validSuggestions.length}, + failedSuggestions count: ${failedSuggestions.length}`); let succeededSuggestions = []; // Separate domain-wide from regular suggestions @@ -1870,6 +1875,8 @@ function SuggestionsController(ctx, sqs, env) { // Add ineligible suggestions to failed list ineligibleSuggestions.forEach((item) => { + context.log.info(`[edge-rollback-failed] site: ${apexBaseUrl}, ${opportunity.getType()}` + + ` suggestion ${item.suggestion.getId()} is ineligible: ${item.reason}`); failedSuggestions.push({ uuid: item.suggestion.getId(), index: suggestionIds.indexOf(item.suggestion.getId()), From 37b2f99e699300b6c074282cecaba177691ec55d Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Sun, 5 Apr 2026 16:58:34 +0530 Subject: [PATCH 2/7] fix: initial commit --- src/controllers/llmo/llmo.js | 386 +++++----- src/routes/index.js | 1 - src/routes/required-capabilities.js | 1 - src/support/slack/commands/onboard-status.js | 203 +++++ test/controllers/llmo/llmo.test.js | 717 ++++++++---------- .../slack/commands/onboard-status.test.js | 605 +++++++++++++++ 6 files changed, 1311 insertions(+), 602 deletions(-) create mode 100644 src/support/slack/commands/onboard-status.js create mode 100644 test/support/slack/commands/onboard-status.test.js diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index d860696b0..9934d953a 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -31,9 +31,9 @@ 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 { 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 { triggerBrandProfileAgent } from '../../support/brand-profile-trigger.js'; import { applyFilters, @@ -1097,6 +1097,21 @@ function LlmoController(ctx) { const getDemoBrandPresence = createDemoFixtureHandler(handleDemoBrandPresence, 'brand-presence'); const getDemoRecommendations = createDemoFixtureHandler(handleDemoRecommendations, 'recommendations'); + // 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-config * Creates or updates Tokowaka edge optimization configuration @@ -1111,13 +1126,9 @@ 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 || {}; - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the edge optimize config'); - } - log.info(`createOrUpdateEdgeConfig request received for site ${siteId}, data=${JSON.stringify(context.data)}`); if (tokowakaEnabled !== undefined && typeof tokowakaEnabled !== 'boolean') { @@ -1140,6 +1151,20 @@ 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'); + } + + // Validate and normalise cdnType when provided + let cdnTypeNormalized = null; + if (hasText(cdnType)) { + const cdnTypeTrimmed = cdnType.toLowerCase().trim(); + cdnTypeNormalized = EDGE_OPTIMIZE_CDN_TYPES.includes(cdnTypeTrimmed) ? cdnTypeTrimmed : null; + if (!cdnTypeNormalized) { + return badRequest(`cdnType must be one of: ${EDGE_OPTIMIZE_CDN_TYPES.join(', ')}`); + } + } + try { // Get site const site = await Site.findById(siteId); @@ -1156,6 +1181,33 @@ function LlmoController(ctx) { return forbidden('User does not own this site'); } + // Authorization: LLMO Administrator (paid) OR member of LLMO Admin IMS group (trial) + const isPaidAdmin = accessControlUtil.isLLMOAdministrator(); + if (!isPaidAdmin) { + const llmoAdminGroupId = env.LLMO_ADMIN_IMS_GROUP_ID; + if (!hasText(llmoAdminGroupId)) { + return forbidden('Only LLMO administrators can update the edge optimize config'); + } + const org = await site.getOrganization(); + const imsOrgId = org.getImsOrgId(); + const userEmail = profile?.email; + let isTrialAdmin = false; + if (hasText(userEmail) && hasText(imsOrgId)) { + try { + isTrialAdmin = await context.imsClient.isUserInImsGroup( + imsOrgId, + llmoAdminGroupId, + userEmail, + ); + } catch (groupCheckError) { + log.warn(`[edge-optimize-config] IMS group check failed for site ${siteId}: ${groupCheckError.message}`); + } + } + if (!isTrialAdmin) { + return forbidden('Only LLMO administrators or LLMO Admin group members can update the edge optimize config'); + } + } + const baseURL = site.getBaseURL(); const tokowakaClient = TokowakaClient.createFrom(context); @@ -1225,6 +1277,143 @@ function LlmoController(ctx) { } } + // CDN routing — only when cdnType is provided + if (cdnTypeNormalized) { + // Restrict to production environment + if (env?.ENV && env.ENV !== 'prod') { + return createResponse({ message: `CDN routing is not available in ${env.ENV} environment` }, 400); + } + + 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]; + const routingEnabled = enabled ?? true; + + // Obtain the customer IMS org ID for the SP token request + const org = await site.getOrganization(); + const imsOrgId = org.getImsOrgId(); + if (!hasText(imsOrgId)) { + log.error(`[edge-optimize-config] IMS org ID not found for site ${siteId}`); + return internalServerError('IMS org ID not found for site'); + } + + // 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}`; + let probeResponse; + try { + log.info(`[edge-optimize-config] 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(`[edge-optimize-config] 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(`[edge-optimize-config] 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(`[edge-optimize-config] CDN routing update failed: ${msg}`); + return badRequest(msg); + } + domain = calculateForwardedHost(locationValue, log); + log.info(`[edge-optimize-config] Probe returned 301; using Location domain ${domain}`); + } else { + const msg = `Site ${probeUrl} did not return 2xx or 301 for` + + ` User-Agent AdobeEdgeOptimize-Test (got ${probeResponse.status})`; + log.error(`[edge-optimize-config] CDN routing update failed: ${msg}`); + return badRequest(msg); + } + + // Obtain the Service Principal access token scoped to the customer IMS org + let spToken; + try { + log.debug(`[edge-optimize-config] Obtaining SP token for site ${siteId}, org ${imsOrgId}`); + const edgeImsClient = ImsClient.createServiceClient(context); + const tokenData = await edgeImsClient.getServicePrincipalToken(imsOrgId); + spToken = tokenData.access_token; + log.info(`[edge-optimize-config] SP token obtained for site ${siteId}`); + } catch (tokenError) { + log.warn(`[edge-optimize-config] Failed to obtain SP token for site ${siteId}: ${tokenError.message}`); + return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); + } + + // Call CDN API with the SP token + try { + const cdnUrl = strategy.buildUrl(cdnConfig, domain); + const cdnBody = strategy.buildBody(routingEnabled); + log.info(`[edge-optimize-config] Calling CDN API for domain ${domain} 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(5000), + }); + + if (!cdnResponse.ok) { + const body = await cdnResponse.text(); + log.error(`[edge-optimize-config] 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); + } + + // Persist the routing enabled state + currentConfig.updateEdgeOptimizeConfig({ + ...currentConfig.getEdgeOptimizeConfig(), + enabled: routingEnabled, + }); + await saveSiteConfig(site, currentConfig, log, 'updating edge optimize routing state'); + log.info(`[edge-optimize-config] CDN routing updated for site ${siteId}, domain ${domain}`); + return ok({ + ...metaconfig, + enabled: routingEnabled, + domain, + cdnType: cdnTypeNormalized, + }); + } catch (cdnError) { + log.error(`[edge-optimize-config] CDN routing update failed for site ${siteId}: ${cdnError.message}`); + if (cdnError.status) { + return createResponse({ message: cdnError.message }, cdnError.status); + } + return internalServerError(cdnError.message); + } + } + return ok({ ...metaconfig, }); @@ -1428,190 +1617,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(error.message); - } - }; - const markOpportunitiesReviewed = async (context) => { const { log } = context; @@ -1792,7 +1797,6 @@ function LlmoController(ctx) { getStrategy, saveStrategy, checkEdgeOptimizeStatus, - updateEdgeOptimizeCDNRouting, markOpportunitiesReviewed, }; } diff --git a/src/routes/index.js b/src/routes/index.js index bb7b02910..66160fc43 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -414,7 +414,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 78749c1b0..1759076b5 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -70,7 +70,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/slack/commands/onboard-status.js b/src/support/slack/commands/onboard-status.js new file mode 100644 index 000000000..9a5a0ba00 --- /dev/null +++ b/src/support/slack/commands/onboard-status.js @@ -0,0 +1,203 @@ +/* + * 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 { + getAuditsForOpportunity, + getOpportunityTitle, + AUDIT_OPPORTUNITY_MAP, + computeAuditCompletion, +} from '@adobe/spacecat-shared-utils'; +import { extractURLFromSlackInput, loadProfileConfig } from '../../../utils/slack/base.js'; +import BaseCommand from './base.js'; + +const PHRASES = ['onboard status']; + +/** + * Factory function to create the OnboardStatusCommand object. + * + * Re-checks audit completion and opportunity statuses for a previously onboarded site, + * directly from the database — no Step Functions or SQS round-trip required. + * Use this when the original onboard completion message showed incomplete statuses because + * some audits were still in progress at the time. + * + * @param {Object} context - The context object. + * @returns {OnboardStatusCommand} - The OnboardStatusCommand object. + */ +function OnboardStatusCommand(context) { + const baseCommand = BaseCommand({ + id: 'onboard-status', + name: 'Onboard Status', + description: 'Re-checks audit completion and opportunity statuses for a previously onboarded site.', + phrases: PHRASES, + usageText: `${PHRASES[0]} + +Re-fetches the latest audit and opportunity statuses for a site that has already been onboarded. +Use this when the original onboard completion message showed incomplete statuses because some +audits were still in progress. + +Example: + onboard status https://www.example.com`, + }); + + const handleExecution = async (args, slackContext) => { + const { say } = slackContext; + const { dataAccess, log } = context; + const { Site, LatestAudit } = dataAccess; + + if (!args || args.length === 0) { + await say(':x: Please provide a site URL. Usage: `onboard status `'); + return; + } + + const rawUrl = args[0]; + const siteUrl = extractURLFromSlackInput(rawUrl) || rawUrl.trim().replace(/\/$/, ''); + + if (!siteUrl) { + await say(':x: Could not parse a valid URL. Usage: `onboard status `'); + return; + } + + try { + const site = await Site.findByBaseURL(siteUrl); + if (!site) { + await say(`:x: No site found for \`${siteUrl}\`. Please verify the URL and try again.`); + return; + } + + await say(`:hourglass_flowing_sand: Re-checking audit and opportunity status for \`${siteUrl}\`...`); + + const siteId = site.getId(); + + // Onboard trigger timestamp — same value the task processor reads as onboardStartTime. + // Used by computeAuditCompletion to detect audits that predate the last onboarding. + const onboardConfig = site.getConfig()?.getOnboardConfig(); + const lastStartTime = onboardConfig?.lastStartTime; + const lastProfile = onboardConfig?.lastProfile; + + // Load the profile used during onboarding to restrict which audit types are relevant. + // Falls back to all map-known types if the profile cannot be resolved. + let profileAuditTypes = null; + if (lastProfile) { + try { + const profile = loadProfileConfig(lastProfile); + if (profile?.audits && typeof profile.audits === 'object') { + profileAuditTypes = Object.keys(profile.audits) + .filter((t) => AUDIT_OPPORTUNITY_MAP[t]); + } + } catch (profileErr) { + log.warn(`[onboard-status] Could not load profile "${lastProfile}", falling back to all known types: ${profileErr.message}`); + } + } + const scopedTypes = profileAuditTypes || Object.keys(AUDIT_OPPORTUNITY_MAP); + + // Fetch latest audits — derive auditTypes (for opportunity filtering) from records, + // restricted to the profile's audit types so pre-existing records from prior runs + // don't bleed non-profile opportunities into the status view. + let auditTypes = []; + let pendingAuditTypes = []; + try { + const latestAudits = await LatestAudit.allBySiteId(siteId); + if (latestAudits && latestAudits.length > 0) { + auditTypes = [...new Set( + latestAudits + .map((a) => a.getAuditType()) + .filter((t) => scopedTypes.includes(t)), + )]; + } + const audits = latestAudits || []; + pendingAuditTypes = computeAuditCompletion(scopedTypes, lastStartTime, audits) + .pendingAuditTypes; + } catch (auditErr) { + log.warn(`[onboard-status] Could not fetch audit types for site ${siteId}: ${auditErr.message}`); + } + + // Build expected opportunity types from known audit types. + // auditTypes is already scoped to profile/map-known types so every entry has a mapping. + let expectedOpportunityTypes = []; + for (const auditType of auditTypes) { + const opps = AUDIT_OPPORTUNITY_MAP[auditType]; + if (opps && opps.length > 0) { + expectedOpportunityTypes.push(...opps); + } + } + expectedOpportunityTypes = [...new Set(expectedOpportunityTypes)]; + + // Fetch opportunities and build status lines. + // Opportunities whose source audit is still pending show ⏳ instead of stale ✅/ℹ️. + const opportunities = await site.getOpportunities(); + const processedTypes = new Set(); + const shouldFilter = auditTypes.length > 0 && expectedOpportunityTypes.length > 0; + + const visibleOpportunities = opportunities.filter((opportunity) => { + const oppType = opportunity.getType(); + if (shouldFilter && !expectedOpportunityTypes.includes(oppType)) return false; + if (processedTypes.has(oppType)) return false; + processedTypes.add(oppType); + return true; + }); + + const opportunityStatusLines = await Promise.all( + visibleOpportunities.map(async (opportunity) => { + const oppType = opportunity.getType(); + const sourceAuditIsPending = getAuditsForOpportunity(oppType) + .some((auditType) => pendingAuditTypes.includes(auditType)); + if (sourceAuditIsPending) { + return `${getOpportunityTitle(oppType)} :hourglass_flowing_sand:`; + } + const suggestions = await opportunity.getSuggestions(); + const statusIcon = suggestions?.length > 0 ? ':white_check_mark:' : ':information_source:'; + return `${getOpportunityTitle(oppType)} ${statusIcon}`; + }), + ); + + // Section: Opportunity Statuses + await say(`*Opportunity Statuses for site ${siteUrl}*`); + if (opportunityStatusLines.length > 0) { + await say(opportunityStatusLines.join('\n')); + } else { + await say('No opportunities found'); + } + + // Disclaimer: list pending audits, or confirm all complete. + // pendingAuditTypes is always computed against all map-known types, so this runs + // unconditionally — audits not yet started (no DB record) correctly appear as pending. + // Only list types with known opportunity mappings; infrastructure audits are excluded. + const relevantPendingTypes = pendingAuditTypes.filter( + (t) => AUDIT_OPPORTUNITY_MAP[t]?.length > 0, + ); + if (relevantPendingTypes.length > 0) { + // Expand audit types to their opportunity types so titles are accurate. + // Audit types like 'forms-opportunities' map to multiple distinct opp types. + const pendingOppTypes = [ + ...new Set(relevantPendingTypes.flatMap((t) => AUDIT_OPPORTUNITY_MAP[t])), + ]; + const pendingList = pendingOppTypes.map(getOpportunityTitle).join(', '); + await say( + `:warning: *Heads-up:* The following audit${relevantPendingTypes.length > 1 ? 's' : ''} ` + + `may still be in progress: *${pendingList}*.\n` + + 'The statuses above reflect data available at this moment and may be incomplete. ' + + `Run \`onboard status ${siteUrl}\` again once all audits have completed.`, + ); + } else { + await say(':white_check_mark: All audits have completed. The statuses above are up to date.'); + } + } catch (error) { + log.error(`[onboard-status] Error for ${siteUrl}: ${error.message}`); + await say(`:x: Error checking status for \`${siteUrl}\`: ${error.message}`); + } + }; + + baseCommand.init(context); + return { ...baseCommand, handleExecution }; +} + +export default OnboardStatusCommand; diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 5152ab2e6..942f8fa35 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -84,6 +84,8 @@ describe('LlmoController', () => { let fetchWithTimeoutStub; let postLlmoAlertStub; let postSlackMessageStub; + let getServicePrincipalTokenStub; + let isUserInImsGroupStub; const mockHttpUtils = { ok: (data, headers = {}) => ({ @@ -195,6 +197,13 @@ describe('LlmoController', () => { exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), fetchWithTimeout: (...args) => fetchWithTimeoutStub(...args), }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: { + createServiceClient: () => ({ + getServicePrincipalToken: (...args) => getServicePrincipalTokenStub(...args), + }), + }, + }, '../../../src/support/brand-profile-trigger.js': { triggerBrandProfileAgent: (...args) => triggerBrandProfileAgentStub(...args), }, @@ -507,8 +516,11 @@ describe('LlmoController', () => { }, }, pathInfo: { method: 'GET', suffix: '/llmo/sheet-data', headers: {} }, + imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, }; + getServicePrincipalTokenStub = sinon.stub().resolves({ access_token: 'sp-access-token' }); + isUserInImsGroupStub = sinon.stub().resolves(false); tracingFetchStub = sinon.stub(); fetchWithTimeoutStub = sinon.stub(); readConfigStub = sinon.stub(); @@ -4972,6 +4984,302 @@ describe('LlmoController', () => { expect(calledMessage).to.include('• Site: https://www.example.com'); expect(calledMessage).to.not.include('cc:'); }); + + // ── Trial admin authorization ─────────────────────────────────────────────── + + it('returns 403 when user is not LLMO admin and LLMO_ADMIN_IMS_GROUP_ID is not configured', async () => { + // controlled via the noAdmin controller (isLLMOAdministrator → false) + // edgeConfigContext.env has no LLMO_ADMIN_IMS_GROUP_ID + 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) => 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() }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: { + createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + }, + }, + }); + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(edgeConfigContext); + expect(result.status).to.equal(403); + expect((await result.json()).message).to.equal('Only LLMO administrators can update the edge optimize config'); + }); + + it('returns 403 when user is not LLMO admin and IMS group check returns false', async () => { + isUserInImsGroupStub.resolves(false); + const ctx = { + ...edgeConfigContext, + env: { ...edgeConfigContext.env, LLMO_ADMIN_IMS_GROUP_ID: 'llmo-admin-group-id' }, + imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, + }; + 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) => 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() }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: { + createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + }, + }, + }); + 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 IMS group check throws (trial admin path)', async () => { + isUserInImsGroupStub.rejects(new Error('IMS API error')); + const ctx = { + ...edgeConfigContext, + env: { ...edgeConfigContext.env, LLMO_ADMIN_IMS_GROUP_ID: 'llmo-admin-group-id' }, + imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, + }; + 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) => 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() }, + '@adobe/spacecat-shared-ims-client': { + ImsClient: { + createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + }, + }, + }); + const controllerNoAdmin = LlmoControllerNoAdmin(ctx); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); + expect(result.status).to.equal(403); + }); + + // ── cdnType / enabled input validation ───────────────────────────────────── + + it('returns 400 when cdnType is not a supported value', async () => { + const result = await controller.createOrUpdateEdgeConfig({ + ...edgeConfigContext, + data: { cdnType: 'unknown-cdn' }, + }); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('cdnType must be one of'); + }); + + 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 = {}) { + return { + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, ...overrides.data }, + env: { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly, ...overrides.env }, + }; + } + + 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'] }); + }); + + 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 500 when IMS org ID is missing on the site org', async () => { + const orgNoIms = { + getId: sinon.stub().returns(TEST_ORG_ID), + getImsOrgId: sinon.stub().returns(''), + }; + mockSite.getOrganization = sinon.stub().resolves(orgNoIms); + tracingFetchStub.resolves({ ok: true }); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.include('IMS org ID not found'); + }); + + it('returns 400 when site probe throws', async () => { + tracingFetchStub.rejects(new Error('Connection refused')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(400); + expect((await result.json()).message).to.include('Error probing site'); + }); + + it('returns 400 when probe returns non-2xx non-301', async () => { + tracingFetchStub.resolves({ ok: false, status: 404 }); + 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 () => { + tracingFetchStub.resolves({ + ok: false, + status: 301, + headers: { get: (n) => (n === 'location' ? 'https://other-domain.com/' : null) }, + }); + 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 () => { + tracingFetchStub.resolves({ ok: true }); + 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 403 when CDN API responds 403', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + tracingFetchStub.onSecondCall().resolves({ + ok: false, + status: 403, + text: sinon.stub().resolves('forbidden'), + }); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(403); + expect((await result.json()).message).to.include('not authorized'); + }); + + it('returns 500 when CDN API responds with non-401/403 error', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + tracingFetchStub.onSecondCall().resolves({ + ok: false, + status: 503, + text: sinon.stub().resolves('unavailable'), + }); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.include('Upstream call failed'); + }); + + it('returns 200 with routing data when CDN routing succeeds', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + tracingFetchStub.onSecondCall().resolves({ ok: true }); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.include.keys('domain', 'cdnType'); + expect(body.enabled).to.equal(true); + expect(body.cdnType).to.equal(LOG_SOURCES.AEM_CS_FASTLY); + expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( + sinon.match({ enabled: true }), + ); + }); + + it('returns 200 and sets enabled:false when routing with enabled=false', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + tracingFetchStub.onSecondCall().resolves({ ok: true }); + const routingData = { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: false }; + const result = await controller.createOrUpdateEdgeConfig( + makeRoutingCtx({ data: routingData }), + ); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.enabled).to.equal(false); + expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( + sinon.match({ enabled: false }), + ); + }); + + it('returns 200 with routing on 301 redirect to same root domain', async () => { + mockSite.getBaseURL = sinon.stub().returns('https://example.com'); + tracingFetchStub.onFirstCall().resolves({ + ok: false, + status: 301, + headers: { + get: (n) => (n === 'location' ? 'https://www.example.com/' : null), + }, + }); + tracingFetchStub.onSecondCall().resolves({ ok: true }); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.domain).to.be.a('string'); + }); + + it('uses status from thrown CDN error when available', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + const err = Object.assign(new Error('CDN failed'), { status: 418 }); + tracingFetchStub.onSecondCall().rejects(err); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(418); + }); + + it('returns 500 when CDN fetch throws without a status', async () => { + tracingFetchStub.onFirstCall().resolves({ ok: true }); + tracingFetchStub.onSecondCall().rejects(new Error('Network error')); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(500); + }); + }); // end describe('CDN routing') }); describe('createOrUpdateStageEdgeConfig', () => { @@ -5667,415 +5975,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/support/slack/commands/onboard-status.test.js b/test/support/slack/commands/onboard-status.test.js new file mode 100644 index 000000000..60a1059cd --- /dev/null +++ b/test/support/slack/commands/onboard-status.test.js @@ -0,0 +1,605 @@ +/* + * 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 { use, expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; +import { AUDIT_OPPORTUNITY_MAP, computeAuditCompletion } from '@adobe/spacecat-shared-utils'; + +use(sinonChai); + +describe('OnboardStatusCommand', () => { + let context; + let slackContext; + let dataAccessStub; + let extractURLFromSlackInputStub; + let loadProfileConfigStub; + let OnboardStatusCommand; + + const siteUrl = 'https://example.com'; + const siteId = 'test-site-id'; + const onboardTime = Date.now() - 3600000; // 1 hour ago + + function makeAudit(type, auditedAt) { + return { + getAuditType: sinon.stub().returns(type), + getAuditedAt: sinon.stub().returns(auditedAt), + }; + } + + function makeSite(overrides = {}) { + const { lastStartTime, lastProfile, ...siteOverrides } = overrides; + const onboardConfig = lastStartTime || lastProfile + ? { ...(lastStartTime ? { lastStartTime } : {}), ...(lastProfile ? { lastProfile } : {}) } + : undefined; + return { + getId: sinon.stub().returns(siteId), + getOpportunities: sinon.stub().resolves([]), + getConfig: sinon.stub().returns({ + getOnboardConfig: sinon.stub().returns(onboardConfig), + }), + ...siteOverrides, + }; + } + + beforeEach(async () => { + extractURLFromSlackInputStub = sinon.stub().callsFake((url) => url.trim().replace(/\/$/, '')); + loadProfileConfigStub = sinon.stub(); + + dataAccessStub = { + Site: { findByBaseURL: sinon.stub() }, + LatestAudit: { allBySiteId: sinon.stub() }, + }; + + context = { + dataAccess: dataAccessStub, + log: { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }, + }; + + slackContext = { + say: sinon.stub().resolves(), + channelId: 'test-channel', + threadTs: 'test-thread', + }; + + OnboardStatusCommand = await esmock( + '../../../../src/support/slack/commands/onboard-status.js', + { + '../../../../src/utils/slack/base.js': { + extractURLFromSlackInput: extractURLFromSlackInputStub, + loadProfileConfig: loadProfileConfigStub, + }, + }, + ); + }); + + afterEach(() => { + sinon.restore(); + esmock.purge(OnboardStatusCommand); + }); + + describe('Initialization', () => { + it('initializes with correct base command properties', () => { + const command = OnboardStatusCommand(context); + expect(command.id).to.equal('onboard-status'); + expect(command.name).to.equal('Onboard Status'); + expect(command.description).to.include('Re-checks'); + expect(command.phrases).to.deep.equal(['onboard status']); + }); + }); + + describe('handleExecution — argument validation', () => { + it('says error when args is empty', async () => { + const command = OnboardStatusCommand(context); + await command.handleExecution([], slackContext); + expect(slackContext.say).to.have.been.calledWith( + ':x: Please provide a site URL. Usage: `onboard status `', + ); + }); + + it('says error when args is null', async () => { + const command = OnboardStatusCommand(context); + await command.handleExecution(null, slackContext); + expect(slackContext.say).to.have.been.calledWith( + ':x: Please provide a site URL. Usage: `onboard status `', + ); + }); + + it('says error when URL cannot be parsed', async () => { + extractURLFromSlackInputStub.returns(null); + const command = OnboardStatusCommand(context); + await command.handleExecution(['/'], slackContext); + expect(slackContext.say).to.have.been.calledWith( + ':x: Could not parse a valid URL. Usage: `onboard status `', + ); + }); + }); + + describe('handleExecution — site lookup', () => { + it('says error when site not found', async () => { + dataAccessStub.Site.findByBaseURL.resolves(null); + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + expect(slackContext.say).to.have.been.calledWith( + `:x: No site found for \`${siteUrl}\`. Please verify the URL and try again.`, + ); + }); + + it('sends hourglass message immediately after site found', async () => { + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith( + `:hourglass_flowing_sand: Re-checking audit and opportunity status for \`${siteUrl}\`...`, + ); + }); + }); + + describe('handleExecution — opportunity statuses', () => { + it('shows "No opportunities found" when site has no opportunities', async () => { + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith('No opportunities found'); + }); + + it('shows checkmark for opportunity with suggestions when audit is complete', async () => { + const opp = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([{ id: 's1' }]) }; + const siteWithOpp = makeSite({ getOpportunities: sinon.stub().resolves([opp]) }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithOpp); + // audit completed after onboardStartTime + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith('Core Web Vitals :white_check_mark:'); + }); + + it('shows info icon for opportunity with no suggestions when audit is complete', async () => { + const opp = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([]) }; + const siteWithOpp = makeSite({ getOpportunities: sinon.stub().resolves([opp]) }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithOpp); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith('Core Web Vitals :information_source:'); + }); + + it('shows hourglass for opportunity whose source audit is still pending', async () => { + const opp = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([{ id: 's1' }]) }; + // lastStartTime=onboardTime; cwv record predates it → pending → ⏳ + const siteWithOpp = makeSite({ + lastStartTime: onboardTime, + getOpportunities: sinon.stub().resolves([opp]), + }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithOpp); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime - 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith('Core Web Vitals :hourglass_flowing_sand:'); + // getSuggestions should NOT be called — no point fetching for a pending audit + expect(opp.getSuggestions).to.not.have.been.called; + }); + + it('does not fetch suggestions for pending opportunity', async () => { + const opp = { getType: sinon.stub().returns('sitemap'), getSuggestions: sinon.stub().resolves([]) }; + // lastStartTime=onboardTime; sitemap record predates it → pending + const siteWithOpp = makeSite({ + lastStartTime: onboardTime, + getOpportunities: sinon.stub().resolves([opp]), + }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithOpp); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('sitemap', new Date(onboardTime - 500).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(opp.getSuggestions).to.not.have.been.called; + }); + + it('processes each duplicate opportunity type only once', async () => { + const opp1 = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([{ id: 's1' }]) }; + const opp2 = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([{ id: 's2' }]) }; + const siteWithDupes = makeSite({ getOpportunities: sinon.stub().resolves([opp1, opp2]) }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithDupes); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(opp1.getSuggestions).to.have.been.calledOnce; + expect(opp2.getSuggestions).to.not.have.been.called; + }); + + it('skips opportunity not in expected audit types', async () => { + const opp = { getType: sinon.stub().returns('meta-tags'), getSuggestions: sinon.stub().resolves([]) }; + const siteWithMetaTags = makeSite({ getOpportunities: sinon.stub().resolves([opp]) }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithMetaTags); + // auditTypes = ['cwv'], meta-tags opportunity is filtered out + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(opp.getSuggestions).to.not.have.been.called; + expect(slackContext.say).to.have.been.calledWith('No opportunities found'); + }); + + it('shows all opportunities when only unknown audit types are in DB (no profile set)', async () => { + // unknown-audit-type is not in AUDIT_OPPORTUNITY_MAP → filtered from auditTypes → + // auditTypes is empty → shouldFilter is false → all opportunities shown + const opp = { getType: sinon.stub().returns('some-opp'), getSuggestions: sinon.stub().resolves([]) }; + const siteWithUnknown = makeSite({ getOpportunities: sinon.stub().resolves([opp]) }); + dataAccessStub.Site.findByBaseURL.resolves(siteWithUnknown); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('unknown-audit-type', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(opp.getSuggestions).to.have.been.called; + }); + }); + + describe('handleExecution — audit completion disclaimer', () => { + // All audit types present in AUDIT_OPPORTUNITY_MAP + const ALL_MAP_TYPES = Object.keys(AUDIT_OPPORTUNITY_MAP); + + function makeAllMapAudits(timestamp) { + return ALL_MAP_TYPES.map((t) => makeAudit(t, timestamp)); + } + + it('shows "all complete" when all map-known audits completed after lastStartTime', async () => { + // lastStartTime = onboardTime; audits completed at onboardTime+1000 → completed + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastStartTime: onboardTime })); + dataAccessStub.LatestAudit.allBySiteId.resolves( + makeAllMapAudits(new Date(onboardTime + 1000).toISOString()), + ); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith( + ':white_check_mark: All audits have completed. The statuses above are up to date.', + ); + }); + + it('shows pending warning when audit record predates lastStartTime', async () => { + // cwv ran at onboardTime-1000 but lastStartTime is onboardTime → cwv is pending + const audits = ALL_MAP_TYPES.map((t) => makeAudit( + t, + new Date(t === 'cwv' ? onboardTime - 1000 : onboardTime + 1000).toISOString(), + )); + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastStartTime: onboardTime })); + dataAccessStub.LatestAudit.allBySiteId.resolves(audits); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.include('Core Web Vitals'); + expect(disclaimer).to.include(`Run \`onboard status ${siteUrl}\``); + }); + + it('uses singular "audit" grammar when exactly one audit is pending', async () => { + // Only cwv is stale (predates lastStartTime); all others completed after + const audits = ALL_MAP_TYPES.map((t) => makeAudit( + t, + new Date(t === 'cwv' ? onboardTime - 1000 : onboardTime + 1000).toISOString(), + )); + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastStartTime: onboardTime })); + dataAccessStub.LatestAudit.allBySiteId.resolves(audits); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.match(/audit may still be in progress/); + }); + + it('uses plural "audits" grammar when multiple audits are pending', async () => { + // No records at all → all map types pending regardless of lastAuditRunTime + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.match(/audits may still be in progress/); + }); + + it('shows "all complete" when records exist but no lastStartTime set', async () => { + // No lastStartTime in onboardConfig → any existing record counts as completed + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves( + makeAllMapAudits(new Date().toISOString()), + ); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith( + ':white_check_mark: All audits have completed. The statuses above are up to date.', + ); + }); + + it('shows all map-known types as pending when no audit records exist yet', async () => { + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + expect(calls.some((m) => m.includes('may still be in progress'))).to.be.true; + }); + + it('treats null response from allBySiteId as empty and shows all types pending', async () => { + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.resolves(null); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + expect(calls.some((m) => m.includes('may still be in progress'))).to.be.true; + }); + + it('excludes infrastructure audit types not in AUDIT_OPPORTUNITY_MAP from disclaimer', async () => { + // cwv record predates lastStartTime → pending + // scrape-top-pages is not in the map → excluded from disclaimer + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastStartTime: onboardTime })); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime - 1000).toISOString()), + makeAudit('scrape-top-pages', new Date(onboardTime - 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.exist; + expect(disclaimer).to.include('Core Web Vitals'); + expect(disclaimer).to.not.include('Scrape Top Pages'); + }); + + it('expands multi-opp audit types to their opportunity titles in the disclaimer', async () => { + // forms-opportunities record predates lastStartTime → pending + // Should show opportunity type titles, not the audit type name + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastStartTime: onboardTime })); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('forms-opportunities', new Date(onboardTime - 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + const calls = slackContext.say.args.map((a) => a[0]); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + // Opportunity type titles, not the audit type fallback "Forms Opportunities" + expect(disclaimer).to.include('High Form Views Low Conversions'); + }); + }); + + describe('handleExecution — profile-scoped filtering', () => { + it('restricts audit types and pending check to profile when lastProfile is set', async () => { + // plg profile has cwv and broken-backlinks; site also has a meta-tags record from a prior run + loadProfileConfigStub.returns({ + audits: { cwv: {}, 'broken-backlinks': {} }, + imports: {}, + }); + const opp = { getType: sinon.stub().returns('cwv'), getSuggestions: sinon.stub().resolves([{ id: 's1' }]) }; + const metaTagsOpp = { getType: sinon.stub().returns('meta-tags'), getSuggestions: sinon.stub().resolves([]) }; + const site = makeSite({ + lastProfile: 'plg', + lastStartTime: onboardTime, + getOpportunities: sinon.stub().resolves([opp, metaTagsOpp]), + }); + dataAccessStub.Site.findByBaseURL.resolves(site); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + makeAudit('meta-tags', new Date(onboardTime + 1000).toISOString()), // pre-existing, not in profile + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + // cwv opp shown; meta-tags opp filtered out + expect(slackContext.say).to.have.been.calledWith('Core Web Vitals :white_check_mark:'); + expect(metaTagsOpp.getSuggestions).to.not.have.been.called; + }); + + it('falls back to all map-known types and logs warning when loadProfileConfig throws', async () => { + loadProfileConfigStub.throws(new Error('profile not found')); + dataAccessStub.Site.findByBaseURL.resolves(makeSite({ lastProfile: 'unknown-profile' })); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(context.log.warn).to.have.been.calledWith( + sinon.match(/Could not load profile "unknown-profile", falling back to all known types/), + ); + // Still shows pending disclaimer (all map types pending since no audit records) + const calls = slackContext.say.args.map((a) => a[0]); + expect(calls.some((m) => m.includes('may still be in progress'))).to.be.true; + }); + + it('filters out profile audit types not in AUDIT_OPPORTUNITY_MAP', async () => { + // Profile has cwv (known) and scrape-top-pages (not in map) — only cwv should scope + loadProfileConfigStub.returns({ + audits: { cwv: {}, 'scrape-top-pages': {} }, + imports: {}, + }); + const site = makeSite({ lastProfile: 'demo', lastStartTime: onboardTime }); + dataAccessStub.Site.findByBaseURL.resolves(site); + dataAccessStub.LatestAudit.allBySiteId.resolves([ + makeAudit('cwv', new Date(onboardTime + 1000).toISOString()), + ]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + // Only cwv is in scopedTypes; broken-backlinks (in map but not in profile) stays pending + expect(slackContext.say).to.have.been.calledWith('No opportunities found'); + const calls = slackContext.say.args.map((a) => a[0]); + // The pending check should NOT include scrape-top-pages (no opp mapping) + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.not.exist; // cwv completed, scrape-top-pages has no opp mapping + }); + + it('uses all map-known types when lastProfile is absent', async () => { + // No lastProfile stored → scopedTypes falls back to Object.keys(AUDIT_OPPORTUNITY_MAP) + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); // no lastProfile + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + // loadProfileConfig must not be called + expect(loadProfileConfigStub).to.not.have.been.called; + // All map types are pending — disclaimer is shown + const calls = slackContext.say.args.map((a) => a[0]); + expect(calls.some((m) => m.includes('may still be in progress'))).to.be.true; + }); + }); + + describe('handleExecution — error handling', () => { + it('warns and continues when audit fetch fails', async () => { + dataAccessStub.Site.findByBaseURL.resolves(makeSite()); + dataAccessStub.LatestAudit.allBySiteId.rejects(new Error('timeout')); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(context.log.warn).to.have.been.calledWith( + sinon.match(/Could not fetch audit types for site test-site-id: timeout/), + ); + expect(slackContext.say).to.have.been.calledWith( + `*Opportunity Statuses for site ${siteUrl}*`, + ); + }); + + it('handles null getConfig gracefully', async () => { + const site = makeSite({ getConfig: sinon.stub().returns(null) }); + dataAccessStub.Site.findByBaseURL.resolves(site); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(slackContext.say).to.have.been.calledWith( + `*Opportunity Statuses for site ${siteUrl}*`, + ); + }); + + it('logs error and says error when getOpportunities throws', async () => { + const site = makeSite({ getOpportunities: sinon.stub().rejects(new Error('DB error')) }); + dataAccessStub.Site.findByBaseURL.resolves(site); + dataAccessStub.LatestAudit.allBySiteId.resolves([]); + + const command = OnboardStatusCommand(context); + await command.handleExecution([siteUrl], slackContext); + + expect(context.log.error).to.have.been.calledWith( + sinon.match(/\[onboard-status\] Error for https:\/\/example\.com: DB error/), + ); + expect(slackContext.say).to.have.been.calledWith( + sinon.match(/:x: Error checking status for `https:\/\/example\.com`: DB error/), + ); + }); + }); +}); + +describe('computeAuditCompletion', () => { + it('marks audit type as pending when no record exists in latestAudits', () => { + const { pendingAuditTypes, completedAuditTypes } = computeAuditCompletion(['cwv'], undefined, []); + expect(pendingAuditTypes).to.deep.equal(['cwv']); + expect(completedAuditTypes).to.deep.equal([]); + }); + + it('marks audit as pending when record predates lastStartTime', () => { + const runTime = Date.now(); + const staleAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(runTime - 1000).toISOString(), + }; + const { pendingAuditTypes } = computeAuditCompletion(['cwv'], runTime, [staleAudit]); + expect(pendingAuditTypes).to.deep.equal(['cwv']); + }); + + it('marks audit as pending when auditedAt exactly equals lastStartTime (boundary)', () => { + const runTime = Date.now(); + const boundaryAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(runTime).toISOString(), + }; + const { pendingAuditTypes } = computeAuditCompletion(['cwv'], runTime, [boundaryAudit]); + expect(pendingAuditTypes).to.deep.equal(['cwv']); + }); + + it('marks audit as completed when record postdates lastStartTime', () => { + const runTime = Date.now() - 60000; + const freshAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(runTime + 1000).toISOString(), + }; + const { completedAuditTypes } = computeAuditCompletion(['cwv'], runTime, [freshAudit]); + expect(completedAuditTypes).to.deep.equal(['cwv']); + }); + + it('treats existing record as completed when no lastStartTime set', () => { + const audit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(Date.now() - 86400000).toISOString(), // 1 day old + }; + const { completedAuditTypes, pendingAuditTypes } = computeAuditCompletion(['cwv'], undefined, [audit]); + expect(completedAuditTypes).to.deep.equal(['cwv']); + expect(pendingAuditTypes).to.deep.equal([]); + }); +}); From 35755ae66986490ead675aa4c0feb48c9a90e98a Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Sun, 5 Apr 2026 17:58:50 +0530 Subject: [PATCH 3/7] fix: working flow --- src/controllers/llmo/llmo-utils.js | 11 ++++ src/controllers/llmo/llmo.js | 45 ++++++++++------- test/controllers/llmo/llmo.test.js | 81 +++++++++++++++++++----------- 3 files changed, 92 insertions(+), 45 deletions(-) diff --git a/src/controllers/llmo/llmo-utils.js b/src/controllers/llmo/llmo-utils.js index c07341a03..eb821e6f5 100644 --- a/src/controllers/llmo/llmo-utils.js +++ b/src/controllers/llmo/llmo-utils.js @@ -41,6 +41,17 @@ export const EDGE_OPTIMIZE_CDN_STRATEGIES = { export const EDGE_OPTIMIZE_CDN_TYPES = Object.keys(EDGE_OPTIMIZE_CDN_STRATEGIES); +export const LLMO_ADMIN_GROUP_NAME = 'LLMO Admin'; + +// 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; + // 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 9934d953a..6693129bf 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -31,7 +31,7 @@ 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 { ImsClient } from '@adobe/spacecat-shared-ims-client'; +import { ImsServiceClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { triggerBrandProfileAgent } from '../../support/brand-profile-trigger.js'; @@ -44,6 +44,9 @@ import { LLMO_SHEETDATA_SOURCE_URL, EDGE_OPTIMIZE_CDN_STRATEGIES, EDGE_OPTIMIZE_CDN_TYPES, + LLMO_ADMIN_GROUP_NAME, + OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE, + EDGE_OPTIMIZE_MARKING_DELAY_SECONDS, } from './llmo-utils.js'; import { LLMO_SHEET_MAPPINGS } from './llmo-mappings.js'; import { @@ -1184,21 +1187,23 @@ function LlmoController(ctx) { // Authorization: LLMO Administrator (paid) OR member of LLMO Admin IMS group (trial) const isPaidAdmin = accessControlUtil.isLLMOAdministrator(); if (!isPaidAdmin) { - const llmoAdminGroupId = env.LLMO_ADMIN_IMS_GROUP_ID; - if (!hasText(llmoAdminGroupId)) { - return forbidden('Only LLMO administrators can update the edge optimize config'); - } const org = await site.getOrganization(); const imsOrgId = org.getImsOrgId(); const userEmail = profile?.email; let isTrialAdmin = false; if (hasText(userEmail) && hasText(imsOrgId)) { try { - isTrialAdmin = await context.imsClient.isUserInImsGroup( - imsOrgId, - llmoAdminGroupId, - userEmail, + const groups = await context.imsClient.getOrgGroups(imsOrgId); + const llmoAdminGroup = groups.find( + (g) => g.groupName === LLMO_ADMIN_GROUP_NAME, ); + if (llmoAdminGroup) { + isTrialAdmin = await context.imsClient.isUserInImsGroup( + imsOrgId, + String(llmoAdminGroup.ident), + userEmail, + ); + } } catch (groupCheckError) { log.warn(`[edge-optimize-config] IMS group check failed for site ${siteId}: ${groupCheckError.message}`); } @@ -1359,7 +1364,7 @@ function LlmoController(ctx) { let spToken; try { log.debug(`[edge-optimize-config] Obtaining SP token for site ${siteId}, org ${imsOrgId}`); - const edgeImsClient = ImsClient.createServiceClient(context); + const edgeImsClient = ImsServiceClient.createFrom(context); const tokenData = await edgeImsClient.getServicePrincipalToken(imsOrgId); spToken = tokenData.access_token; log.info(`[edge-optimize-config] SP token obtained for site ${siteId}`); @@ -1392,16 +1397,22 @@ function LlmoController(ctx) { return createResponse({ message: `Upstream call failed with status ${cdnResponse.status}` }, 500); } - // Persist the routing enabled state - currentConfig.updateEdgeOptimizeConfig({ - ...currentConfig.getEdgeOptimizeConfig(), - enabled: routingEnabled, - }); - await saveSiteConfig(site, currentConfig, log, 'updating edge optimize routing state'); log.info(`[edge-optimize-config] CDN routing updated for site ${siteId}, domain ${domain}`); + // 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-config] Queued edge-optimize enabled marking for site ${siteId} (delay: ${EDGE_OPTIMIZE_MARKING_DELAY_SECONDS}s)`); + } catch (sqsError) { + log.warn(`[edge-optimize-config] Failed to queue edge-optimize enabled marking for site ${siteId}: ${sqsError.message}`); + } return ok({ ...metaconfig, - enabled: routingEnabled, domain, cdnType: cdnTypeNormalized, }); diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 942f8fa35..0cf668e5c 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -86,6 +86,7 @@ describe('LlmoController', () => { let postSlackMessageStub; let getServicePrincipalTokenStub; let isUserInImsGroupStub; + let getOrgGroupsStub; const mockHttpUtils = { ok: (data, headers = {}) => ({ @@ -198,8 +199,8 @@ describe('LlmoController', () => { fetchWithTimeout: (...args) => fetchWithTimeoutStub(...args), }, '@adobe/spacecat-shared-ims-client': { - ImsClient: { - createServiceClient: () => ({ + ImsServiceClient: { + createFrom: () => ({ getServicePrincipalToken: (...args) => getServicePrincipalTokenStub(...args), }), }, @@ -477,6 +478,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', @@ -516,11 +518,15 @@ describe('LlmoController', () => { }, }, pathInfo: { method: 'GET', suffix: '/llmo/sheet-data', headers: {} }, - imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + }, }; getServicePrincipalTokenStub = sinon.stub().resolves({ access_token: 'sp-access-token' }); isUserInImsGroupStub = sinon.stub().resolves(false); + getOrgGroupsStub = sinon.stub().resolves([{ groupName: 'LLMO Admin', ident: 99999 }]); tracingFetchStub = sinon.stub(); fetchWithTimeoutStub = sinon.stub(); readConfigStub = sinon.stub(); @@ -4689,7 +4695,7 @@ describe('LlmoController', () => { 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 Admin group members'); }); // Note: Slack notification functionality uses postLlmoAlert() from llmo-onboarding.js @@ -4987,9 +4993,16 @@ describe('LlmoController', () => { // ── Trial admin authorization ─────────────────────────────────────────────── - it('returns 403 when user is not LLMO admin and LLMO_ADMIN_IMS_GROUP_ID is not configured', async () => { - // controlled via the noAdmin controller (isLLMOAdministrator → false) - // edgeConfigContext.env has no LLMO_ADMIN_IMS_GROUP_ID + it('returns 403 when user is not LLMO admin and LLMO Admin IMS group is not found in org', async () => { + // getOrgGroups returns groups but none named 'LLMO Admin' + getOrgGroupsStub.resolves([{ groupName: 'Administrators', ident: 11111 }]); + const ctx = { + ...edgeConfigContext, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + }, + }; 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, @@ -5006,23 +5019,25 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsClient: { - createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsServiceClient: { + createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, }); - const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); - const result = await controllerNoAdmin.createOrUpdateEdgeConfig(edgeConfigContext); + const controllerNoAdmin = LlmoControllerNoAdmin(ctx); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); expect(result.status).to.equal(403); - expect((await result.json()).message).to.equal('Only LLMO administrators can update the edge optimize config'); + expect((await result.json()).message).to.include('LLMO Admin group members'); }); it('returns 403 when user is not LLMO admin and IMS group check returns false', async () => { isUserInImsGroupStub.resolves(false); const ctx = { ...edgeConfigContext, - env: { ...edgeConfigContext.env, LLMO_ADMIN_IMS_GROUP_ID: 'llmo-admin-group-id' }, - imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + }, }; const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), @@ -5040,8 +5055,8 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsClient: { - createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsServiceClient: { + createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, }); @@ -5052,11 +5067,13 @@ describe('LlmoController', () => { }); it('returns 403 when IMS group check throws (trial admin path)', async () => { - isUserInImsGroupStub.rejects(new Error('IMS API error')); + getOrgGroupsStub.rejects(new Error('IMS API error')); const ctx = { ...edgeConfigContext, - env: { ...edgeConfigContext.env, LLMO_ADMIN_IMS_GROUP_ID: 'llmo-admin-group-id' }, - imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args) }, + imsClient: { + isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), + getOrgGroups: (...args) => getOrgGroupsStub(...args), + }, }; const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), @@ -5074,8 +5091,8 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsClient: { - createServiceClient: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsServiceClient: { + createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, }); @@ -5227,14 +5244,18 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); expect(body).to.include.keys('domain', 'cdnType'); - expect(body.enabled).to.equal(true); + expect(body).to.not.have.key('enabled'); expect(body.cdnType).to.equal(LOG_SOURCES.AEM_CS_FASTLY); - expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( - sinon.match({ enabled: true }), + expect(mockConfig.updateEdgeOptimizeConfig).to.not.have.been.called; + 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 sets enabled:false when routing with enabled=false', async () => { + it('returns 200 with routing data when CDN routing disabled (enabled=false)', async () => { tracingFetchStub.onFirstCall().resolves({ ok: true }); tracingFetchStub.onSecondCall().resolves({ ok: true }); const routingData = { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: false }; @@ -5243,9 +5264,13 @@ describe('LlmoController', () => { ); expect(result.status).to.equal(200); const body = await result.json(); - expect(body.enabled).to.equal(false); - expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.calledWith( - sinon.match({ enabled: false }), + expect(body).to.not.have.key('enabled'); + expect(mockConfig.updateEdgeOptimizeConfig).to.not.have.been.called; + expect(mockContext.sqs.sendMessage).to.have.been.calledWith( + mockEnv.IMPORT_WORKER_QUEUE_URL, + { type: 'optimize-at-edge-enabled-marking' }, + undefined, + { delaySeconds: 300 }, ); }); From c57ce97fa2b87f4ba0f7c0dc89ad74324ae5c0c3 Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Sun, 5 Apr 2026 20:16:41 +0530 Subject: [PATCH 4/7] fix: refactoring --- package-lock.json | 6 +- package.json | 2 +- src/controllers/llmo/llmo-utils.js | 37 ---- src/controllers/llmo/llmo.js | 232 ++++++++-------------- src/support/edge-routing-utils.js | 201 +++++++++++++++++++ test/controllers/llmo/llmo.test.js | 176 +++++++++-------- test/support/edge-routing-utils.test.js | 253 ++++++++++++++++++++++++ 7 files changed, 637 insertions(+), 270 deletions(-) create mode 100644 src/support/edge-routing-utils.js create mode 100644 test/support/edge-routing-utils.test.js diff --git a/package-lock.json b/package-lock.json index 24952394f..f69ab8bf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@adobe/spacecat-shared-drs-client": "1.4.1", "@adobe/spacecat-shared-gpt-client": "1.6.20", "@adobe/spacecat-shared-http-utils": "1.25.1", - "@adobe/spacecat-shared-ims-client": "1.12.2", + "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", "@adobe/spacecat-shared-rum-api-client": "2.40.10", "@adobe/spacecat-shared-scrape-client": "2.6.0", "@adobe/spacecat-shared-slack-client": "1.6.4", @@ -5743,8 +5743,8 @@ }, "node_modules/@adobe/spacecat-shared-ims-client": { "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-ims-client/-/spacecat-shared-ims-client-1.12.2.tgz", - "integrity": "sha512-kaq7DRr4U2K7oLhZEJ/mkcWb5In3EoU4My4biIgxBWleZsgcOwsKJZ3ztdyBF92zBhyng0tKjbapz60o80PwDQ==", + "resolved": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", + "integrity": "sha512-FaA0ok+tbJEHuo+wF5UcwC753JKQTQmDByntgQTs6iNmJIOI6QQO/gsFxdgG2iPXrouNxMSMxbtK0kAKPTGbrg==", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", diff --git a/package.json b/package.json index 77cc0e494..96d6d6cbc 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0", "@adobe/spacecat-shared-gpt-client": "1.6.20", "@adobe/spacecat-shared-http-utils": "1.25.1", - "@adobe/spacecat-shared-ims-client": "1.12.2", + "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", "@adobe/spacecat-shared-rum-api-client": "2.40.10", "@adobe/spacecat-shared-scrape-client": "2.6.0", "@adobe/spacecat-shared-slack-client": "1.6.4", diff --git a/src/controllers/llmo/llmo-utils.js b/src/controllers/llmo/llmo-utils.js index eb821e6f5..5183b4095 100644 --- a/src/controllers/llmo/llmo-utils.js +++ b/src/controllers/llmo/llmo-utils.js @@ -13,45 +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 = { - BYOCDN_FASTLY: 'byocdn-fastly', - BYOCDN_AKAMAI: 'byocdn-akamai', - BYOCDN_CLOUDFRONT: 'byocdn-cloudfront', - BYOCDN_CLOUDFLARE: 'byocdn-cloudflare', - BYOCDN_IMPERVA: 'byocdn-imperva', - BYOCDN_OTHER: 'byocdn-other', - AMS_CLOUDFRONT: 'ams-cloudfront', - AMS_FRONTDOOR: 'ams-frontdoor', - AEM_CS_FASTLY: 'aem-cs-fastly', - 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); - export const LLMO_ADMIN_GROUP_NAME = 'LLMO Admin'; -// 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; - // 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 6693129bf..439550296 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -30,10 +30,20 @@ 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 { ImsServiceClient } from '@adobe/spacecat-shared-ims-client'; +import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; +import { ImsEdgeClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; import { UnauthorizedProductError } from '../../support/errors.js'; +import { + probeSiteAndResolveDomain, + parseEdgeRoutingConfig, + callCdnRoutingApi, + CdnApiError, + EDGE_OPTIMIZE_CDN_STRATEGIES, + SUPPORTED_EDGE_ROUTING_CDN_TYPES, + OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE, + EDGE_OPTIMIZE_MARKING_DELAY_SECONDS, +} from '../../support/edge-routing-utils.js'; import { triggerBrandProfileAgent } from '../../support/brand-profile-trigger.js'; import { applyFilters, @@ -42,11 +52,7 @@ import { applyGroups, applyMappings, LLMO_SHEETDATA_SOURCE_URL, - EDGE_OPTIMIZE_CDN_STRATEGIES, - EDGE_OPTIMIZE_CDN_TYPES, LLMO_ADMIN_GROUP_NAME, - OPTIMIZE_AT_EDGE_ENABLED_MARKING_TYPE, - EDGE_OPTIMIZE_MARKING_DELAY_SECONDS, } from './llmo-utils.js'; import { LLMO_SHEET_MAPPINGS } from './llmo-mappings.js'; import { @@ -1100,26 +1106,12 @@ function LlmoController(ctx) { const getDemoBrandPresence = createDemoFixtureHandler(handleDemoBrandPresence, 'brand-presence'); const getDemoRecommendations = createDemoFixtureHandler(handleDemoRecommendations, 'recommendations'); - // 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-config * 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 */ @@ -1158,16 +1150,6 @@ function LlmoController(ctx) { return badRequest('enabled field must be a boolean'); } - // Validate and normalise cdnType when provided - let cdnTypeNormalized = null; - if (hasText(cdnType)) { - const cdnTypeTrimmed = cdnType.toLowerCase().trim(); - cdnTypeNormalized = EDGE_OPTIMIZE_CDN_TYPES.includes(cdnTypeTrimmed) ? cdnTypeTrimmed : null; - if (!cdnTypeNormalized) { - return badRequest(`cdnType must be one of: ${EDGE_OPTIMIZE_CDN_TYPES.join(', ')}`); - } - } - try { // Get site const site = await Site.findById(siteId); @@ -1184,35 +1166,6 @@ function LlmoController(ctx) { return forbidden('User does not own this site'); } - // Authorization: LLMO Administrator (paid) OR member of LLMO Admin IMS group (trial) - const isPaidAdmin = accessControlUtil.isLLMOAdministrator(); - if (!isPaidAdmin) { - const org = await site.getOrganization(); - const imsOrgId = org.getImsOrgId(); - const userEmail = profile?.email; - let isTrialAdmin = false; - if (hasText(userEmail) && hasText(imsOrgId)) { - try { - const groups = await context.imsClient.getOrgGroups(imsOrgId); - const llmoAdminGroup = groups.find( - (g) => g.groupName === LLMO_ADMIN_GROUP_NAME, - ); - if (llmoAdminGroup) { - isTrialAdmin = await context.imsClient.isUserInImsGroup( - imsOrgId, - String(llmoAdminGroup.ident), - userEmail, - ); - } - } catch (groupCheckError) { - log.warn(`[edge-optimize-config] IMS group check failed for site ${siteId}: ${groupCheckError.message}`); - } - } - if (!isTrialAdmin) { - return forbidden('Only LLMO administrators or LLMO Admin group members can update the edge optimize config'); - } - } - const baseURL = site.getBaseURL(); const tokowakaClient = TokowakaClient.createFrom(context); @@ -1282,122 +1235,107 @@ 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-config] cdnType: ${cdnType} not eligible for automated routing`); + } + } + // CDN routing — only when cdnType is provided if (cdnTypeNormalized) { + // Authorization: LLMO Administrator (paid) OR member of LLMO Admin IMS group (trial). + const org = await site.getOrganization(); + const imsOrgId = org.getImsOrgId(); + const isPaidAdmin = accessControlUtil.isLLMOAdministrator(); + if (!isPaidAdmin) { + const userEmail = profile?.email; + let isTrialAdmin = false; + if (hasText(userEmail) && hasText(imsOrgId)) { + try { + const groups = await context.imsClient.getOrgGroups(imsOrgId); + const llmoAdminGroup = groups.find( + (g) => g.groupName === LLMO_ADMIN_GROUP_NAME, + ); + if (llmoAdminGroup) { + isTrialAdmin = await context.imsClient.isUserInImsGroup( + imsOrgId, + String(llmoAdminGroup.ident), + userEmail, + ); + } + } catch (groupCheckError) { + log.warn(`[edge-optimize-config] IMS group check failed for site ${siteId}: ${groupCheckError.message}`); + } + } + if (!isTrialAdmin) { + return forbidden('Only LLMO administrators or LLMO Admin group members can update the edge optimize config'); + } + } + // Restrict to production environment if (env?.ENV && env.ENV !== 'prod') { return createResponse({ message: `CDN routing is not available in ${env.ENV} environment` }, 400); } - let routingConfig; + let cdnConfig; try { - routingConfig = JSON.parse(env?.EDGE_OPTIMIZE_ROUTING_CONFIG); + cdnConfig = parseEdgeRoutingConfig(env?.EDGE_OPTIMIZE_ROUTING_CONFIG, cdnTypeNormalized); } 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}`); + if (parseError instanceof SyntaxError) { + log.error(`EDGE_OPTIMIZE_ROUTING_CONFIG invalid JSON: ${parseError.message}`); + return internalServerError('Failed to parse routing config.'); + } + log.error(parseError.message); return createResponse({ message: 'API is missing mandatory environment variable' }, 503); } const strategy = EDGE_OPTIMIZE_CDN_STRATEGIES[cdnTypeNormalized]; const routingEnabled = enabled ?? true; - // Obtain the customer IMS org ID for the SP token request - const org = await site.getOrganization(); - const imsOrgId = org.getImsOrgId(); - if (!hasText(imsOrgId)) { - log.error(`[edge-optimize-config] IMS org ID not found for site ${siteId}`); - return internalServerError('IMS org ID not found for site'); - } - // 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}`; - let probeResponse; + log.info(`[edge-optimize-config] Probing site ${probeUrl}`); + let domain; try { - log.info(`[edge-optimize-config] Probing site ${probeUrl}`); - probeResponse = await fetch(probeUrl, { - method: 'GET', - headers: { 'User-Agent': 'AdobeEdgeOptimize-Test AdobeEdgeOptimize/1.0' }, - signal: AbortSignal.timeout(5000), - }); + domain = await probeSiteAndResolveDomain(probeUrl, log); } catch (probeError) { - log.error(`[edge-optimize-config] 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(`[edge-optimize-config] 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(`[edge-optimize-config] CDN routing update failed: ${msg}`); - return badRequest(msg); - } - domain = calculateForwardedHost(locationValue, log); - log.info(`[edge-optimize-config] Probe returned 301; using Location domain ${domain}`); - } else { - const msg = `Site ${probeUrl} did not return 2xx or 301 for` - + ` User-Agent AdobeEdgeOptimize-Test (got ${probeResponse.status})`; - log.error(`[edge-optimize-config] CDN routing update failed: ${msg}`); - return badRequest(msg); + log.error(`[edge-optimize-config] CDN routing update failed for site ${siteId}: ${probeError.message}`); + return badRequest(probeError.message); } // Obtain the Service Principal access token scoped to the customer IMS org let spToken; try { log.debug(`[edge-optimize-config] Obtaining SP token for site ${siteId}, org ${imsOrgId}`); - const edgeImsClient = ImsServiceClient.createFrom(context); + const edgeImsClient = ImsEdgeClient.createFrom(context); const tokenData = await edgeImsClient.getServicePrincipalToken(imsOrgId); spToken = tokenData.access_token; log.info(`[edge-optimize-config] SP token obtained for site ${siteId}`); } catch (tokenError) { log.warn(`[edge-optimize-config] Failed to obtain SP token for site ${siteId}: ${tokenError.message}`); - return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); + return createResponse({ message: 'Authentication failed' }, 401); } // Call CDN API with the SP token try { - const cdnUrl = strategy.buildUrl(cdnConfig, domain); - const cdnBody = strategy.buildBody(routingEnabled); - log.info(`[edge-optimize-config] Calling CDN API for domain ${domain} 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(5000), - }); - - if (!cdnResponse.ok) { - const body = await cdnResponse.text(); - log.error(`[edge-optimize-config] 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); + await callCdnRoutingApi(strategy, cdnConfig, domain, spToken, routingEnabled, log); + } catch (cdnError) { + log.error(`[edge-optimize-config] CDN routing update failed for site ${siteId}: ${cdnError.message}`); + if (cdnError instanceof CdnApiError) { + return createResponse({ message: cdnError.message }, cdnError.status); } + return internalServerError('Failed to update CDN routing'); + } - log.info(`[edge-optimize-config] CDN routing updated for site ${siteId}, domain ${domain}`); + log.info(`[edge-optimize-config] 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 { @@ -1411,17 +1349,15 @@ function LlmoController(ctx) { } catch (sqsError) { log.warn(`[edge-optimize-config] Failed to queue edge-optimize enabled marking for site ${siteId}: ${sqsError.message}`); } - return ok({ - ...metaconfig, - domain, - cdnType: cdnTypeNormalized, + } else { + // Routing disabled — record the disabled state immediately in site config. + const updatedEdgeConfig = currentConfig.getEdgeOptimizeConfig() || {}; + currentConfig.updateEdgeOptimizeConfig({ + ...updatedEdgeConfig, + enabled: false, }); - } catch (cdnError) { - log.error(`[edge-optimize-config] CDN routing update failed for site ${siteId}: ${cdnError.message}`); - if (cdnError.status) { - return createResponse({ message: cdnError.message }, cdnError.status); - } - return internalServerError(cdnError.message); + await saveSiteConfig(site, currentConfig, log, 'marking edge optimize disabled'); + log.info(`[edge-optimize-config] Marked edge optimize as disabled for site ${siteId}`); } } diff --git a/src/support/edge-routing-utils.js b/src/support/edge-routing-utils.js new file mode 100644 index 000000000..24a03f73b --- /dev/null +++ b/src/support/edge-routing-utils.js @@ -0,0 +1,201 @@ +/* + * 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 { isObject, isValidUrl, tracingFetch as fetch } from '@adobe/spacecat-shared-utils'; +import { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; + +// Supported CDN / log source types. Aligned with auth-service (cdn-logs-infrastructure/common.js). +export const LOG_SOURCES = { + BYOCDN_FASTLY: 'byocdn-fastly', + BYOCDN_AKAMAI: 'byocdn-akamai', + BYOCDN_CLOUDFRONT: 'byocdn-cloudfront', + BYOCDN_CLOUDFLARE: 'byocdn-cloudflare', + BYOCDN_IMPERVA: 'byocdn-imperva', + BYOCDN_OTHER: 'byocdn-other', + AMS_CLOUDFRONT: 'ams-cloudfront', + AMS_FRONTDOOR: 'ams-frontdoor', + AEM_CS_FASTLY: 'aem-cs-fastly', + 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 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; +} + +/** + * Error thrown when the CDN routing API returns a non-successful response. + * Carries an HTTP `status` code so callers can map it to the appropriate HTTP response. + */ +export class CdnApiError extends Error { + constructor(message, status) { + super(message); + this.name = 'CdnApiError'; + this.status = status; + } +} + +/** + * 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 {CdnApiError} On a non-2xx CDN API response. + * @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}`); + if (cdnResponse.status === 401 || cdnResponse.status === 403) { + throw new CdnApiError('User is not authorized to update CDN routing', cdnResponse.status); + } + throw new CdnApiError(`Upstream call failed with status ${cdnResponse.status}`, cdnResponse.status); + } +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 0cf668e5c..5ef30dceb 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -17,7 +17,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 { LOG_SOURCES, CdnApiError } from '../../../src/support/edge-routing-utils.js'; import { UnauthorizedProductError } from '../../../src/support/errors.js'; use(sinonChai); @@ -87,6 +87,8 @@ describe('LlmoController', () => { let getServicePrincipalTokenStub; let isUserInImsGroupStub; let getOrgGroupsStub; + let probeSiteAndResolveDomainStub; + let callCdnRoutingApiStub; const mockHttpUtils = { ok: (data, headers = {}) => ({ @@ -199,7 +201,7 @@ describe('LlmoController', () => { fetchWithTimeout: (...args) => fetchWithTimeoutStub(...args), }, '@adobe/spacecat-shared-ims-client': { - ImsServiceClient: { + ImsEdgeClient: { createFrom: () => ({ getServicePrincipalToken: (...args) => getServicePrincipalTokenStub(...args), }), @@ -253,6 +255,31 @@ 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), + 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; + }, + CdnApiError, + 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 @@ -527,6 +554,8 @@ describe('LlmoController', () => { getServicePrincipalTokenStub = sinon.stub().resolves({ access_token: 'sp-access-token' }); isUserInImsGroupStub = sinon.stub().resolves(false); getOrgGroupsStub = sinon.stub().resolves([{ groupName: 'LLMO Admin', ident: 99999 }]); + probeSiteAndResolveDomainStub = sinon.stub().resolves('www.example.com'); + callCdnRoutingApiStub = sinon.stub().resolves(); tracingFetchStub = sinon.stub(); fetchWithTimeoutStub = sinon.stub(); readConfigStub = sinon.stub(); @@ -4666,6 +4695,9 @@ describe('LlmoController', () => { 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-data-access/src/models/site/config.js': { + Config: { toDynamoItem: sinon.stub().returnsArg(0) }, + }, '@adobe/spacecat-shared-tokowaka-client': { default: { createFrom: () => mockTokowakaClient, @@ -4691,7 +4723,10 @@ describe('LlmoController', () => { }); const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); - const result = await controllerNoAdmin.createOrUpdateEdgeConfig(edgeConfigContext); + const result = await controllerNoAdmin.createOrUpdateEdgeConfig({ + ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, + }); expect(result.status).to.equal(403); const responseBody = await result.json(); @@ -4998,6 +5033,7 @@ describe('LlmoController', () => { getOrgGroupsStub.resolves([{ groupName: 'Administrators', ident: 11111 }]); const ctx = { ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), getOrgGroups: (...args) => getOrgGroupsStub(...args), @@ -5006,6 +5042,9 @@ describe('LlmoController', () => { 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-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, @@ -5019,7 +5058,7 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsServiceClient: { + ImsEdgeClient: { createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, @@ -5034,6 +5073,7 @@ describe('LlmoController', () => { isUserInImsGroupStub.resolves(false); const ctx = { ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), getOrgGroups: (...args) => getOrgGroupsStub(...args), @@ -5042,6 +5082,9 @@ describe('LlmoController', () => { 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-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, @@ -5055,7 +5098,7 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsServiceClient: { + ImsEdgeClient: { createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, @@ -5070,6 +5113,7 @@ describe('LlmoController', () => { getOrgGroupsStub.rejects(new Error('IMS API error')); const ctx = { ...edgeConfigContext, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY }, imsClient: { isUserInImsGroup: (...args) => isUserInImsGroupStub(...args), getOrgGroups: (...args) => getOrgGroupsStub(...args), @@ -5078,6 +5122,9 @@ describe('LlmoController', () => { 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-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, @@ -5091,7 +5138,7 @@ describe('LlmoController', () => { }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub().resolves() }, '@adobe/spacecat-shared-ims-client': { - ImsServiceClient: { + ImsEdgeClient: { createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), }, }, @@ -5103,13 +5150,16 @@ describe('LlmoController', () => { // ── cdnType / enabled input validation ───────────────────────────────────── - it('returns 400 when cdnType is not a supported value', async () => { + 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(400); - expect((await result.json()).message).to.include('cdnType must be one of'); + 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 () => { @@ -5131,7 +5181,12 @@ describe('LlmoController', () => { return { ...edgeConfigContext, data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, ...overrides.data }, - env: { ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly, ...overrides.env }, + env: { + ...edgeConfigContext.env, + ENV: 'prod', + EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly, + ...overrides.env, + }, }; } @@ -5166,87 +5221,59 @@ describe('LlmoController', () => { expect(result.status).to.equal(503); }); - it('returns 500 when IMS org ID is missing on the site org', async () => { - const orgNoIms = { - getId: sinon.stub().returns(TEST_ORG_ID), - getImsOrgId: sinon.stub().returns(''), - }; - mockSite.getOrganization = sinon.stub().resolves(orgNoIms); - tracingFetchStub.resolves({ ok: true }); - const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); - expect(result.status).to.equal(500); - expect((await result.json()).message).to.include('IMS org ID not found'); - }); - it('returns 400 when site probe throws', async () => { - tracingFetchStub.rejects(new Error('Connection refused')); + 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('Error probing site'); + expect((await result.json()).message).to.include('Connection refused'); }); it('returns 400 when probe returns non-2xx non-301', async () => { - tracingFetchStub.resolves({ ok: false, status: 404 }); + 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 () => { - tracingFetchStub.resolves({ - ok: false, - status: 301, - headers: { get: (n) => (n === 'location' ? 'https://other-domain.com/' : null) }, - }); + 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 () => { - tracingFetchStub.resolves({ ok: true }); 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', - ); + expect((await result.json()).message).to.equal('Authentication failed'); }); it('returns 403 when CDN API responds 403', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ - ok: false, - status: 403, - text: sinon.stub().resolves('forbidden'), - }); + callCdnRoutingApiStub.rejects(new CdnApiError('User is not authorized to update CDN routing', 403)); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); expect(result.status).to.equal(403); expect((await result.json()).message).to.include('not authorized'); }); - it('returns 500 when CDN API responds with non-401/403 error', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ - ok: false, - status: 503, - text: sinon.stub().resolves('unavailable'), - }); + it('returns upstream status when CDN API responds with non-401/403 error', async () => { + callCdnRoutingApiStub.rejects(new CdnApiError('Upstream call failed with status 503', 503)); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); - expect(result.status).to.equal(500); - expect((await result.json()).message).to.include('Upstream call failed'); + expect(result.status).to.equal(503); + expect((await result.json()).message).to.equal('Upstream call failed with status 503'); }); it('returns 200 with routing data when CDN routing succeeds', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); expect(result.status).to.equal(200); const body = await result.json(); - expect(body).to.include.keys('domain', 'cdnType'); - expect(body).to.not.have.key('enabled'); - expect(body.cdnType).to.equal(LOG_SOURCES.AEM_CS_FASTLY); - expect(mockConfig.updateEdgeOptimizeConfig).to.not.have.been.called; + 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' }, @@ -5256,8 +5283,6 @@ describe('LlmoController', () => { }); it('returns 200 with routing data when CDN routing disabled (enabled=false)', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); const routingData = { cdnType: LOG_SOURCES.AEM_CS_FASTLY, enabled: false }; const result = await controller.createOrUpdateEdgeConfig( makeRoutingCtx({ data: routingData }), @@ -5265,42 +5290,31 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); expect(body).to.not.have.key('enabled'); - expect(mockConfig.updateEdgeOptimizeConfig).to.not.have.been.called; - expect(mockContext.sqs.sendMessage).to.have.been.calledWith( - mockEnv.IMPORT_WORKER_QUEUE_URL, - { type: 'optimize-at-edge-enabled-marking' }, - undefined, - { delaySeconds: 300 }, - ); + // 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('returns 200 with routing on 301 redirect to same root domain', async () => { - mockSite.getBaseURL = sinon.stub().returns('https://example.com'); - tracingFetchStub.onFirstCall().resolves({ - ok: false, - status: 301, - headers: { - get: (n) => (n === 'location' ? 'https://www.example.com/' : null), - }, - }); - tracingFetchStub.onSecondCall().resolves({ ok: true }); + probeSiteAndResolveDomainStub.resolves('www.example.com'); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); expect(result.status).to.equal(200); const body = await result.json(); - expect(body.domain).to.be.a('string'); + expect(body.apiKeys).to.deep.equal(['k']); + expect(callCdnRoutingApiStub).to.have.been.calledOnce; }); - it('uses status from thrown CDN error when available', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - const err = Object.assign(new Error('CDN failed'), { status: 418 }); - tracingFetchStub.onSecondCall().rejects(err); + it('uses status from CdnApiError when CDN call throws', async () => { + callCdnRoutingApiStub.rejects(new CdnApiError('CDN failed', 418)); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); expect(result.status).to.equal(418); }); - it('returns 500 when CDN fetch throws without a status', async () => { - tracingFetchStub.onFirstCall().resolves({ ok: true }); - tracingFetchStub.onSecondCall().rejects(new Error('Network error')); + it('returns 500 when CDN call throws a plain error', async () => { + callCdnRoutingApiStub.rejects(new Error('Network error')); const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); expect(result.status).to.equal(500); }); diff --git a/test/support/edge-routing-utils.test.js b/test/support/edge-routing-utils.test.js new file mode 100644 index 000000000..9261f2fdf --- /dev/null +++ b/test/support/edge-routing-utils.test.js @@ -0,0 +1,253 @@ +/* + * 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'; + +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('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 CdnApiError with status 403 when CDN responds 403', async () => { + fetchStub.resolves({ + ok: false, + status: 403, + text: sandbox.stub().resolves('forbidden'), + }); + + const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) + .catch((e) => e); + expect(err).to.be.instanceOf(edgeUtils.CdnApiError); + expect(err.status).to.equal(403); + expect(err.message).to.include('not authorized'); + }); + + it('throws CdnApiError with status 401 when CDN responds 401', async () => { + fetchStub.resolves({ + ok: false, + status: 401, + text: sandbox.stub().resolves('unauthorized'), + }); + + const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) + .catch((e) => e); + expect(err).to.be.instanceOf(edgeUtils.CdnApiError); + expect(err.status).to.equal(401); + }); + + it('throws CdnApiError with the response status on non-401/403 error', async () => { + fetchStub.resolves({ + ok: false, + status: 503, + text: sandbox.stub().resolves('unavailable'), + }); + + const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) + .catch((e) => e); + expect(err).to.be.instanceOf(edgeUtils.CdnApiError); + expect(err.status).to.equal(503); + expect(err.message).to.include('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 }); + }); + }); +}); From 13207821caca4d00428fcbc0ca121c3e6700d25f Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Fri, 10 Apr 2026 04:13:28 +0530 Subject: [PATCH 5/7] fix: e2e impl --- package-lock.json | 839 +++++++++++++++++++----- package.json | 2 +- src/controllers/llmo/llmo.js | 119 ++-- src/support/edge-routing-auth.js | 162 +++++ src/support/edge-routing-utils.js | 69 +- test/controllers/llmo/llmo.test.js | 404 +++++++++--- test/routes/index.test.js | 11 +- test/support/edge-routing-auth.test.js | 251 +++++++ test/support/edge-routing-utils.test.js | 106 ++- 9 files changed, 1623 insertions(+), 340 deletions(-) create mode 100644 src/support/edge-routing-auth.js create mode 100644 test/support/edge-routing-auth.test.js diff --git a/package-lock.json b/package-lock.json index f69ab8bf8..ac27f600a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@adobe/spacecat-shared-drs-client": "1.4.1", "@adobe/spacecat-shared-gpt-client": "1.6.20", "@adobe/spacecat-shared-http-utils": "1.25.1", - "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", + "@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-rum-api-client": "2.40.10", "@adobe/spacecat-shared-scrape-client": "2.6.0", "@adobe/spacecat-shared-slack-client": "1.6.4", @@ -5742,15 +5742,15 @@ } }, "node_modules/@adobe/spacecat-shared-ims-client": { - "version": "1.12.2", - "resolved": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", - "integrity": "sha512-FaA0ok+tbJEHuo+wF5UcwC753JKQTQmDByntgQTs6iNmJIOI6QQO/gsFxdgG2iPXrouNxMSMxbtK0kAKPTGbrg==", + "version": "1.12.5", + "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.2.3", - "@adobe/helix-universal": "5.4.0", + "@adobe/fetch": "4.3.0", + "@adobe/helix-universal": "5.4.1", "@adobe/spacecat-shared-utils": "1.81.1", - "@aws-sdk/client-secrets-manager": "3.1019.0", + "@aws-sdk/client-secrets-manager": "3.1024.0", "aws-xray-sdk": "3.12.0" }, "engines": { @@ -5758,6 +5758,16 @@ "npm": ">=10.9.0 <12.0.0" } }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@adobe/helix-universal": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.1.tgz", + "integrity": "sha512-wgmjwo0xJkYhFQUmv6GTPvCFjDYBoT7zP3OuAxLN+FlHgS6kDkbJOtKxwQn9SrWbhoIfM8GdCnRDpBn6BmkASw==", + "license": "Apache-2.0", + "dependencies": { + "@adobe/fetch": "4.3.0", + "aws4": "1.13.2" + } + }, "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@adobe/spacecat-shared-utils": { "version": "1.81.1", "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-utils/-/spacecat-shared-utils-1.81.1.tgz", @@ -5848,6 +5858,465 @@ "node": ">=18.0.0" } }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.1024.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1024.0.tgz", + "integrity": "sha512-EXbgMqueA5gw/jqpE2zMWAfBnzn6cZWqCISGdfn1201Um9IAIoTcHjyWoQMALQm0f8Lu1NF6yRtngs6zpZcagQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/core": { + "version": "3.973.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", + "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/xml-builder": "^3.972.17", + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/signature-v4": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", + "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", + "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", + "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/credential-provider-env": "^3.972.25", + "@aws-sdk/credential-provider-http": "^3.972.27", + "@aws-sdk/credential-provider-login": "^3.972.29", + "@aws-sdk/credential-provider-process": "^3.972.25", + "@aws-sdk/credential-provider-sso": "^3.972.29", + "@aws-sdk/credential-provider-web-identity": "^3.972.29", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", + "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", + "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.25", + "@aws-sdk/credential-provider-http": "^3.972.27", + "@aws-sdk/credential-provider-ini": "^3.972.29", + "@aws-sdk/credential-provider-process": "^3.972.25", + "@aws-sdk/credential-provider-sso": "^3.972.29", + "@aws-sdk/credential-provider-web-identity": "^3.972.29", + "@aws-sdk/types": "^3.973.7", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", + "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", + "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/token-providers": "3.1026.0", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", + "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", + "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", + "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", + "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", + "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-retry": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/nested-clients": { + "version": "3.996.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", + "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/middleware-host-header": "^3.972.9", + "@aws-sdk/middleware-logger": "^3.972.9", + "@aws-sdk/middleware-recursion-detection": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/region-config-resolver": "^3.972.11", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@aws-sdk/util-user-agent-browser": "^3.972.9", + "@aws-sdk/util-user-agent-node": "^3.973.15", + "@smithy/config-resolver": "^4.4.14", + "@smithy/core": "^3.23.14", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/hash-node": "^4.2.13", + "@smithy/invalid-dependency": "^4.2.13", + "@smithy/middleware-content-length": "^4.2.13", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-retry": "^4.5.0", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.45", + "@smithy/util-defaults-mode-node": "^4.2.49", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", + "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/config-resolver": "^4.4.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/token-providers": { + "version": "3.1026.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", + "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/types": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", + "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", + "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-endpoints": "^3.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", + "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", + "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/types": "^3.973.7", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", + "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-sqs": { "version": "3.940.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.940.0.tgz", @@ -6328,6 +6797,26 @@ } } }, + "node_modules/@adobe/spacecat-shared-ims-client/node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@adobe/spacecat-shared-ims-client/node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -16023,16 +16512,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.13", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", - "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", + "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -16040,18 +16529,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.12", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", - "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "version": "3.23.14", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", + "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.20", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -16061,15 +16550,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", - "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", + "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -16147,14 +16636,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", + "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -16178,12 +16667,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", - "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", + "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -16207,12 +16696,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", - "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", + "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16246,13 +16735,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", - "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", + "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16260,18 +16749,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.27", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", - "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "version": "4.4.29", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", + "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-serde": "^4.2.15", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", + "@smithy/core": "^3.23.14", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -16279,18 +16768,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.44", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", - "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz", + "integrity": "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -16299,14 +16789,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", - "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "version": "4.2.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", + "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16314,12 +16804,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", + "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16327,14 +16817,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", + "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16342,15 +16832,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", - "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", + "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16358,12 +16847,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", + "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16371,12 +16860,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", + "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16384,12 +16873,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", + "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -16398,12 +16887,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", + "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16411,24 +16900,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", + "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1" + "@smithy/types": "^4.14.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", + "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16436,16 +16925,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", - "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", + "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -16455,17 +16944,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", - "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", + "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.20", + "@smithy/core": "^3.23.14", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" }, "engines": { @@ -16473,9 +16962,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", + "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -16485,13 +16974,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", + "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/querystring-parser": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16562,14 +17051,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.43", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", - "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "version": "4.3.45", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", + "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16577,17 +17066,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.47", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", - "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "version": "4.2.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", + "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.13", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", + "@smithy/config-resolver": "^4.4.14", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16595,13 +17084,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", - "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", + "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16621,12 +17110,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", + "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16634,13 +17123,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.0.tgz", + "integrity": "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -16648,14 +17137,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.20", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", - "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "version": "4.5.22", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", + "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/types": "^4.13.1", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", diff --git a/package.json b/package.json index 96d6d6cbc..af9b097c1 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0", "@adobe/spacecat-shared-gpt-client": "1.6.20", "@adobe/spacecat-shared-http-utils": "1.25.1", - "@adobe/spacecat-shared-ims-client": "https://gist.github.com/dipratap/c893e5f937a62e34bee0a889b0b5287b/raw/168ab6300112b5f4f8f1fe2b1369bc9d35bbd585/adobe-spacecat-shared-ims-client-1.12.2.tgz", + "@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-rum-api-client": "2.40.10", "@adobe/spacecat-shared-scrape-client": "2.6.0", "@adobe/spacecat-shared-slack-client": "1.6.4", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 439550296..44ff58446 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,20 +32,22 @@ import crypto from 'crypto'; import { getDomain } from 'tldts'; import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; -import { ImsEdgeClient } from '@adobe/spacecat-shared-ims-client'; +import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { probeSiteAndResolveDomain, parseEdgeRoutingConfig, callCdnRoutingApi, - CdnApiError, 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, @@ -52,7 +55,6 @@ import { applyGroups, applyMappings, LLMO_SHEETDATA_SOURCE_URL, - LLMO_ADMIN_GROUP_NAME, } from './llmo-utils.js'; import { LLMO_SHEET_MAPPINGS } from './llmo-mappings.js'; import { @@ -1241,43 +1243,58 @@ function LlmoController(ctx) { cdnTypeNormalized = SUPPORTED_EDGE_ROUTING_CDN_TYPES.includes(cdnTypeTrimmed) ? cdnTypeTrimmed : null; if (!cdnTypeNormalized) { - log.info(`[edge-optimize-config] cdnType: ${cdnType} not eligible for automated routing`); + 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.warn(`[edge-optimize-routing-failed] Requested cdnType '${cdnTypeNormalized}' does not match detected CDN '${detectedCdn}' for site ${siteId}`); + return badRequest(`Requested CDN type '${cdnTypeNormalized}' does not match the detected CDN for this domain`); + } + if (!detectedCdn) { + log.info(`[edge-optimize-config] CDN auto-detection returned no result for site ${siteId}, proceeding with requested cdnType`); + } + } 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) { - // Authorization: LLMO Administrator (paid) OR member of LLMO Admin IMS group (trial). + // 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(); - const isPaidAdmin = accessControlUtil.isLLMOAdministrator(); - if (!isPaidAdmin) { - const userEmail = profile?.email; - let isTrialAdmin = false; - if (hasText(userEmail) && hasText(imsOrgId)) { - try { - const groups = await context.imsClient.getOrgGroups(imsOrgId); - const llmoAdminGroup = groups.find( - (g) => g.groupName === LLMO_ADMIN_GROUP_NAME, - ); - if (llmoAdminGroup) { - isTrialAdmin = await context.imsClient.isUserInImsGroup( - imsOrgId, - String(llmoAdminGroup.ident), - userEmail, - ); - } - } catch (groupCheckError) { - log.warn(`[edge-optimize-config] IMS group check failed for site ${siteId}: ${groupCheckError.message}`); - } - } - if (!isTrialAdmin) { - return forbidden('Only LLMO administrators or LLMO Admin group members can update the edge optimize config'); - } + 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); } @@ -1286,10 +1303,10 @@ function LlmoController(ctx) { cdnConfig = parseEdgeRoutingConfig(env?.EDGE_OPTIMIZE_ROUTING_CONFIG, cdnTypeNormalized); } catch (parseError) { if (parseError instanceof SyntaxError) { - log.error(`EDGE_OPTIMIZE_ROUTING_CONFIG invalid JSON: ${parseError.message}`); + log.error(`[edge-optimize-routing-failed] EDGE_OPTIMIZE_ROUTING_CONFIG invalid JSON: ${parseError.message}`); return internalServerError('Failed to parse routing config.'); } - log.error(parseError.message); + log.error(`[edge-optimize-routing-failed] ${parseError.message}`); return createResponse({ message: 'API is missing mandatory environment variable' }, 503); } @@ -1300,40 +1317,41 @@ function LlmoController(ctx) { const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL; const effectiveBaseUrl = isValidUrl(overrideBaseURL) ? overrideBaseURL : baseURL; const probeUrl = effectiveBaseUrl.startsWith('http') ? effectiveBaseUrl : `https://${effectiveBaseUrl}`; - log.info(`[edge-optimize-config] Probing site ${probeUrl}`); + log.info(`[edge-optimize-routing] Probing site ${probeUrl}`); let domain; try { domain = await probeSiteAndResolveDomain(probeUrl, log); } catch (probeError) { - log.error(`[edge-optimize-config] CDN routing update failed for site ${siteId}: ${probeError.message}`); + log.error(`[edge-optimize-routing-failed] CDN routing update failed for site ${siteId}: ${probeError.message}`); return badRequest(probeError.message); } - // Obtain the Service Principal access token scoped to the customer IMS org + // Obtain org-scoped SP token for the CDN API call let spToken; try { - log.debug(`[edge-optimize-config] Obtaining SP token for site ${siteId}, org ${imsOrgId}`); - const edgeImsClient = ImsEdgeClient.createFrom(context); - const tokenData = await edgeImsClient.getServicePrincipalToken(imsOrgId); - spToken = tokenData.access_token; - log.info(`[edge-optimize-config] SP token obtained for site ${siteId}`); + 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-config] Failed to obtain SP token for site ${siteId}: ${tokenError.message}`); - return createResponse({ message: 'Authentication failed' }, 401); + 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-config] CDN routing update failed for site ${siteId}: ${cdnError.message}`); - if (cdnError instanceof CdnApiError) { - return createResponse({ message: cdnError.message }, cdnError.status); - } + 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-config] CDN routing updated for site ${siteId}, domain ${domain}`); + 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 @@ -1345,9 +1363,10 @@ function LlmoController(ctx) { undefined, { delaySeconds: EDGE_OPTIMIZE_MARKING_DELAY_SECONDS }, ); - log.info(`[edge-optimize-config] Queued edge-optimize enabled marking for site ${siteId} (delay: ${EDGE_OPTIMIZE_MARKING_DELAY_SECONDS}s)`); + 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-config] Failed to queue edge-optimize enabled marking for site ${siteId}: ${sqsError.message}`); + 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. @@ -1357,7 +1376,7 @@ function LlmoController(ctx) { enabled: false, }); await saveSiteConfig(site, currentConfig, log, 'marking edge optimize disabled'); - log.info(`[edge-optimize-config] Marked edge optimize as disabled for site ${siteId}`); + log.info(`[edge-optimize-routing] Marked edge optimize as disabled for site ${siteId}`); } } 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 index 24a03f73b..7affcd039 100644 --- a/src/support/edge-routing-utils.js +++ b/src/support/edge-routing-utils.js @@ -10,6 +10,7 @@ * 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'; @@ -143,18 +144,6 @@ export function parseEdgeRoutingConfig(configJson, cdnTypeNormalized) { return cdnConfig; } -/** - * Error thrown when the CDN routing API returns a non-successful response. - * Carries an HTTP `status` code so callers can map it to the appropriate HTTP response. - */ -export class CdnApiError extends Error { - constructor(message, status) { - super(message); - this.name = 'CdnApiError'; - this.status = status; - } -} - /** * Calls the CDN routing API with the given strategy and SP token. * @@ -165,7 +154,6 @@ export class CdnApiError extends Error { * @param {boolean} routingEnabled - Whether to enable or disable CDN routing. * @param {object} log - Logger. * @returns {Promise} Resolves on success. - * @throws {CdnApiError} On a non-2xx CDN API response. * @throws {Error} On network/timeout failure. */ export async function callCdnRoutingApi( @@ -193,9 +181,56 @@ export async function callCdnRoutingApi( if (!cdnResponse.ok) { const body = await cdnResponse.text(); log.error(`[edge-routing-utils] CDN API failed for domain ${domain}: ${cdnResponse.status} ${body}`); - if (cdnResponse.status === 401 || cdnResponse.status === 403) { - throw new CdnApiError('User is not authorized to update CDN routing', cdnResponse.status); - } - throw new CdnApiError(`Upstream call failed with status ${cdnResponse.status}`, cdnResponse.status); + 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 LOG_SOURCES.AEM_CS_FASTLY; + } + + const ips = await dns.resolve4(host).catch(() => []); + if (ips.some((ip) => AEM_CS_FASTLY_IPS.has(ip))) { + return LOG_SOURCES.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 5ef30dceb..027efc361 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -17,7 +17,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, CdnApiError } from '../../../src/support/edge-routing-utils.js'; +import { LOG_SOURCES } from '../../../src/support/edge-routing-utils.js'; import { UnauthorizedProductError } from '../../../src/support/errors.js'; use(sinonChai); @@ -87,8 +87,14 @@ describe('LlmoController', () => { 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 = {}) => ({ @@ -116,6 +122,10 @@ describe('LlmoController', () => { status: 500, json: async () => ({ message }), }), + unauthorized: (message) => ({ + status: 401, + json: async () => ({ message }), + }), }; // Common mocks needed for all esmock instances @@ -166,6 +176,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': { @@ -200,11 +217,15 @@ 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': { - ImsEdgeClient: { - createFrom: () => ({ - getServicePrincipalToken: (...args) => getServicePrincipalTokenStub(...args), - }), + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); }, }, '../../../src/support/brand-profile-trigger.js': { @@ -258,6 +279,20 @@ describe('LlmoController', () => { '../../../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]; @@ -266,7 +301,6 @@ describe('LlmoController', () => { } return config; }, - CdnApiError, EDGE_OPTIMIZE_CDN_STRATEGIES: { 'aem-cs-fastly': { buildUrl: (cdnConfig, domain) => `${cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, '')}/${domain}/edgeoptimize`, @@ -534,6 +568,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', @@ -548,13 +590,25 @@ describe('LlmoController', () => { 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(); @@ -4315,7 +4369,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 }), @@ -4667,7 +4721,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 }), @@ -4690,47 +4744,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-data-access/src/models/site/config.js': { - Config: { toDynamoItem: sinon.stub().returnsArg(0) }, - }, - '@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({ + 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.include('LLMO Admin group members'); + expect(responseBody.message).to.include('LLMO product access'); }); // Note: Slack notification functionality uses postLlmoAlert() from llmo-onboarding.js @@ -5026,17 +5062,38 @@ describe('LlmoController', () => { expect(calledMessage).to.not.include('cc:'); }); - // ── Trial admin authorization ─────────────────────────────────────────────── + // ── Trial admin authorization (authorizeEdgeCdnRouting FREE_TRIAL + IMS org groups) ── - it('returns 403 when user is not LLMO admin and LLMO Admin IMS group is not found in org', async () => { - // getOrgGroups returns groups but none named 'LLMO Admin' - getOrgGroupsStub.resolves([{ groupName: 'Administrators', ident: 11111 }]); + 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', { @@ -5057,9 +5114,15 @@ describe('LlmoController', () => { 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': { - ImsEdgeClient: { - createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); }, }, }); @@ -5069,14 +5132,36 @@ describe('LlmoController', () => { expect((await result.json()).message).to.include('LLMO Admin group members'); }); - it('returns 403 when user is not LLMO admin and IMS group check returns false', async () => { - isUserInImsGroupStub.resolves(false); + 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', { @@ -5097,9 +5182,15 @@ describe('LlmoController', () => { 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': { - ImsEdgeClient: { - createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); }, }, }); @@ -5109,14 +5200,33 @@ describe('LlmoController', () => { expect((await result.json()).message).to.include('LLMO Admin group members'); }); - it('returns 403 when IMS group check throws (trial admin path)', async () => { - getOrgGroupsStub.rejects(new Error('IMS API error')); + 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', { @@ -5137,9 +5247,15 @@ describe('LlmoController', () => { 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': { - ImsEdgeClient: { - createFrom: () => ({ getServicePrincipalToken: getServicePrincipalTokenStub }), + ImsClient: function MockImsClient() { + this.getServiceAccessTokenOrgScopedV3 = (...args) => ( + getServicePrincipalTokenStub(...args) + ); }, }, }); @@ -5178,14 +5294,33 @@ describe('LlmoController', () => { }); 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, - data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, ...overrides.data }, + ...restOverrides, + data: { cdnType: LOG_SOURCES.AEM_CS_FASTLY, ...overrideData }, env: { ...edgeConfigContext.env, ENV: 'prod', EDGE_OPTIMIZE_ROUTING_CONFIG: routingConfigFastly, - ...overrides.env, + ...overrideEnv, + }, + pathInfo: { + ...(edgeConfigContext.pathInfo || {}), + ...(overridePathInfo || {}), + headers: mergedHeaders, }, }; } @@ -5196,6 +5331,42 @@ describe('LlmoController', () => { 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.warn).to.have.been.calledWith( + sinon.match(/does not match detected CDN/), + ); + }); + + it('logs and proceeds when CDN auto-detection returns no match', async () => { + detectCdnForDomainStub.resolves(null); + const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); + expect(result.status).to.equal(200); + expect(mockLog.info).to.have.been.calledWith( + sinon.match(/CDN auto-detection returned no result/), + ); + }); + + 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 () => { @@ -5246,21 +5417,32 @@ describe('LlmoController', () => { 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'); + expect((await result.json()).message).to.equal('Authentication failed with upstream IMS service'); }); - it('returns 403 when CDN API responds 403', async () => { - callCdnRoutingApiStub.rejects(new CdnApiError('User is not authorized to update CDN routing', 403)); + 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(403); - expect((await result.json()).message).to.include('not authorized'); + expect(result.status).to.equal(500); + expect((await result.json()).message).to.equal('Failed to update CDN routing'); }); - it('returns upstream status when CDN API responds with non-401/403 error', async () => { - callCdnRoutingApiStub.rejects(new CdnApiError('Upstream call failed with status 503', 503)); + 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(503); - expect((await result.json()).message).to.equal('Upstream call failed with status 503'); + 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 () => { @@ -5282,8 +5464,18 @@ describe('LlmoController', () => { ); }); + 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 }), ); @@ -5298,6 +5490,57 @@ describe('LlmoController', () => { 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()); @@ -5307,16 +5550,11 @@ describe('LlmoController', () => { expect(callCdnRoutingApiStub).to.have.been.calledOnce; }); - it('uses status from CdnApiError when CDN call throws', async () => { - callCdnRoutingApiStub.rejects(new CdnApiError('CDN failed', 418)); - const result = await controller.createOrUpdateEdgeConfig(makeRoutingCtx()); - expect(result.status).to.equal(418); - }); - - it('returns 500 when CDN call throws a plain error', async () => { + 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') }); @@ -5547,6 +5785,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: {} }; diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 22d1a63bf..1f45ab48a 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -202,7 +202,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(), @@ -302,7 +302,6 @@ describe('getRouteHandlers', () => { getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, - updateEdgeOptimizeCDNRouting: () => null, getStrategy: () => null, saveStrategy: () => null, getDemoBrandPresence: () => null, @@ -518,7 +517,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', @@ -780,7 +779,6 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', 'GET /sites/:siteId/llmo/edge-optimize-status', - 'POST /sites/:siteId/llmo/edge-optimize-routing', 'PUT /sites/:siteId/llmo/opportunities-reviewed', 'GET /sites/:siteId/llmo/strategy', 'PUT /sites/:siteId/llmo/strategy', @@ -827,7 +825,8 @@ describe('getRouteHandlers', () => { 'GET /organizations/:organizationId/contact-sales-leads', 'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead', 'PATCH /contact-sales-leads/:contactSalesLeadId', - ); + ]; + 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']); @@ -1036,8 +1035,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..407b96ae4 --- /dev/null +++ b/test/support/edge-routing-auth.test.js @@ -0,0 +1,251 @@ +/* + * 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')); + try { + await getImsTokenFromCookie({ pathInfo: { headers: { cookie: 'promiseToken=ptok' } } }); + expect.fail('expected throw'); + } catch (e) { + expect(e.status).to.equal(401); + expect(e.message).to.equal('Authentication failed with upstream IMS service'); + } + }); + + 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 index 9261f2fdf..f56b64571 100644 --- a/test/support/edge-routing-utils.test.js +++ b/test/support/edge-routing-utils.test.js @@ -136,6 +136,17 @@ describe('edge-routing-utils', () => { }); }); + 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[mod.LOG_SOURCES.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({ @@ -193,45 +204,40 @@ describe('edge-routing-utils', () => { expect(opts.headers.Authorization).to.equal('Bearer test-sp-token'); }); - it('throws CdnApiError with status 403 when CDN responds 403', async () => { + it('throws Error mentioning status when CDN responds 403', async () => { fetchStub.resolves({ ok: false, status: 403, text: sandbox.stub().resolves('forbidden'), }); - const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) - .catch((e) => e); - expect(err).to.be.instanceOf(edgeUtils.CdnApiError); - expect(err.status).to.equal(403); - expect(err.message).to.include('not authorized'); + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /403/); }); - it('throws CdnApiError with status 401 when CDN responds 401', async () => { + it('throws Error mentioning status when CDN responds 401', async () => { fetchStub.resolves({ ok: false, status: 401, text: sandbox.stub().resolves('unauthorized'), }); - const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) - .catch((e) => e); - expect(err).to.be.instanceOf(edgeUtils.CdnApiError); - expect(err.status).to.equal(401); + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /401/); }); - it('throws CdnApiError with the response status on non-401/403 error', async () => { + it('throws Error mentioning status on other non-OK CDN responses', async () => { fetchStub.resolves({ ok: false, status: 503, text: sandbox.stub().resolves('unavailable'), }); - const err = await edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log) - .catch((e) => e); - expect(err).to.be.instanceOf(edgeUtils.CdnApiError); - expect(err.status).to.equal(503); - expect(err.message).to.include('503'); + await expect( + edgeUtils.callCdnRoutingApi(strategy, cdnConfig, domain, spToken, true, log), + ).to.be.rejectedWith(Error, /503/); }); it('propagates network errors from fetch', async () => { @@ -250,4 +256,70 @@ describe('edge-routing-utils', () => { 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(edgeUtilsDns.LOG_SOURCES.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(edgeUtilsDns.LOG_SOURCES.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); + }); + }); }); From abc196f55302a62ffa6495cb4001b935b4012db0 Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Fri, 10 Apr 2026 04:16:29 +0530 Subject: [PATCH 6/7] fix: e2e impl --- src/controllers/llmo/llmo-utils.js | 14 ++++++++++++++ src/support/edge-routing-utils.js | 21 ++++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/controllers/llmo/llmo-utils.js b/src/controllers/llmo/llmo-utils.js index 5183b4095..b72c66d22 100644 --- a/src/controllers/llmo/llmo-utils.js +++ b/src/controllers/llmo/llmo-utils.js @@ -13,6 +13,20 @@ // LLMO constants export const LLMO_SHEETDATA_SOURCE_URL = 'https://main--project-elmo-ui-data--adobe.aem.live'; +// 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', + BYOCDN_CLOUDFLARE: 'byocdn-cloudflare', + BYOCDN_IMPERVA: 'byocdn-imperva', + BYOCDN_OTHER: 'byocdn-other', + AMS_CLOUDFRONT: 'ams-cloudfront', + AMS_FRONTDOOR: 'ams-frontdoor', + AEM_CS_FASTLY: 'aem-cs-fastly', + COMMERCE_FASTLY: 'commerce-fastly', +}; + export const LLMO_ADMIN_GROUP_NAME = 'LLMO Admin'; // Apply filters to data arrays with case-insensitive exact matching diff --git a/src/support/edge-routing-utils.js b/src/support/edge-routing-utils.js index 7affcd039..f4bd294ea 100644 --- a/src/support/edge-routing-utils.js +++ b/src/support/edge-routing-utils.js @@ -13,24 +13,11 @@ 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'; - -// Supported CDN / log source types. Aligned with auth-service (cdn-logs-infrastructure/common.js). -export const LOG_SOURCES = { - BYOCDN_FASTLY: 'byocdn-fastly', - BYOCDN_AKAMAI: 'byocdn-akamai', - BYOCDN_CLOUDFRONT: 'byocdn-cloudfront', - BYOCDN_CLOUDFLARE: 'byocdn-cloudflare', - BYOCDN_IMPERVA: 'byocdn-imperva', - BYOCDN_OTHER: 'byocdn-other', - AMS_CLOUDFRONT: 'ams-cloudfront', - AMS_FRONTDOOR: 'ams-frontdoor', - AEM_CS_FASTLY: 'aem-cs-fastly', - COMMERCE_FASTLY: 'commerce-fastly', -}; +import { CDN_TYPES } from '../controllers/llmo/llmo-utils.js'; // Per-CDN strategies for edge optimize routing. export const EDGE_OPTIMIZE_CDN_STRATEGIES = { - [LOG_SOURCES.AEM_CS_FASTLY]: { + [CDN_TYPES.AEM_CS_FASTLY]: { buildUrl: (cdnConfig, domain) => { const base = cdnConfig.cdnRoutingUrl.trim().replace(/\/+$/, ''); return `${base}/${domain}/edgeoptimize`; @@ -201,12 +188,12 @@ const AEM_CS_FASTLY_IPS = new Set([ 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 LOG_SOURCES.AEM_CS_FASTLY; + return CDN_TYPES.AEM_CS_FASTLY; } const ips = await dns.resolve4(host).catch(() => []); if (ips.some((ip) => AEM_CS_FASTLY_IPS.has(ip))) { - return LOG_SOURCES.AEM_CS_FASTLY; + return CDN_TYPES.AEM_CS_FASTLY; } return null; From 7745a52e95c0b25a5d6221688fd44cab15360eb9 Mon Sep 17 00:00:00 2001 From: Divyansh Pratap Date: Fri, 10 Apr 2026 04:48:33 +0530 Subject: [PATCH 7/7] fix: tests --- src/controllers/llmo/llmo-utils.js | 2 - src/controllers/llmo/llmo.js | 7 +- test/controllers/llmo/llmo.test.js | 86 ++++++++++++++++++++++--- test/support/edge-routing-auth.test.js | 10 ++- test/support/edge-routing-utils.test.js | 7 +- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/controllers/llmo/llmo-utils.js b/src/controllers/llmo/llmo-utils.js index 1afbb9ca8..187b80c7d 100644 --- a/src/controllers/llmo/llmo-utils.js +++ b/src/controllers/llmo/llmo-utils.js @@ -27,8 +27,6 @@ export const CDN_TYPES = { COMMERCE_FASTLY: 'commerce-fastly', }; -export const LLMO_ADMIN_GROUP_NAME = 'LLMO Admin'; - // 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 a285fd8fc..6fcc68635 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -1303,13 +1303,10 @@ function LlmoController(ctx) { try { const hostname = getHostnameWithoutWww(baseURL, log); const detectedCdn = await detectCdnForDomain(hostname); - if (detectedCdn && detectedCdn !== cdnTypeNormalized) { - log.warn(`[edge-optimize-routing-failed] Requested cdnType '${cdnTypeNormalized}' does not match detected CDN '${detectedCdn}' for site ${siteId}`); + 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`); } - if (!detectedCdn) { - log.info(`[edge-optimize-config] CDN auto-detection returned no result for site ${siteId}, proceeding with requested cdnType`); - } } catch (detectError) { log.info(`[edge-optimize-config] CDN auto-detection failed for site ${siteId}: ${detectError.message}`); } diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 8e7a0803e..4adaf4007 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -4332,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(); @@ -4735,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); @@ -5096,7 +5160,7 @@ describe('LlmoController', () => { }, }; const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { - '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '../../../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) }, @@ -5124,6 +5188,7 @@ describe('LlmoController', () => { ); }, }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), }); const controllerNoAdmin = LlmoControllerNoAdmin(ctx); const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); @@ -5164,7 +5229,7 @@ describe('LlmoController', () => { }, }; const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { - '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '../../../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) }, @@ -5192,6 +5257,7 @@ describe('LlmoController', () => { ); }, }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), }); const controllerNoAdmin = LlmoControllerNoAdmin(ctx); const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); @@ -5229,7 +5295,7 @@ describe('LlmoController', () => { }, }; const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { - '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '../../../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) }, @@ -5257,6 +5323,7 @@ describe('LlmoController', () => { ); }, }, + '../../../src/support/edge-routing-utils.js': trialEdgeRoutingUtilsForCdnAuth(), }); const controllerNoAdmin = LlmoControllerNoAdmin(ctx); const result = await controllerNoAdmin.createOrUpdateEdgeConfig(ctx); @@ -5345,17 +5412,18 @@ describe('LlmoController', () => { 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.warn).to.have.been.calledWith( - sinon.match(/does not match detected CDN/), + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Requested cdnType: 'aem-cs-fastly', detected CDN: 'akamai'/), ); }); - it('logs and proceeds when CDN auto-detection returns no match', async () => { + 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(200); - expect(mockLog.info).to.have.been.calledWith( - sinon.match(/CDN auto-detection returned no result/), + 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'/), ); }); diff --git a/test/support/edge-routing-auth.test.js b/test/support/edge-routing-auth.test.js index 407b96ae4..60e71e110 100644 --- a/test/support/edge-routing-auth.test.js +++ b/test/support/edge-routing-auth.test.js @@ -80,12 +80,20 @@ describe('edge-routing-auth', () => { 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' } } }); + 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), + ); } }); diff --git a/test/support/edge-routing-utils.test.js b/test/support/edge-routing-utils.test.js index f56b64571..1ac668f6f 100644 --- a/test/support/edge-routing-utils.test.js +++ b/test/support/edge-routing-utils.test.js @@ -17,6 +17,7 @@ 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); @@ -139,7 +140,7 @@ describe('edge-routing-utils', () => { 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[mod.LOG_SOURCES.AEM_CS_FASTLY]; + 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 }); @@ -297,7 +298,7 @@ describe('edge-routing-utils', () => { dnsPromises.resolveCname.withArgs('example.com').resolves([]); dnsPromises.resolve4.resolves([]); const result = await edgeUtilsDns.detectCdnForDomain('example.com'); - expect(result).to.equal(edgeUtilsDns.LOG_SOURCES.AEM_CS_FASTLY); + expect(result).to.equal(CDN_TYPES.AEM_CS_FASTLY); }); it('returns aem-cs-fastly when A record matches known Fastly IP', async () => { @@ -306,7 +307,7 @@ describe('edge-routing-utils', () => { host === 'www.example.com' ? ['146.75.123.10'] : [] )); const result = await edgeUtilsDns.detectCdnForDomain('example.com'); - expect(result).to.equal(edgeUtilsDns.LOG_SOURCES.AEM_CS_FASTLY); + expect(result).to.equal(CDN_TYPES.AEM_CS_FASTLY); }); });