From a0b793697fea1fe14958bc00db590d0c9b0caa1c Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Wed, 8 Apr 2026 18:25:29 +0530 Subject: [PATCH 1/4] feat: Adds an API for resolving hlxConfig and code for a github url --- package.json | 2 +- src/controllers/sites.js | 45 ++++++++++ src/routes/index.js | 1 + src/support/hlx-config.js | 177 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/support/hlx-config.js diff --git a/package.json b/package.json index 92c1ce069..92245355d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "hedy -v --test-bundle", "deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest", "deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest", - "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", + "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l sandsinh --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env", "docs": "npm run docs:lint && npm run docs:build", "docs:build": "npx @redocly/cli build-docs -o ./docs/index.html --config docs/openapi/redocly-config.yaml", diff --git a/src/controllers/sites.js b/src/controllers/sites.js index b50226206..15c621b2b 100755 --- a/src/controllers/sites.js +++ b/src/controllers/sites.js @@ -46,6 +46,7 @@ import { } from '../support/utils.js'; import AccessControlUtil from '../support/access-control-util.js'; import { triggerBrandProfileAgent } from '../support/brand-profile-trigger.js'; +import { resolveHlxConfigFromGitHubURL } from '../support/hlx-config.js'; /** * Sites controller. Provides methods to create, read, update and delete sites. @@ -1284,6 +1285,49 @@ function SitesController(ctx, log, env) { } }; + /** + * Resolves hlxConfig and code from a GitHub repository URL by calling + * admin.hlx.page and falling back to fstab.yaml. Read-only — does not + * persist anything. + * @param {object} context - Context of the request. + * @returns {Promise} Resolved hlxConfig and code. + */ + const resolveConfig = async (context) => { + const { siteId } = context.params; + + 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 { gitHubURL } = context.data; + if (!hasText(gitHubURL)) { + return badRequest('gitHubURL is required'); + } + + if (!validateRepoUrl(gitHubURL)) { + return badRequest('Invalid GitHub repository URL'); + } + + const hlxAdminToken = env.HLX_ADMIN_TOKEN; + if (!hasText(hlxAdminToken)) { + log.error('HLX_ADMIN_TOKEN is not configured'); + return internalServerError('HLX admin token not configured'); + } + + try { + const result = await resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log); + return ok(result); + } catch (e) { + log.error(`Error resolving config from ${gitHubURL}: ${e.message}`); + return internalServerError('Failed to resolve config'); + } + }; + return { createSite, getAll, @@ -1300,6 +1344,7 @@ function SitesController(ctx, log, env) { getPageCitabilityCounts, getTopPages, resolveSite, + resolveConfig, getBrandProfile, triggerBrandProfile, diff --git a/src/routes/index.js b/src/routes/index.js index b5a6e1cfd..0af1edfe4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -215,6 +215,7 @@ export default function getRouteHandlers( 'GET /sites.xlsx': sitesController.getAllAsExcel, 'GET /sites/:siteId': sitesController.getByID, 'PATCH /sites/:siteId': sitesController.updateSite, + 'POST /sites/:siteId/resolve-config': sitesController.resolveConfig, 'PATCH /sites/:siteId/config/cdn-logs': sitesController.updateCdnLogsConfig, 'DELETE /sites/:siteId': sitesController.removeSite, 'GET /sites/:siteId/bot-blocker': botBlockerController.checkBotBlocker, diff --git a/src/support/hlx-config.js b/src/support/hlx-config.js new file mode 100644 index 000000000..4d6a2d9c0 --- /dev/null +++ b/src/support/hlx-config.js @@ -0,0 +1,177 @@ +/* + * 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 yaml from 'js-yaml'; + +/** + * Parses a domain string for the HLX RSO pattern: ref--site--owner.(hlx.live|aem.live) + * @param {string} domain - The domain to parse. + * @returns {object|null} - Parsed RSO object or null if no match. + */ +export function parseHlxRSO(domain) { + const regex = /^([\w-]+)--([\w-]+)--([\w-]+)\.(hlx\.live|aem\.live)$/; + const match = domain.match(regex); + + if (!match) { + return null; + } + + return { + ref: match[1], + site: match[2], + owner: match[3], + tld: match[4], + }; +} + +/** + * Fetches the aggregated HLX config from admin.hlx.page for the given owner/site. + * Returns the config object containing cdn, code, and content, or null if not found. + * @param {object} hlxConfig - The hlx config object with rso.owner and rso.site. + * @param {string} hlxAdminToken - The HLX admin API token. + * @param {object} log - The logger object. + * @returns {Promise} - The config from admin API, or null. + */ +export async function fetchHlxConfig(hlxConfig, hlxAdminToken, log) { + const { hlxVersion, rso } = hlxConfig; + + if (hlxVersion < 5) { + log.info(`HLX version is ${hlxVersion}. Skipping fetching hlx config`); + return null; + } + + const { owner, site } = rso; + const url = `https://admin.hlx.page/config/${owner}/aggregated/${site}.json`; + + try { + const response = await fetch(url, { + headers: { Authorization: `token ${hlxAdminToken}` }, + }); + + if (response.status === 200) { + return response.json(); + } + + if (response.status === 404) { + log.debug(`No hlx config found for ${owner}/${site}`); + return null; + } + + log.error(`Error fetching hlx config for ${owner}/${site}. Status: ${response.status}. Error: ${response.headers.get('x-error')}`); + } catch (e) { + log.error(`Error fetching hlx config for ${owner}/${site}`, e); + } + + return null; +} + +/** + * Fetches the content source from fstab.yaml in the GitHub repository. + * @param {object} hlxConfig - The hlx config object with rso.ref, rso.site, rso.owner. + * @param {object} log - The logger object. + * @returns {Promise} - Content source object or null. + */ +export async function getContentSource(hlxConfig, log) { + const { ref, site: repo, owner } = hlxConfig.rso; + + const fstabResponse = await fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/fstab.yaml`); + + if (fstabResponse.status !== 200) { + log.error(`Error fetching fstab.yaml for ${owner}/${repo}. Status: ${fstabResponse.status}`); + return null; + } + + const fstabContent = await fstabResponse.text(); + const parsedContent = yaml.load(fstabContent); + + const url = parsedContent?.mountpoints + ? Object.entries(parsedContent.mountpoints)?.[0]?.[1] + : null; + + if (!isValidUrl(url)) { + log.debug(`No content source found for ${owner}/${repo} in fstab.yaml`); + return null; + } + + const type = url.includes('drive.google') ? 'drive.google' : 'onedrive'; + return { source: { type, url } }; +} + +/** + * Resolves the full HLX config and code object from a GitHub repository URL. + * + * Calls admin.hlx.page to fetch cdn, code, and content config. Falls back to + * reading fstab.yaml from GitHub for content source if the admin API returns nothing. + * + * @param {string} gitHubURL - GitHub repository URL (e.g. https://github.com/owner/repo). + * @param {string} hlxAdminToken - The HLX admin API token. + * @param {object} log - The logger object. + * @returns {Promise<{hlxConfig: object, code: object}>} - Resolved hlxConfig and code. + */ +export async function resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log) { + const parsedUrl = new URL(gitHubURL); + const pathParts = parsedUrl.pathname.split('/').filter(Boolean); + const owner = pathParts[0]; + const repo = pathParts[1]; + + const hlxConfig = { + hlxVersion: 5, + rso: { owner, site: repo, ref: 'main' }, + }; + + // Fetch full config from admin.hlx.page + const adminConfig = await fetchHlxConfig(hlxConfig, hlxAdminToken, log); + if (isObject(adminConfig)) { + const { cdn, code, content } = adminConfig; + if (isObject(cdn)) { + hlxConfig.cdn = cdn; + // If CDN prod host is available, derive full rso with tld + if (cdn.prod?.host) { + const rso = parseHlxRSO(cdn.prod.host); + if (rso) { + hlxConfig.rso = rso; + } + } + } + if (isObject(code)) { + hlxConfig.code = code; + } + if (isObject(content)) { + hlxConfig.content = content; + } + } else { + // Fallback: read fstab.yaml for content source + try { + const content = await getContentSource(hlxConfig, log); + if (isObject(content)) { + hlxConfig.content = content; + } + } catch (e) { + log.error(`Error fetching fstab.yaml for ${owner}/${repo}: ${e.message}`); + } + } + + const code = { + type: 'github', + owner, + repo, + ref: 'main', + url: gitHubURL, + }; + + return { hlxConfig, code }; +} From bf73e902c34f9062c5d9333c1e6a46041fc563ee Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 9 Apr 2026 03:12:51 +0530 Subject: [PATCH 2/4] fix: adds tests --- package-lock.json | 24 +++ src/routes/required-capabilities.js | 3 + test/controllers/sites.test.js | 159 +++++++++++++++++ test/routes/index.test.js | 1 + test/support/hlx-config.test.js | 256 ++++++++++++++++++++++++++++ 5 files changed, 443 insertions(+) create mode 100644 test/support/hlx-config.test.js diff --git a/package-lock.json b/package-lock.json index a77182951..b589a24e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -699,6 +699,7 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.0.tgz", "integrity": "sha512-3ZfFdjYtpv7RCgul9yyOBsRVsxLNapwt0YjASBhyzJGNjnPxrWDlqDtbpBdwAgA1Nuh9nmjzFDFu8CJWv6BMKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -10201,6 +10202,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -14325,6 +14327,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -14556,6 +14559,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -14762,6 +14766,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -14925,6 +14930,7 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -16918,6 +16924,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -17233,6 +17240,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -17280,6 +17288,7 @@ "integrity": "sha512-F+LMD2IDIXuHxgpLJh3nkLj9+tSaEzoUWd+7fONGq5pe2169FUDjpEkOfEpoGLz1sbZni/69p07OsecNfAOpqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17755,6 +17764,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -18417,6 +18427,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -20629,6 +20640,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -24893,6 +24905,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -25947,6 +25960,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -28528,6 +28542,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -29064,6 +29079,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -30309,6 +30325,7 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -30319,6 +30336,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -31088,6 +31106,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -32495,6 +32514,7 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -33592,6 +33612,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -34344,6 +34365,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -34608,6 +34630,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -34617,6 +34640,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 4e94f3653..508b890aa 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -76,6 +76,9 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize-routing', 'PUT /sites/:siteId/llmo/opportunities-reviewed', + // Resolve config - UI-only, resolves hlxConfig/code from GitHub URL + 'POST /sites/:siteId/resolve-config', + // PLG onboarding - IMS token auth, self-service flow, not S2S 'POST /plg/onboard', 'GET /plg/sites', diff --git a/test/controllers/sites.test.js b/test/controllers/sites.test.js index 54ea30223..5ef3f8122 100644 --- a/test/controllers/sites.test.js +++ b/test/controllers/sites.test.js @@ -139,6 +139,7 @@ describe('Sites Controller', () => { 'getSiteMetricsBySource', 'getPageMetricsBySource', 'resolveSite', + 'resolveConfig', 'triggerBrandProfile', 'getGraph', ]; @@ -5037,4 +5038,162 @@ describe('Sites Controller', () => { expect(error.message).to.equal('Restricted Operation'); }); }); + + describe('resolveConfig', () => { + it('should return 404 when site not found', async () => { + mockDataAccess.Site.findById.resolves(null); + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(404); + }); + + it('should return 400 when gitHubURL is missing', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = {}; + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(400); + const body = await response.json(); + expect(body.message).to.equal('gitHubURL is required'); + }); + + it('should return 400 when gitHubURL is invalid', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://not-a-github-url.com/foo' }; + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(400); + const body = await response.json(); + expect(body.message).to.equal('Invalid GitHub repository URL'); + }); + + it('should return 500 when HLX_ADMIN_TOKEN is not configured', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = undefined; + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(500); + }); + + it('should return resolved hlxConfig and code on success', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = 'test-token'; + + // Mock the admin API call + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(200, { + cdn: { prod: { host: 'main--test-repo--adobe.aem.live' } }, + code: { owner: 'adobe', repo: 'test-repo', source: 'https://github.com/adobe/test-repo' }, + content: { source: { type: 'onedrive', url: 'https://adobe.sharepoint.com/sites/test' } }, + }); + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig).to.be.an('object'); + expect(body.hlxConfig.rso).to.deep.include({ owner: 'adobe', site: 'test-repo' }); + expect(body.hlxConfig.cdn).to.be.an('object'); + expect(body.hlxConfig.code).to.be.an('object'); + expect(body.code).to.deep.include({ + type: 'github', + owner: 'adobe', + repo: 'test-repo', + ref: 'main', + url: 'https://github.com/adobe/test-repo', + }); + }); + + it('should fall back to fstab.yaml when admin API returns 404', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = 'test-token'; + + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: https://adobe.sharepoint.com/sites/test'); + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig.content.source.type).to.equal('onedrive'); + expect(body.hlxConfig.content.source.url).to.equal('https://adobe.sharepoint.com/sites/test'); + expect(body.code.owner).to.equal('adobe'); + expect(body.code.repo).to.equal('test-repo'); + }); + + it('should return basic hlxConfig when both admin API and fstab fail', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = 'test-token'; + + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(404); + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); + expect(body.hlxConfig.content).to.be.undefined; + expect(body.code.type).to.equal('github'); + }); + + it('should return 403 when user does not have access to the site', async () => { + sandbox.stub(AccessControlUtil.prototype, 'hasAccess').returns(false); + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + const response = await sitesController.resolveConfig(context); + expect(response.status).to.equal(403); + }); + + it('should return 500 when resolveHlxConfigFromGitHubURL throws', async () => { + context.params = { siteId: SITE_IDS[0] }; + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = 'test-token'; + + // Force a network error on the admin API call + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .replyWithError('ECONNREFUSED'); + + // Also force fstab to throw + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .replyWithError('ECONNREFUSED'); + + // The function itself catches fstab errors, so we need to make the URL parsing fail + // Use esmock to override resolveHlxConfigFromGitHubURL to throw + const SitesControllerMocked = (await import('esmock')).default( + '../../src/controllers/sites.js', + { + '../../src/support/hlx-config.js': { + resolveHlxConfigFromGitHubURL: sinon.stub().rejects(new Error('Unexpected failure')), + }, + }, + ); + + const mockedController = (await SitesControllerMocked)(context, loggerStub, context.env); + const response = await mockedController.resolveConfig(context); + expect(response.status).to.equal(500); + const body = await response.json(); + expect(body.message).to.equal('Failed to resolve config'); + }); + }); }); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index e451b667b..470f9a35c 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -734,6 +734,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/brand-guidelines', 'GET /sites/:siteId/brand-profile', 'POST /sites/:siteId/brand-profile', + 'POST /sites/:siteId/resolve-config', 'GET /sites/:siteId/page-citability/counts', 'GET /sites/:siteId/top-pages', 'GET /sites/:siteId/top-pages/:source', diff --git a/test/support/hlx-config.test.js b/test/support/hlx-config.test.js new file mode 100644 index 000000000..fa586f3aa --- /dev/null +++ b/test/support/hlx-config.test.js @@ -0,0 +1,256 @@ +/* + * 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 sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import nock from 'nock'; +import { + parseHlxRSO, + fetchHlxConfig, + getContentSource, + resolveHlxConfigFromGitHubURL, +} from '../../src/support/hlx-config.js'; + +use(sinonChai); + +describe('hlx-config', () => { + const log = { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + debug: sinon.stub(), + }; + + afterEach(() => { + nock.cleanAll(); + sinon.resetHistory(); + }); + + describe('parseHlxRSO', () => { + it('should parse a valid aem.live domain', () => { + const result = parseHlxRSO('main--bufferin--lion-corporation.aem.live'); + expect(result).to.deep.equal({ + ref: 'main', site: 'bufferin', owner: 'lion-corporation', tld: 'aem.live', + }); + }); + + it('should parse a valid hlx.live domain', () => { + const result = parseHlxRSO('main--site--owner.hlx.live'); + expect(result).to.deep.equal({ + ref: 'main', site: 'site', owner: 'owner', tld: 'hlx.live', + }); + }); + + it('should return null for non-matching domain', () => { + expect(parseHlxRSO('www.example.com')).to.be.null; + }); + + it('should return null for partial match', () => { + expect(parseHlxRSO('main--site.aem.live')).to.be.null; + }); + }); + + describe('fetchHlxConfig', () => { + it('should return config from admin API on 200', async () => { + nock('https://admin.hlx.page') + .get('/config/owner/aggregated/site.json') + .reply(200, { cdn: { prod: { host: 'test.aem.live' } } }); + + const hlxConfig = { hlxVersion: 5, rso: { owner: 'owner', site: 'site' } }; + const result = await fetchHlxConfig(hlxConfig, 'token', log); + expect(result).to.deep.equal({ cdn: { prod: { host: 'test.aem.live' } } }); + }); + + it('should return null on 404', async () => { + nock('https://admin.hlx.page') + .get('/config/owner/aggregated/site.json') + .reply(404); + + const hlxConfig = { hlxVersion: 5, rso: { owner: 'owner', site: 'site' } }; + const result = await fetchHlxConfig(hlxConfig, 'token', log); + expect(result).to.be.null; + }); + + it('should return null and log error on non-200/non-404 status', async () => { + nock('https://admin.hlx.page') + .get('/config/owner/aggregated/site.json') + .reply(500, 'Internal Server Error', { 'x-error': 'something broke' }); + + const hlxConfig = { hlxVersion: 5, rso: { owner: 'owner', site: 'site' } }; + const result = await fetchHlxConfig(hlxConfig, 'token', log); + expect(result).to.be.null; + expect(log.error).to.have.been.called; + }); + + it('should return null and log error on network failure', async () => { + nock('https://admin.hlx.page') + .get('/config/owner/aggregated/site.json') + .replyWithError('connection refused'); + + const hlxConfig = { hlxVersion: 5, rso: { owner: 'owner', site: 'site' } }; + const result = await fetchHlxConfig(hlxConfig, 'token', log); + expect(result).to.be.null; + expect(log.error).to.have.been.called; + }); + + it('should skip for hlxVersion < 5', async () => { + const hlxConfig = { hlxVersion: 4, rso: { owner: 'owner', site: 'site' } }; + const result = await fetchHlxConfig(hlxConfig, 'token', log); + expect(result).to.be.null; + }); + }); + + describe('getContentSource', () => { + it('should parse onedrive content source from fstab.yaml', async () => { + nock('https://raw.githubusercontent.com') + .get('/owner/repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: https://adobe.sharepoint.com/sites/test'); + + const hlxConfig = { rso: { ref: 'main', site: 'repo', owner: 'owner' } }; + const result = await getContentSource(hlxConfig, log); + expect(result).to.deep.equal({ + source: { type: 'onedrive', url: 'https://adobe.sharepoint.com/sites/test' }, + }); + }); + + it('should parse google drive content source from fstab.yaml', async () => { + nock('https://raw.githubusercontent.com') + .get('/owner/repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: https://drive.google.com/drive/folders/abc'); + + const hlxConfig = { rso: { ref: 'main', site: 'repo', owner: 'owner' } }; + const result = await getContentSource(hlxConfig, log); + expect(result.source.type).to.equal('drive.google'); + }); + + it('should return null when mountpoint URL is invalid', async () => { + nock('https://raw.githubusercontent.com') + .get('/owner/repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: not-a-valid-url'); + + const hlxConfig = { rso: { ref: 'main', site: 'repo', owner: 'owner' } }; + const result = await getContentSource(hlxConfig, log); + expect(result).to.be.null; + expect(log.debug).to.have.been.called; + }); + + it('should return null when fstab.yaml has no mountpoints', async () => { + nock('https://raw.githubusercontent.com') + .get('/owner/repo/main/fstab.yaml') + .reply(200, 'folders:\n /: /content/dam'); + + const hlxConfig = { rso: { ref: 'main', site: 'repo', owner: 'owner' } }; + const result = await getContentSource(hlxConfig, log); + expect(result).to.be.null; + }); + + it('should return null when fstab.yaml not found', async () => { + nock('https://raw.githubusercontent.com') + .get('/owner/repo/main/fstab.yaml') + .reply(404); + + const hlxConfig = { rso: { ref: 'main', site: 'repo', owner: 'owner' } }; + const result = await getContentSource(hlxConfig, log); + expect(result).to.be.null; + }); + }); + + describe('resolveHlxConfigFromGitHubURL', () => { + it('should resolve full config when admin API returns data', async () => { + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(200, { + cdn: { prod: { host: 'main--test-repo--adobe.aem.live' } }, + code: { owner: 'adobe', repo: 'test-repo' }, + content: { source: { type: 'onedrive', url: 'https://sharepoint.com/test' } }, + }); + + const result = await resolveHlxConfigFromGitHubURL( + 'https://github.com/adobe/test-repo', + 'token', + log, + ); + + expect(result.hlxConfig.rso).to.deep.equal({ + ref: 'main', site: 'test-repo', owner: 'adobe', tld: 'aem.live', + }); + expect(result.hlxConfig.cdn).to.be.an('object'); + expect(result.hlxConfig.code).to.be.an('object'); + expect(result.code).to.deep.equal({ + type: 'github', owner: 'adobe', repo: 'test-repo', ref: 'main', url: 'https://github.com/adobe/test-repo', + }); + }); + + it('should fall back to fstab when admin API returns 404', async () => { + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: https://adobe.sharepoint.com/sites/test'); + + const result = await resolveHlxConfigFromGitHubURL( + 'https://github.com/adobe/test-repo', + 'token', + log, + ); + + expect(result.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); + expect(result.hlxConfig.content.source.type).to.equal('onedrive'); + }); + + it('should return basic config when both APIs fail', async () => { + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(404); + + const result = await resolveHlxConfigFromGitHubURL( + 'https://github.com/adobe/test-repo', + 'token', + log, + ); + + expect(result.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); + expect(result.hlxConfig.content).to.be.undefined; + expect(result.code.type).to.equal('github'); + }); + + it('should handle fstab.yaml fetch throwing an error', async () => { + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .replyWithError('network timeout'); + + const result = await resolveHlxConfigFromGitHubURL( + 'https://github.com/adobe/test-repo', + 'token', + log, + ); + + expect(result.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); + expect(result.hlxConfig.content).to.be.undefined; + expect(result.code.type).to.equal('github'); + expect(log.error).to.have.been.called; + }); + }); +}); From 5a3fb7f8781e5ebf680055004f75c71c4914a6c6 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 9 Apr 2026 03:51:50 +0530 Subject: [PATCH 3/4] fix: generic API --- src/controllers/sites.js | 45 ------- src/controllers/tools.js | 65 +++++++++++ src/index.js | 3 + src/routes/index.js | 4 +- src/routes/required-capabilities.js | 2 +- test/controllers/sites.test.js | 159 ------------------------- test/controllers/tools.test.js | 174 ++++++++++++++++++++++++++++ test/routes/index.test.js | 8 +- 8 files changed, 253 insertions(+), 207 deletions(-) create mode 100644 src/controllers/tools.js create mode 100644 test/controllers/tools.test.js diff --git a/src/controllers/sites.js b/src/controllers/sites.js index 15c621b2b..b50226206 100755 --- a/src/controllers/sites.js +++ b/src/controllers/sites.js @@ -46,7 +46,6 @@ import { } from '../support/utils.js'; import AccessControlUtil from '../support/access-control-util.js'; import { triggerBrandProfileAgent } from '../support/brand-profile-trigger.js'; -import { resolveHlxConfigFromGitHubURL } from '../support/hlx-config.js'; /** * Sites controller. Provides methods to create, read, update and delete sites. @@ -1285,49 +1284,6 @@ function SitesController(ctx, log, env) { } }; - /** - * Resolves hlxConfig and code from a GitHub repository URL by calling - * admin.hlx.page and falling back to fstab.yaml. Read-only — does not - * persist anything. - * @param {object} context - Context of the request. - * @returns {Promise} Resolved hlxConfig and code. - */ - const resolveConfig = async (context) => { - const { siteId } = context.params; - - 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 { gitHubURL } = context.data; - if (!hasText(gitHubURL)) { - return badRequest('gitHubURL is required'); - } - - if (!validateRepoUrl(gitHubURL)) { - return badRequest('Invalid GitHub repository URL'); - } - - const hlxAdminToken = env.HLX_ADMIN_TOKEN; - if (!hasText(hlxAdminToken)) { - log.error('HLX_ADMIN_TOKEN is not configured'); - return internalServerError('HLX admin token not configured'); - } - - try { - const result = await resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log); - return ok(result); - } catch (e) { - log.error(`Error resolving config from ${gitHubURL}: ${e.message}`); - return internalServerError('Failed to resolve config'); - } - }; - return { createSite, getAll, @@ -1344,7 +1300,6 @@ function SitesController(ctx, log, env) { getPageCitabilityCounts, getTopPages, resolveSite, - resolveConfig, getBrandProfile, triggerBrandProfile, diff --git a/src/controllers/tools.js b/src/controllers/tools.js new file mode 100644 index 000000000..34db13850 --- /dev/null +++ b/src/controllers/tools.js @@ -0,0 +1,65 @@ +/* + * 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 { + badRequest, + internalServerError, + ok, +} from '@adobe/spacecat-shared-http-utils'; +import { hasText } from '@adobe/spacecat-shared-utils'; +import { validateRepoUrl } from '../utils/validations.js'; +import { resolveHlxConfigFromGitHubURL } from '../support/hlx-config.js'; + +/** + * Tools Controller. Provides generic utility endpoints not tied to a specific site. + * @param {object} ctx - Context of the request. + * @param {object} log - Logger instance. + * @param {object} env - Environment variables. + * @returns {object} Tools controller. + */ +function ToolsController(ctx, log, env) { + /** + * Resolves hlxConfig and code attributes from a GitHub URL by querying the + * admin.hlx.page API and falling back to fstab.yaml. Read-only — does not + * persist anything. + * @param {object} context - Context of the request. + * @returns {Promise} Resolved hlxConfig and code. + */ + const resolveConfig = async (context) => { + const { gitHubURL } = context.data; + if (!hasText(gitHubURL)) { + return badRequest('gitHubURL is required'); + } + + if (!validateRepoUrl(gitHubURL)) { + return badRequest('Invalid GitHub repository URL'); + } + + const hlxAdminToken = env.HLX_ADMIN_TOKEN; + if (!hasText(hlxAdminToken)) { + log.error('HLX_ADMIN_TOKEN is not configured'); + return internalServerError('HLX admin token not configured'); + } + + try { + const result = await resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, log); + return ok(result); + } catch (e) { + log.error(`Error resolving config from ${gitHubURL}: ${e.message}`); + return internalServerError('Failed to resolve config'); + } + }; + + return { resolveConfig }; +} + +export default ToolsController; diff --git a/src/index.js b/src/index.js index d4579566c..f328e1a09 100644 --- a/src/index.js +++ b/src/index.js @@ -96,6 +96,7 @@ import FeatureFlagsController from './controllers/feature-flags.js'; import routeRequiredCapabilities from './routes/required-capabilities.js'; import ContactSalesLeadsController from './controllers/contact-sales-leads.js'; import PageRelationshipsController from './controllers/page-relationships.js'; +import ToolsController from './controllers/tools.js'; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -231,6 +232,7 @@ async function run(request, context) { const contactSalesLeadsController = ContactSalesLeadsController(context); const featureFlagsController = FeatureFlagsController(context); const pageRelationshipsController = PageRelationshipsController(context); + const toolsController = ToolsController(context, log, context.env); const routeHandlers = getRouteHandlers( auditsController, @@ -280,6 +282,7 @@ async function run(request, context) { contactSalesLeadsController, featureFlagsController, pageRelationshipsController, + toolsController, ephemeralRunController, ); diff --git a/src/routes/index.js b/src/routes/index.js index 0af1edfe4..595e06274 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -93,6 +93,7 @@ function isStaticRoute(routePattern) { * @param {Object} contactSalesLeadsController - The contact sales leads controller. * @param {Object} featureFlagsController - Organization feature flags (mysticat) controller. * @param {Object} pageRelationshipsController - The page relationships controller. + * @param {Object} toolsController - The tools controller. * @param {Object} ephemeralRunController - The ephemeral run batch controller. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ @@ -144,6 +145,7 @@ export default function getRouteHandlers( contactSalesLeadsController, featureFlagsController, pageRelationshipsController, + toolsController, ephemeralRunController, ) { const staticRoutes = {}; @@ -215,7 +217,6 @@ export default function getRouteHandlers( 'GET /sites.xlsx': sitesController.getAllAsExcel, 'GET /sites/:siteId': sitesController.getByID, 'PATCH /sites/:siteId': sitesController.updateSite, - 'POST /sites/:siteId/resolve-config': sitesController.resolveConfig, 'PATCH /sites/:siteId/config/cdn-logs': sitesController.updateCdnLogsConfig, 'DELETE /sites/:siteId': sitesController.removeSite, 'GET /sites/:siteId/bot-blocker': botBlockerController.checkBotBlocker, @@ -348,6 +349,7 @@ export default function getRouteHandlers( 'POST /slack/events': slackController.handleEvent, 'POST /slack/channels/invite-by-user-id': slackController.inviteUserToChannel, 'GET /trigger': triggerHandler, + 'POST /tools/resolve-config': toolsController.resolveConfig, 'POST /tools/api-keys': apiKeyController.createApiKey, 'DELETE /tools/api-keys/:id': apiKeyController.deleteApiKey, 'GET /tools/api-keys': apiKeyController.getApiKeys, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 508b890aa..cbd006395 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -77,7 +77,7 @@ export const INTERNAL_ROUTES = [ 'PUT /sites/:siteId/llmo/opportunities-reviewed', // Resolve config - UI-only, resolves hlxConfig/code from GitHub URL - 'POST /sites/:siteId/resolve-config', + 'POST /tools/resolve-config', // PLG onboarding - IMS token auth, self-service flow, not S2S 'POST /plg/onboard', diff --git a/test/controllers/sites.test.js b/test/controllers/sites.test.js index 5ef3f8122..54ea30223 100644 --- a/test/controllers/sites.test.js +++ b/test/controllers/sites.test.js @@ -139,7 +139,6 @@ describe('Sites Controller', () => { 'getSiteMetricsBySource', 'getPageMetricsBySource', 'resolveSite', - 'resolveConfig', 'triggerBrandProfile', 'getGraph', ]; @@ -5038,162 +5037,4 @@ describe('Sites Controller', () => { expect(error.message).to.equal('Restricted Operation'); }); }); - - describe('resolveConfig', () => { - it('should return 404 when site not found', async () => { - mockDataAccess.Site.findById.resolves(null); - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(404); - }); - - it('should return 400 when gitHubURL is missing', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = {}; - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(400); - const body = await response.json(); - expect(body.message).to.equal('gitHubURL is required'); - }); - - it('should return 400 when gitHubURL is invalid', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://not-a-github-url.com/foo' }; - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(400); - const body = await response.json(); - expect(body.message).to.equal('Invalid GitHub repository URL'); - }); - - it('should return 500 when HLX_ADMIN_TOKEN is not configured', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - context.env.HLX_ADMIN_TOKEN = undefined; - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(500); - }); - - it('should return resolved hlxConfig and code on success', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - context.env.HLX_ADMIN_TOKEN = 'test-token'; - - // Mock the admin API call - nock('https://admin.hlx.page') - .get('/config/adobe/aggregated/test-repo.json') - .reply(200, { - cdn: { prod: { host: 'main--test-repo--adobe.aem.live' } }, - code: { owner: 'adobe', repo: 'test-repo', source: 'https://github.com/adobe/test-repo' }, - content: { source: { type: 'onedrive', url: 'https://adobe.sharepoint.com/sites/test' } }, - }); - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(200); - - const body = await response.json(); - expect(body.hlxConfig).to.be.an('object'); - expect(body.hlxConfig.rso).to.deep.include({ owner: 'adobe', site: 'test-repo' }); - expect(body.hlxConfig.cdn).to.be.an('object'); - expect(body.hlxConfig.code).to.be.an('object'); - expect(body.code).to.deep.include({ - type: 'github', - owner: 'adobe', - repo: 'test-repo', - ref: 'main', - url: 'https://github.com/adobe/test-repo', - }); - }); - - it('should fall back to fstab.yaml when admin API returns 404', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - context.env.HLX_ADMIN_TOKEN = 'test-token'; - - nock('https://admin.hlx.page') - .get('/config/adobe/aggregated/test-repo.json') - .reply(404); - - nock('https://raw.githubusercontent.com') - .get('/adobe/test-repo/main/fstab.yaml') - .reply(200, 'mountpoints:\n /: https://adobe.sharepoint.com/sites/test'); - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(200); - - const body = await response.json(); - expect(body.hlxConfig.content.source.type).to.equal('onedrive'); - expect(body.hlxConfig.content.source.url).to.equal('https://adobe.sharepoint.com/sites/test'); - expect(body.code.owner).to.equal('adobe'); - expect(body.code.repo).to.equal('test-repo'); - }); - - it('should return basic hlxConfig when both admin API and fstab fail', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - context.env.HLX_ADMIN_TOKEN = 'test-token'; - - nock('https://admin.hlx.page') - .get('/config/adobe/aggregated/test-repo.json') - .reply(404); - - nock('https://raw.githubusercontent.com') - .get('/adobe/test-repo/main/fstab.yaml') - .reply(404); - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(200); - - const body = await response.json(); - expect(body.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); - expect(body.hlxConfig.content).to.be.undefined; - expect(body.code.type).to.equal('github'); - }); - - it('should return 403 when user does not have access to the site', async () => { - sandbox.stub(AccessControlUtil.prototype, 'hasAccess').returns(false); - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - - const response = await sitesController.resolveConfig(context); - expect(response.status).to.equal(403); - }); - - it('should return 500 when resolveHlxConfigFromGitHubURL throws', async () => { - context.params = { siteId: SITE_IDS[0] }; - context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; - context.env.HLX_ADMIN_TOKEN = 'test-token'; - - // Force a network error on the admin API call - nock('https://admin.hlx.page') - .get('/config/adobe/aggregated/test-repo.json') - .replyWithError('ECONNREFUSED'); - - // Also force fstab to throw - nock('https://raw.githubusercontent.com') - .get('/adobe/test-repo/main/fstab.yaml') - .replyWithError('ECONNREFUSED'); - - // The function itself catches fstab errors, so we need to make the URL parsing fail - // Use esmock to override resolveHlxConfigFromGitHubURL to throw - const SitesControllerMocked = (await import('esmock')).default( - '../../src/controllers/sites.js', - { - '../../src/support/hlx-config.js': { - resolveHlxConfigFromGitHubURL: sinon.stub().rejects(new Error('Unexpected failure')), - }, - }, - ); - - const mockedController = (await SitesControllerMocked)(context, loggerStub, context.env); - const response = await mockedController.resolveConfig(context); - expect(response.status).to.equal(500); - const body = await response.json(); - expect(body.message).to.equal('Failed to resolve config'); - }); - }); }); diff --git a/test/controllers/tools.test.js b/test/controllers/tools.test.js new file mode 100644 index 000000000..82ffefbea --- /dev/null +++ b/test/controllers/tools.test.js @@ -0,0 +1,174 @@ +/* + * 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 sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import nock from 'nock'; + +import ToolsController from '../../src/controllers/tools.js'; + +use(sinonChai); + +describe('Tools Controller', () => { + let sandbox; + let toolsController; + let context; + let loggerStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + loggerStub = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + + context = { + dataAccess: {}, + data: {}, + params: {}, + env: { HLX_ADMIN_TOKEN: 'test-token' }, + }; + + toolsController = ToolsController(context, loggerStub, context.env); + }); + + afterEach(() => { + nock.cleanAll(); + sandbox.restore(); + }); + + it('contains all controller functions', () => { + expect(toolsController).to.have.property('resolveConfig'); + }); + + describe('resolveConfig', () => { + it('should return 400 when gitHubURL is missing', async () => { + context.data = {}; + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(400); + const body = await response.json(); + expect(body.message).to.equal('gitHubURL is required'); + }); + + it('should return 400 when gitHubURL is invalid', async () => { + context.data = { gitHubURL: 'https://not-a-github-url.com/foo' }; + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(400); + const body = await response.json(); + expect(body.message).to.equal('Invalid GitHub repository URL'); + }); + + it('should return 500 when HLX_ADMIN_TOKEN is not configured', async () => { + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + context.env.HLX_ADMIN_TOKEN = undefined; + toolsController = ToolsController(context, loggerStub, context.env); + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(500); + }); + + it('should return resolved hlxConfig and code on success', async () => { + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(200, { + cdn: { prod: { host: 'main--test-repo--adobe.aem.live' } }, + code: { owner: 'adobe', repo: 'test-repo', source: 'https://github.com/adobe/test-repo' }, + content: { source: { type: 'onedrive', url: 'https://adobe.sharepoint.com/sites/test' } }, + }); + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig).to.be.an('object'); + expect(body.hlxConfig.rso).to.deep.include({ owner: 'adobe', site: 'test-repo' }); + expect(body.hlxConfig.cdn).to.be.an('object'); + expect(body.hlxConfig.code).to.be.an('object'); + expect(body.code).to.deep.include({ + type: 'github', + owner: 'adobe', + repo: 'test-repo', + ref: 'main', + url: 'https://github.com/adobe/test-repo', + }); + }); + + it('should fall back to fstab.yaml when admin API returns 404', async () => { + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(200, 'mountpoints:\n /: https://adobe.sharepoint.com/sites/test'); + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig.content.source.type).to.equal('onedrive'); + expect(body.hlxConfig.content.source.url).to.equal('https://adobe.sharepoint.com/sites/test'); + expect(body.code.owner).to.equal('adobe'); + expect(body.code.repo).to.equal('test-repo'); + }); + + it('should return basic hlxConfig when both admin API and fstab fail', async () => { + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + nock('https://admin.hlx.page') + .get('/config/adobe/aggregated/test-repo.json') + .reply(404); + + nock('https://raw.githubusercontent.com') + .get('/adobe/test-repo/main/fstab.yaml') + .reply(404); + + const response = await toolsController.resolveConfig(context); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.hlxConfig.rso).to.deep.equal({ owner: 'adobe', site: 'test-repo', ref: 'main' }); + expect(body.hlxConfig.content).to.be.undefined; + expect(body.code.type).to.equal('github'); + }); + + it('should return 500 when resolveHlxConfigFromGitHubURL throws', async () => { + context.data = { gitHubURL: 'https://github.com/adobe/test-repo' }; + + const ToolsControllerMocked = (await import('esmock')).default( + '../../src/controllers/tools.js', + { + '../../src/support/hlx-config.js': { + resolveHlxConfigFromGitHubURL: sinon.stub().rejects(new Error('Unexpected failure')), + }, + }, + ); + + const mockedController = (await ToolsControllerMocked)(context, loggerStub, context.env); + const response = await mockedController.resolveConfig(context); + expect(response.status).to.equal(500); + const body = await response.json(); + expect(body.message).to.equal('Failed to resolve config'); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 470f9a35c..b1816cfe7 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -413,6 +413,10 @@ describe('getRouteHandlers', () => { search: sinon.stub(), }; + const mockToolsController = { + resolveConfig: sinon.stub(), + }; + const mockEphemeralRunController = { batchRun: () => null, batchStatus: () => null, @@ -467,6 +471,7 @@ describe('getRouteHandlers', () => { mockContactSalesLeadsController, mockFeatureFlagsController, mockPageRelationshipsController, + mockToolsController, mockEphemeralRunController, ); @@ -491,6 +496,7 @@ describe('getRouteHandlers', () => { 'GET /trigger', 'POST /event/fulfillment', 'POST /slack/channels/invite-by-user-id', + 'POST /tools/resolve-config', 'POST /tools/api-keys', 'GET /tools/api-keys', 'POST /tools/import/jobs', @@ -522,6 +528,7 @@ describe('getRouteHandlers', () => { expect(staticRoutes['GET /sites.csv']).to.equal(mockSitesController.getAllAsCsv); expect(staticRoutes['GET /sites.xlsx']).to.equal(mockSitesController.getAllAsExcel); expect(staticRoutes['GET /trigger']).to.equal(mockTrigger); + expect(staticRoutes['POST /tools/resolve-config']).to.equal(mockToolsController.resolveConfig); expect(staticRoutes['POST /tools/api-keys']).to.equal(mockApiKeyController.createApiKey); expect(staticRoutes['GET /tools/api-keys']).to.equal(mockApiKeyController.getApiKeys); expect(staticRoutes['POST /consent-banner']).to.equal(mockConsentBannerController.takeScreenshots); @@ -734,7 +741,6 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/brand-guidelines', 'GET /sites/:siteId/brand-profile', 'POST /sites/:siteId/brand-profile', - 'POST /sites/:siteId/resolve-config', 'GET /sites/:siteId/page-citability/counts', 'GET /sites/:siteId/top-pages', 'GET /sites/:siteId/top-pages/:source', From c0413a719716b2ba1e2b87b37abff8ef51018252 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 9 Apr 2026 04:10:59 +0530 Subject: [PATCH 4/4] fix: test --- src/support/hlx-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/support/hlx-config.js b/src/support/hlx-config.js index 4d6a2d9c0..733d4ed94 100644 --- a/src/support/hlx-config.js +++ b/src/support/hlx-config.js @@ -173,5 +173,5 @@ export async function resolveHlxConfigFromGitHubURL(gitHubURL, hlxAdminToken, lo url: gitHubURL, }; - return { hlxConfig, code }; + return { hlxConfig, code, adminConfig }; }