diff --git a/package.json b/package.json index 465804e2c..bce7d67d7 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/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 d45a68a1e..4e0b53064 100644 --- a/src/index.js +++ b/src/index.js @@ -97,6 +97,7 @@ import AutofixChecksController from './controllers/autofix-checks.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; @@ -233,6 +234,7 @@ async function run(request, context) { const featureFlagsController = FeatureFlagsController(context); const autofixChecksController = AutofixChecksController(context); const pageRelationshipsController = PageRelationshipsController(context); + const toolsController = ToolsController(context, log, context.env); const routeHandlers = getRouteHandlers( auditsController, @@ -282,6 +284,7 @@ async function run(request, context) { contactSalesLeadsController, featureFlagsController, pageRelationshipsController, + toolsController, ephemeralRunController, autofixChecksController, ); diff --git a/src/routes/index.js b/src/routes/index.js index 83d8c2286..145637682 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. * @param {Object} autofixChecksController - Autofix checks controller for autofix deploy. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. @@ -145,6 +146,7 @@ export default function getRouteHandlers( contactSalesLeadsController, featureFlagsController, pageRelationshipsController, + toolsController, ephemeralRunController, autofixChecksController, ) { @@ -349,6 +351,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 cc7d101e8..f23739563 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 /tools/resolve-config', + // PLG onboarding - IMS token auth, self-service flow, not S2S 'POST /plg/onboard', 'GET /plg/sites', diff --git a/src/support/hlx-config.js b/src/support/hlx-config.js new file mode 100644 index 000000000..733d4ed94 --- /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, adminConfig }; +} 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 3ddcf4c27..2ab850e7b 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -412,6 +412,10 @@ describe('getRouteHandlers', () => { search: sinon.stub(), }; + const mockToolsController = { + resolveConfig: sinon.stub(), + }; + const mockEphemeralRunController = { batchRun: () => null, batchStatus: () => null, @@ -470,6 +474,7 @@ describe('getRouteHandlers', () => { mockContactSalesLeadsController, mockFeatureFlagsController, mockPageRelationshipsController, + mockToolsController, mockEphemeralRunController, mockAutofixChecksController, ); @@ -495,6 +500,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', @@ -527,6 +533,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); 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; + }); + }); +});