diff --git a/src/support/slack/commands.js b/src/support/slack/commands.js index 5a93ae8e9..a811b76b4 100644 --- a/src/support/slack/commands.js +++ b/src/support/slack/commands.js @@ -48,6 +48,7 @@ import runPageCitability from './commands/run-page-citability.js'; import runA11yCodefix from './commands/run-a11y-codefix.js'; import identifyRedirects from './commands/identify-redirects.js'; import identifyAndUpdateRedirects from './commands/identify-and-update-redirects.js'; +import detectCdn from './commands/detect-cdn.js'; /** * Returns all commands. @@ -94,4 +95,5 @@ export default (context) => [ runA11yCodefix(context), identifyRedirects(context), identifyAndUpdateRedirects(context), + detectCdn(context), ]; diff --git a/src/support/slack/commands/detect-cdn.js b/src/support/slack/commands/detect-cdn.js new file mode 100644 index 000000000..e7baea39b --- /dev/null +++ b/src/support/slack/commands/detect-cdn.js @@ -0,0 +1,94 @@ +/* + * 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 { hasText } from '@adobe/spacecat-shared-utils'; + +import BaseCommand from './base.js'; +import { extractURLFromSlackInput, postErrorMessage } from '../../../utils/slack/base.js'; + +const PHRASES = ['detect-cdn']; + +export default function DetectCdnCommand(context) { + const baseCommand = BaseCommand({ + id: 'detect-cdn', + name: 'Detect CDN', + description: 'Detects which CDN a website uses (e.g. Cloudflare, Akamai, Fastly) from HTTP headers. Optional: pass a Spacecat site base URL to associate the result with a site for future onboarding.', + phrases: PHRASES, + usageText: `${PHRASES[0]} {url}`, + }); + + const { + dataAccess, + env, + log, + sqs, + } = context; + const { Site } = dataAccess; + + const handleExecution = async (args, slackContext) => { + const { say, channelId, threadTs } = slackContext; + + try { + const [urlInput] = args; + const baseURL = extractURLFromSlackInput(urlInput); + + if (!baseURL) { + await say(baseCommand.usage()); + return; + } + + if (!hasText(env?.AUDIT_JOBS_QUEUE_URL)) { + await say(':x: Server misconfiguration: missing `AUDIT_JOBS_QUEUE_URL`.'); + return; + } + + if (!sqs) { + await say(':x: Server misconfiguration: missing SQS client.'); + return; + } + + // Optional: if URL matches a Spacecat site, pass siteId for future onboarding integration + let siteId = null; + try { + const site = await Site.findByBaseURL(baseURL); + if (site) { + siteId = site.getId(); + } + } catch { + // ignore; we can still detect CDN for any URL + } + + await say(`:mag: Queued CDN detection for *${baseURL}*. I'll reply here when it's ready.`); + + await sqs.sendMessage(env.AUDIT_JOBS_QUEUE_URL, { + type: 'detect-cdn', + baseURL, + ...(siteId && { siteId }), + slackContext: { + channelId, + threadTs, + }, + }); + } catch (error) { + log.error(error); + await postErrorMessage(say, error); + } + }; + + baseCommand.init(context); + + return { + ...baseCommand, + usageText: `${PHRASES[0]} {url}`, + handleExecution, + }; +} diff --git a/test/support/slack/commands/detect-cdn.test.js b/test/support/slack/commands/detect-cdn.test.js new file mode 100644 index 000000000..59c3c3efc --- /dev/null +++ b/test/support/slack/commands/detect-cdn.test.js @@ -0,0 +1,206 @@ +/* + * 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, use } from 'chai'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('DetectCdnCommand', () => { + let DetectCdnCommand; + let context; + let slackContext; + let dataAccessStub; + let sqsStub; + let extractURLFromSlackInputStub; + let postErrorMessageStub; + + beforeEach(async function beforeEachHook() { + this.timeout(10000); + extractURLFromSlackInputStub = sinon.stub(); + postErrorMessageStub = sinon.stub().callsFake(async (sayFn, err) => { + await sayFn(`:nuclear-warning: Oops! Something went wrong: ${err.message}`); + }); + + DetectCdnCommand = (await esmock( + '../../../../src/support/slack/commands/detect-cdn.js', + { + '../../../../src/utils/slack/base.js': { + extractURLFromSlackInput: extractURLFromSlackInputStub, + postErrorMessage: postErrorMessageStub, + }, + }, + )).default; + + dataAccessStub = { + Site: { + findByBaseURL: sinon.stub(), + }, + }; + + sqsStub = { + sendMessage: sinon.stub().resolves(), + }; + + context = { + dataAccess: dataAccessStub, + env: { AUDIT_JOBS_QUEUE_URL: 'https://sqs.example.com/queue' }, + log: { + error: sinon.spy(), + }, + sqs: sqsStub, + }; + + slackContext = { + say: sinon.stub().resolves(), + channelId: 'C123', + threadTs: '1712345678.9012', + }; + }); + + it('initializes with base command metadata', () => { + const command = DetectCdnCommand(context); + expect(command.id).to.equal('detect-cdn'); + expect(command.name).to.equal('Detect CDN'); + expect(command.phrases).to.deep.equal(['detect-cdn']); + expect(command.usageText).to.equal('detect-cdn {url}'); + }); + + it('shows usage when URL is missing/invalid', async () => { + extractURLFromSlackInputStub.returns(null); + const command = DetectCdnCommand(context); + + await command.handleExecution([], slackContext); + + expect(slackContext.say).to.have.been.calledOnceWith(command.usage()); + }); + + it('notifies when AUDIT_JOBS_QUEUE_URL is missing', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + const command = DetectCdnCommand({ + ...context, + env: {}, + }); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(slackContext.say.firstCall.args[0]).to.include('missing `AUDIT_JOBS_QUEUE_URL`'); + }); + + it('notifies when env is undefined', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + const { env: _, ...contextWithoutEnv } = context; + const command = DetectCdnCommand(contextWithoutEnv); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(slackContext.say.firstCall.args[0]).to.include('missing `AUDIT_JOBS_QUEUE_URL`'); + }); + + it('notifies when SQS client is missing', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + const command = DetectCdnCommand({ + ...context, + sqs: null, + }); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(slackContext.say.firstCall.args[0]).to.include('missing SQS client'); + }); + + it('says queued and sends SQS message when URL is valid and no site found', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + dataAccessStub.Site.findByBaseURL.resolves(null); + const command = DetectCdnCommand(context); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(slackContext.say.firstCall.args[0]).to.include('Queued CDN detection for *https://example.com*'); + expect(sqsStub.sendMessage).to.have.been.calledOnce; + expect(sqsStub.sendMessage.firstCall.args[0]).to.equal('https://sqs.example.com/queue'); + expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.equal({ + type: 'detect-cdn', + baseURL: 'https://example.com', + slackContext: { + channelId: 'C123', + threadTs: '1712345678.9012', + }, + }); + }); + + it('includes siteId in SQS payload when site is found for base URL', async () => { + extractURLFromSlackInputStub.returns('https://mysite.com'); + dataAccessStub.Site.findByBaseURL.resolves({ + getId: () => 'site-uuid-123', + }); + const command = DetectCdnCommand(context); + + await command.handleExecution(['https://mysite.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(sqsStub.sendMessage).to.have.been.calledOnce; + expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.include({ + type: 'detect-cdn', + baseURL: 'https://mysite.com', + siteId: 'site-uuid-123', + slackContext: { + channelId: 'C123', + threadTs: '1712345678.9012', + }, + }); + }); + + it('still queues job without siteId when Site.findByBaseURL throws', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + dataAccessStub.Site.findByBaseURL.rejects(new Error('db error')); + const command = DetectCdnCommand(context); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + expect(slackContext.say.firstCall.args[0]).to.include('Queued CDN detection'); + expect(sqsStub.sendMessage).to.have.been.calledOnce; + expect(sqsStub.sendMessage.firstCall.args[1]).to.not.have.property('siteId'); + expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.include({ + type: 'detect-cdn', + baseURL: 'https://example.com', + slackContext: { + channelId: 'C123', + threadTs: '1712345678.9012', + }, + }); + }); + + it('logs and posts error when an exception occurs in handleExecution', async () => { + extractURLFromSlackInputStub.returns('https://example.com'); + dataAccessStub.Site.findByBaseURL.resolves(null); + sqsStub.sendMessage.rejects(new Error('SQS failure')); + const command = DetectCdnCommand(context); + + await command.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledTwice; // once "Queued...", then error + expect(context.log.error).to.have.been.calledOnce; + expect(postErrorMessageStub).to.have.been.calledOnce; + expect(postErrorMessageStub.firstCall.args[0]).to.equal(slackContext.say); + expect(postErrorMessageStub.firstCall.args[1]).to.be.instanceOf(Error); + }); +});