diff --git a/src/controllers/autofix-checks.js b/src/controllers/autofix-checks.js new file mode 100644 index 000000000..6a92ea7c7 --- /dev/null +++ b/src/controllers/autofix-checks.js @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isNonEmptyObject, isNonEmptyArray } from '@adobe/spacecat-shared-utils'; +import { + badRequest, forbidden, internalServerError, notFound, ok, +} from '@adobe/spacecat-shared-http-utils'; +import AccessControlUtil from '../support/access-control-util.js'; +import checkHandlerRegistry from '../support/autofix-checks/registry.js'; + +/** + * Autofix Checks Controller — runs server-side permission and capability + * checks for a site before autofix deploy. + * + * POST /sites/:siteId/autofix-checks + * + * Request body: + * { "checks": [{ "type": "content-api-access" }] } + * + * Response: + * { "siteId": "...", "checks": [{ "type", "status", "message" }] } + * + * @param {Object} ctx - Application context (dataAccess, log, etc.) + * @returns {Object} Controller with runChecks method + */ +function AutofixChecksController(ctx) { + if (!isNonEmptyObject(ctx?.dataAccess)) { + throw new Error('Valid data access configuration required'); + } + + const { dataAccess, log } = ctx; + const { Site } = dataAccess; + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + /** + * Runs the requested autofix checks for a site. + * @param {Object} context - Request context with params.siteId and data.checks + * @returns {Promise} HTTP response + */ + const runChecks = async (context) => { + const { siteId } = context.params || {}; + const { checks } = context.data || {}; + + if (!isNonEmptyArray(checks)) { + return badRequest('Request body must include a non-empty "checks" array'); + } + + // Validate all requested check types before doing any work + const unknownTypes = checks + .map((c) => c?.type) + .filter((type) => !checkHandlerRegistry[type]); + + if (unknownTypes.length > 0) { + return badRequest(`Unknown check type(s): ${unknownTypes.join(', ')}`); + } + + try { + const site = await Site.findById(siteId); + + if (!site) { + return notFound(`Site with ID ${siteId} not found`); + } + + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('User does not have access to this site'); + } + + const results = await Promise.all( + checks.map(async (check) => { + const handler = checkHandlerRegistry[check.type]; + try { + return await handler(site, context, log); + } catch (error) { + log.error(`Autofix check "${check.type}" threw unexpectedly: ${error.message}`); + return { + type: check.type, + status: 'ERROR', + message: error.message, + }; + } + }), + ); + + return ok({ siteId, checks: results }); + } catch (error) { + log.error(`Autofix checks failed for site ${siteId}: ${error.message}`); + return internalServerError('Failed to run preflight checks'); + } + }; + + return { runChecks }; +} + +export default AutofixChecksController; diff --git a/src/index.js b/src/index.js index e7df700f3..d45a68a1e 100644 --- a/src/index.js +++ b/src/index.js @@ -93,6 +93,7 @@ import ConsumersController from './controllers/consumers.js'; import TokensController from './controllers/tokens.js'; import ImsOrgAccessController from './controllers/ims-org-access.js'; import FeatureFlagsController from './controllers/feature-flags.js'; +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'; @@ -230,6 +231,7 @@ async function run(request, context) { const imsOrgAccessController = ImsOrgAccessController(context); const contactSalesLeadsController = ContactSalesLeadsController(context); const featureFlagsController = FeatureFlagsController(context); + const autofixChecksController = AutofixChecksController(context); const pageRelationshipsController = PageRelationshipsController(context); const routeHandlers = getRouteHandlers( @@ -281,6 +283,7 @@ async function run(request, context) { featureFlagsController, pageRelationshipsController, ephemeralRunController, + autofixChecksController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/routes/index.js b/src/routes/index.js index e71bb329a..45522f06f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -94,6 +94,7 @@ function isStaticRoute(routePattern) { * @param {Object} featureFlagsController - Organization feature flags (mysticat) controller. * @param {Object} pageRelationshipsController - The page relationships 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. */ export default function getRouteHandlers( @@ -145,6 +146,7 @@ export default function getRouteHandlers( featureFlagsController, pageRelationshipsController, ephemeralRunController, + autofixChecksController, ) { const staticRoutes = {}; const dynamicRoutes = {}; @@ -546,6 +548,9 @@ export default function getRouteHandlers( 'GET /organizations/:organizationId/contact-sales-leads': contactSalesLeadsController.getByOrganizationId, 'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead': contactSalesLeadsController.checkBySite, 'PATCH /contact-sales-leads/:contactSalesLeadId': contactSalesLeadsController.update, + + // Autofix checks (permission/capability validation before autofix deploy) + 'POST /sites/:siteId/autofix-checks': autofixChecksController.runChecks, }; // Initialization of static and dynamic routes diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index c057b21ad..6f2459f92 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -113,6 +113,9 @@ export const INTERNAL_ROUTES = [ 'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead', 'PATCH /contact-sales-leads/:contactSalesLeadId', + // Preflight checks - proxies user's Bearer token to AEM Author; end-user UI only + 'POST /sites/:siteId/autofix-checks', + // Consumer management - admin-only, requires is_s2s_admin; not for general S2S consumers 'GET /consumers', 'GET /consumers/by-client-id/:clientId', diff --git a/src/support/autofix-checks/handlers/code-repo-access.js b/src/support/autofix-checks/handlers/code-repo-access.js new file mode 100644 index 000000000..4b30a89fc --- /dev/null +++ b/src/support/autofix-checks/handlers/code-repo-access.js @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const CHECK_TYPE = 'code-repo-access'; + +const CODE_TYPE = { + STANDARD: 'standard', + GITHUB: 'github', +}; + +const isNumericString = (value) => !!value && /^\d+$/.test(value); + +/** + * Checks whether a site's code repository supports PR creation for code patches. + * + * Mirrors the client-side `getCodePatchRestriction` logic in the UI: + * - CM Standard (type="standard") → FAILED (PRs not supported) + * - CM BYOG GitHub (type="github" with + * numeric owner+repo) → FAILED (PR creation coming soon) + * - AEMY / GitLab / Bitbucket / Azure → PASSED + * - No code config → SKIPPED (not a code-apply site) + * + * @param {Object} site - Site entity + * @returns {{type: string, status: string, message: string}} + */ +export default function codeRepoAccessHandler(site) { + const code = site.getCode(); + + if (!code?.type) { + return { + type: CHECK_TYPE, + status: 'SKIPPED', + message: 'No code configuration — site does not use code patches', + }; + } + + if (code.type === CODE_TYPE.STANDARD) { + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Pull Request creation is not supported on Cloud Manager standard repositories. Please migrate to Cloud Manager BYOG.', + details: code.url, + }; + } + + if (code.type === CODE_TYPE.GITHUB && isNumericString(code.owner) && isNumericString(code.repo)) { + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Pull Request creation for Cloud Manager GitHub repositories is coming soon.', + }; + } + + return { + type: CHECK_TYPE, + status: 'PASSED', + message: 'Code repository supports PR creation', + }; +} diff --git a/src/support/autofix-checks/handlers/content-api-access.js b/src/support/autofix-checks/handlers/content-api-access.js new file mode 100644 index 000000000..bfca80af1 --- /dev/null +++ b/src/support/autofix-checks/handlers/content-api-access.js @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { tracingFetch as fetch } from '@adobe/spacecat-shared-utils'; +import { Site } from '@adobe/spacecat-shared-data-access'; + +const CHECK_TYPE = 'content-api-access'; + +/** + * Two-step Content API probe paths (per terinmez / Content MCP Server approach): + * 1. Experimental ASPM path — works for modern AEM CS instances + * 2. Stable path — works for AEM CS instances where experimental path is not available + * + * If both return 404 → Content API is not deployed on this instance. + * A 2xx on either path confirms the API is reachable and the caller has access. + */ +const PROBE_PATHS = [ + '/adobe/experimental/aspm-expires-20251231/pages?limit=1', + '/adobe/pages?limit=1', +]; + +/** + * Probes a single URL and returns the HTTP status (or null on network error). + * + * @param {string} url + * @param {string} authorization + * @returns {Promise} + */ +async function probeUrl(url, authorization) { + try { + const response = await fetch(url, { + method: 'GET', + headers: { Authorization: authorization }, + }); + return response.status; + } catch { + return null; + } +} + +/** + * Probes the AEM Author Content API to verify it is reachable and the caller + * has sufficient permissions. Only supported for AEM CS (aem_cs) delivery type. + * + * Uses a two-step probe strategy (per AEM Content MCP Server approach): + * 1. Try experimental ASPM path (/adobe/experimental/aspm-expires-20251231/pages?limit=1) + * 2. If 404, fall back to stable path (/adobe/pages?limit=1) + * 3. If both 404 → Content API not deployed on this instance + * + * Granular failure detection: + * - Non-AEM CS delivery type → SKIPPED (check not applicable) + * - No authorURL configured → FAILED + * - Missing auth header → FAILED + * - Network error / timeout → FAILED (author instance unreachable) + * - Both paths return 404 → FAILED (Content API not deployed) + * - 401 / 403 → FAILED (insufficient permissions) + * - 2xx on either path → PASSED + * + * @param {Object} site - Site entity + * @param {Object} context - Request context (pathInfo.headers.authorization) + * @param {Object} log - Logger + * @returns {Promise<{type: string, status: string, message: string}>} + */ +const SUPPORTED_DELIVERY_TYPES = [Site.DELIVERY_TYPES.AEM_CS, Site.DELIVERY_TYPES.AEM_AMS]; + +export default async function contentApiAccessHandler(site, context, log) { + // Only supported for AEM CS and AEM AMS — other delivery types use different deploy mechanisms + if (!SUPPORTED_DELIVERY_TYPES.includes(site.getDeliveryType())) { + return { + type: CHECK_TYPE, + status: 'SKIPPED', + message: 'Content API check is only applicable to AEM CS and AEM AMS sites', + }; + } + + const deliveryConfig = site.getDeliveryConfig(); + const authorURL = deliveryConfig?.authorURL; + + if (!authorURL) { + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Site has no authorURL configured', + }; + } + + const authorization = context.pathInfo?.headers?.authorization; + if (!authorization) { + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Missing authorization header', + }; + } + + // Two-step probe: try experimental path first, then stable fallback + for (const probePath of PROBE_PATHS) { + const probeURL = `${authorURL}${probePath}`; + // eslint-disable-next-line no-await-in-loop + const status = await probeUrl(probeURL, authorization); + + if (status === null) { + log.error(`Content API probe failed for ${authorURL}: network error`); + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Author instance is not reachable', + }; + } + + if (status >= 200 && status < 300) { + return { + type: CHECK_TYPE, + status: 'PASSED', + message: 'Content API is accessible', + }; + } + + if (status === 401 || status === 403) { + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Insufficient permissions for Content API', + }; + } + + if (status === 404) { + // Try next probe path + continue; // eslint-disable-line no-continue + } + + return { + type: CHECK_TYPE, + status: 'FAILED', + message: `Content API returned unexpected status ${status}`, + }; + } + + // Both probe paths returned 404 → Content API not deployed + return { + type: CHECK_TYPE, + status: 'FAILED', + message: 'Content API is not available on this AEM instance', + }; +} diff --git a/src/support/autofix-checks/registry.js b/src/support/autofix-checks/registry.js new file mode 100644 index 000000000..2241ada37 --- /dev/null +++ b/src/support/autofix-checks/registry.js @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import contentApiAccessHandler from './handlers/content-api-access.js'; +import codeRepoAccessHandler from './handlers/code-repo-access.js'; + +/** + * Registry of autofix check handlers. + * + * Each handler is a (sync or async) function with the signature: + * (site, context, log) => { type: string, status: string, message: string } + * + * Statuses: + * PASSED — check passed, deployment can proceed + * FAILED — check failed, deployment should be blocked + * SKIPPED — check not applicable for this site/delivery type + * ERROR — handler threw unexpectedly (controller catches and wraps) + * + * To add a new handler: + * 1. Create a file in ./handlers/ following the existing patterns + * 2. Import it here and add an entry to the registry map + * + * The key is the check type string that callers pass in the request body. + */ +const checkHandlerRegistry = { + 'content-api-access': contentApiAccessHandler, + 'code-repo-access': codeRepoAccessHandler, +}; + +export default checkHandlerRegistry; diff --git a/test/controllers/autofix-checks.test.js b/test/controllers/autofix-checks.test.js new file mode 100644 index 000000000..62aef1bec --- /dev/null +++ b/test/controllers/autofix-checks.test.js @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } 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('PreflightChecks Controller', () => { + const sandbox = sinon.createSandbox(); + const siteId = '123e4567-e89b-12d3-a456-426614174000'; + + const loggerStub = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + + const mockSite = { + getId: () => siteId, + getBaseURL: () => 'https://www.example.com', + getOrganization: sandbox.stub().resolves({ + getId: () => 'org-123', + getImsOrgId: () => 'imsOrg123@AdobeOrg', + }), + getDeliveryConfig: () => ({ + authorURL: 'https://author-p123-e456.adobeaemcloud.com', + }), + }; + + const mockDataAccess = { + Site: { + findById: sandbox.stub().resolves(mockSite), + }, + }; + + const mockAccessControlUtil = { + hasAccess: sandbox.stub().resolves(true), + hasAdminAccess: sandbox.stub().returns(false), + }; + + let contentApiHandlerStub; + let PreflightChecksController; + + before(async () => { + contentApiHandlerStub = sandbox.stub(); + + PreflightChecksController = await esmock('../../src/controllers/autofix-checks.js', { + '../../src/support/access-control-util.js': { + default: { + fromContext: () => mockAccessControlUtil, + }, + }, + '../../src/support/autofix-checks/registry.js': { + default: { + 'content-api-access': contentApiHandlerStub, + }, + }, + }); + }); + + let controller; + + beforeEach(() => { + controller = PreflightChecksController.default({ + dataAccess: mockDataAccess, + log: loggerStub, + }); + + mockDataAccess.Site.findById = sandbox.stub().resolves(mockSite); + mockAccessControlUtil.hasAccess = sandbox.stub().resolves(true); + contentApiHandlerStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('throws if context has no dataAccess', () => { + expect(() => PreflightChecksController.default({ dataAccess: null, log: loggerStub })) + .to.throw('Valid data access configuration required'); + }); + + it('throws if context is null', () => { + expect(() => PreflightChecksController.default(null)) + .to.throw('Valid data access configuration required'); + }); + + describe('runChecks', () => { + it('returns 400 when params and data are undefined', async () => { + const response = await controller.runChecks({}); + + expect(response.status).to.equal(400); + }); + + it('returns 400 when checks array is missing', async () => { + const response = await controller.runChecks({ + params: { siteId }, + data: {}, + }); + + expect(response.status).to.equal(400); + }); + + it('returns 400 when checks array is empty', async () => { + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [] }, + }); + + expect(response.status).to.equal(400); + }); + + it('returns 400 for unknown check type', async () => { + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'unknown-check' }] }, + }); + + expect(response.status).to.equal(400); + const body = await response.json(); + expect(body.message).to.include('Unknown check type(s): unknown-check'); + }); + + it('returns 404 when site not found', async () => { + mockDataAccess.Site.findById = sandbox.stub().resolves(null); + + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'content-api-access' }] }, + }); + + expect(response.status).to.equal(404); + }); + + it('returns 403 when user has no access', async () => { + mockAccessControlUtil.hasAccess = sandbox.stub().resolves(false); + + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'content-api-access' }] }, + }); + + expect(response.status).to.equal(403); + }); + + it('runs content-api-access check and returns result', async () => { + contentApiHandlerStub.resolves({ + type: 'content-api-access', + status: 'PASSED', + message: 'Content API is accessible', + }); + + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'content-api-access' }] }, + pathInfo: { headers: { authorization: 'Bearer test-token' } }, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body.siteId).to.equal(siteId); + expect(body.checks).to.have.length(1); + expect(body.checks[0].status).to.equal('PASSED'); + expect(contentApiHandlerStub).to.have.been.calledOnce; + }); + + it('runs multiple checks in parallel', async () => { + contentApiHandlerStub.resolves({ + type: 'content-api-access', + status: 'PASSED', + message: 'Content API is accessible', + }); + + const response = await controller.runChecks({ + params: { siteId }, + data: { + checks: [ + { type: 'content-api-access' }, + { type: 'content-api-access' }, + ], + }, + pathInfo: { headers: { authorization: 'Bearer test-token' } }, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body.checks).to.have.length(2); + expect(contentApiHandlerStub).to.have.been.calledTwice; + }); + + it('returns ERROR when handler throws', async () => { + contentApiHandlerStub.rejects(new Error('Unexpected failure')); + + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'content-api-access' }] }, + pathInfo: { headers: { authorization: 'Bearer test-token' } }, + }); + + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body.checks[0].status).to.equal('ERROR'); + expect(body.checks[0].message).to.equal('Unexpected failure'); + }); + + it('returns 500 when site lookup throws', async () => { + mockDataAccess.Site.findById = sandbox.stub().rejects(new Error('DB connection lost')); + + const response = await controller.runChecks({ + params: { siteId }, + data: { checks: [{ type: 'content-api-access' }] }, + }); + + expect(response.status).to.equal(500); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 35357cbda..a1b902240 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -418,6 +418,10 @@ describe('getRouteHandlers', () => { batchStatus: () => null, }; + const mockAutofixChecksController = { + runChecks: sinon.stub(), + }; + it('segregates static and dynamic routes', () => { const { staticRoutes, dynamicRoutes } = getRouteHandlers( mockAuditsController, @@ -468,6 +472,7 @@ describe('getRouteHandlers', () => { mockFeatureFlagsController, mockPageRelationshipsController, mockEphemeralRunController, + mockAutofixChecksController, ); expect(staticRoutes).to.have.all.keys( @@ -853,6 +858,7 @@ describe('getRouteHandlers', () => { 'GET /organizations/:organizationId/contact-sales-leads', 'GET /organizations/:organizationId/sites/:siteId/contact-sales-lead', 'PATCH /contact-sales-leads/:contactSalesLeadId', + 'POST /sites/:siteId/autofix-checks', ); expect(dynamicRoutes['GET /audits/latest/:auditType'].handler).to.equal(mockAuditsController.getAllLatest); diff --git a/test/support/autofix-checks/handlers/code-repo-access.test.js b/test/support/autofix-checks/handlers/code-repo-access.test.js new file mode 100644 index 000000000..c290408eb --- /dev/null +++ b/test/support/autofix-checks/handlers/code-repo-access.test.js @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import codeRepoAccessHandler from '../../../../src/support/autofix-checks/handlers/code-repo-access.js'; + +describe('code-repo-access handler', () => { + const makesite = (code) => ({ getCode: () => code }); + + it('returns SKIPPED when site has no code config', () => { + const result = codeRepoAccessHandler(makesite(null)); + + expect(result.type).to.equal('code-repo-access'); + expect(result.status).to.equal('SKIPPED'); + }); + + it('returns SKIPPED when code config has no type', () => { + const result = codeRepoAccessHandler(makesite({})); + + expect(result.status).to.equal('SKIPPED'); + }); + + it('returns FAILED for CM Standard repo (type="standard")', () => { + const result = codeRepoAccessHandler(makesite({ + type: 'standard', + url: 'https://github.com/cm/repo', + })); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('standard'); + expect(result.details).to.equal('https://github.com/cm/repo'); + }); + + it('returns FAILED for CM BYOG GitHub (type="github" with numeric owner+repo)', () => { + const result = codeRepoAccessHandler(makesite({ + type: 'github', + owner: '99552', + repo: '12345', + })); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('coming soon'); + }); + + it('returns PASSED for AEMY GitHub (type="github" with string owner+repo)', () => { + const result = codeRepoAccessHandler(makesite({ + type: 'github', + owner: 'adobe', + repo: 'my-site', + })); + + expect(result.status).to.equal('PASSED'); + }); + + it('returns PASSED for GitLab', () => { + const result = codeRepoAccessHandler(makesite({ type: 'gitlab', owner: 'myorg', repo: 'myrepo' })); + + expect(result.status).to.equal('PASSED'); + }); + + it('returns PASSED when owner is non-numeric even if repo is numeric', () => { + const result = codeRepoAccessHandler(makesite({ + type: 'github', + owner: 'adobe', + repo: '99552', + })); + + expect(result.status).to.equal('PASSED'); + }); +}); diff --git a/test/support/autofix-checks/handlers/content-api-access.test.js b/test/support/autofix-checks/handlers/content-api-access.test.js new file mode 100644 index 000000000..e916049e6 --- /dev/null +++ b/test/support/autofix-checks/handlers/content-api-access.test.js @@ -0,0 +1,185 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('content-api-access handler', () => { + const sandbox = sinon.createSandbox(); + const authorURL = 'https://author-p123-e456.adobeaemcloud.com'; + + const loggerStub = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + + const mockSite = { + getDeliveryType: () => 'aem_cs', + getDeliveryConfig: () => ({ authorURL }), + }; + + const mockContext = { + pathInfo: { + headers: { + authorization: 'Bearer test-ims-token', + }, + }, + }; + + let fetchStub; + let contentApiAccessHandler; + + before(async () => { + fetchStub = sandbox.stub(); + + const mod = await esmock( + '../../../../src/support/autofix-checks/handlers/content-api-access.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: fetchStub, + }, + '@adobe/spacecat-shared-data-access': { + Site: { DELIVERY_TYPES: { AEM_CS: 'aem_cs', AEM_AMS: 'aem_ams' } }, + }, + }, + ); + contentApiAccessHandler = mod.default; + }); + + afterEach(() => { + sandbox.restore(); + fetchStub.reset(); + }); + + it('returns SKIPPED for non-AEM CS/AMS sites (Edge Delivery)', async () => { + const site = { getDeliveryType: () => 'aem_edge', getDeliveryConfig: () => ({}) }; + const result = await contentApiAccessHandler(site, mockContext, loggerStub); + + expect(result.type).to.equal('content-api-access'); + expect(result.status).to.equal('SKIPPED'); + expect(result.message).to.include('AEM CS and AEM AMS'); + expect(fetchStub).to.not.have.been.called; + }); + + it('runs the probe for AEM AMS sites', async () => { + const site = { getDeliveryType: () => 'aem_ams', getDeliveryConfig: () => ({ authorURL }) }; + fetchStub.resolves({ status: 200 }); + + const result = await contentApiAccessHandler(site, mockContext, loggerStub); + + expect(result.status).to.equal('PASSED'); + expect(fetchStub).to.have.been.calledOnce; + }); + + it('returns FAILED when site has no authorURL', async () => { + const site = { getDeliveryType: () => 'aem_cs', getDeliveryConfig: () => ({}) }; + const result = await contentApiAccessHandler(site, mockContext, loggerStub); + + expect(result.type).to.equal('content-api-access'); + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('no authorURL'); + }); + + it('returns FAILED when authorization header is missing', async () => { + const ctx = { pathInfo: { headers: {} } }; + const result = await contentApiAccessHandler(mockSite, ctx, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('authorization header'); + }); + + it('returns PASSED when experimental ASPM path responds 200', async () => { + fetchStub.resolves({ status: 200 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('PASSED'); + expect(result.message).to.equal('Content API is accessible'); + expect(fetchStub).to.have.been.calledOnce; + + const [url, opts] = fetchStub.firstCall.args; + expect(url).to.equal(`${authorURL}/adobe/experimental/aspm-expires-20251231/pages?limit=1`); + expect(opts.headers.Authorization).to.equal('Bearer test-ims-token'); + }); + + it('falls back to stable path when experimental path returns 404 and stable returns 200', async () => { + fetchStub.onFirstCall().resolves({ status: 404 }); + fetchStub.onSecondCall().resolves({ status: 200 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('PASSED'); + expect(result.message).to.equal('Content API is accessible'); + expect(fetchStub).to.have.been.calledTwice; + + const [secondUrl] = fetchStub.secondCall.args; + expect(secondUrl).to.equal(`${authorURL}/adobe/pages?limit=1`); + }); + + it('returns FAILED when both probe paths return 404 (Content API not deployed)', async () => { + fetchStub.resolves({ status: 404 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('not available'); + expect(fetchStub).to.have.been.calledTwice; + }); + + it('returns FAILED with "permissions" for 401 on first probe', async () => { + fetchStub.resolves({ status: 401 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('permissions'); + expect(fetchStub).to.have.been.calledOnce; + }); + + it('returns FAILED with "permissions" for 403 on first probe', async () => { + fetchStub.resolves({ status: 403 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('permissions'); + expect(fetchStub).to.have.been.calledOnce; + }); + + it('returns FAILED with unexpected status for other error codes', async () => { + fetchStub.resolves({ status: 500 }); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('unexpected status 500'); + expect(fetchStub).to.have.been.calledOnce; + }); + + it('returns FAILED with "not reachable" on network error', async () => { + fetchStub.rejects(new Error('ECONNREFUSED')); + + const result = await contentApiAccessHandler(mockSite, mockContext, loggerStub); + + expect(result.status).to.equal('FAILED'); + expect(result.message).to.include('not reachable'); + expect(loggerStub.error).to.have.been.calledOnce; + }); +}); diff --git a/test/support/autofix-checks/registry.test.js b/test/support/autofix-checks/registry.test.js new file mode 100644 index 000000000..adabacaf2 --- /dev/null +++ b/test/support/autofix-checks/registry.test.js @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import checkHandlerRegistry from '../../../src/support/autofix-checks/registry.js'; + +describe('Autofix Check Handler Registry', () => { + it('has content-api-access handler registered', () => { + expect(checkHandlerRegistry['content-api-access']).to.be.a('function'); + }); + + it('has code-repo-access handler registered', () => { + expect(checkHandlerRegistry['code-repo-access']).to.be.a('function'); + }); + + it('does not have unknown handler types', () => { + expect(checkHandlerRegistry['unknown-check']).to.be.undefined; + }); +});