Skip to content

Commit 2c3d2a5

Browse files
noruiznoelruiz34
andauthored
feat: add slack for redirects (#1830)
Please ensure your pull request adheres to the following guidelines: - [ ] make sure to link the related issues in this description. Or if there's no issue created, make sure you describe here the problem you're solving. - [ ] when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes If the PR is changing the API specification: - [ ] make sure you add a "Not implemented yet" note the endpoint description, if the implementation is not ready yet. Ideally, return a 501 status code with a message explaining the feature is not implemented yet. - [ ] make sure you add at least one example of the request and response. If the PR is changing the API implementation or an entity exposed through the API: - [ ] make sure you update the API specification and the examples to reflect the changes. If the PR is introducing a new audit type: - [ ] make sure you update the API specification with the type, schema of the audit result and an example ## Related Issues - https://jira.corp.adobe.com/browse/SITES-40913 Thanks for contributing! --------- Co-authored-by: noelruiz34 <noelruiz34@gmail.com>
1 parent 5d15d48 commit 2c3d2a5

File tree

3 files changed

+370
-0
lines changed

3 files changed

+370
-0
lines changed

src/support/slack/commands.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import revokeEntitlementImsOrg from './commands/revoke-entitlement-imsorg.js';
4646
import detectBotBlocker from './commands/detect-bot-blocker.js';
4747
import runPageCitability from './commands/run-page-citability.js';
4848
import runA11yCodefix from './commands/run-a11y-codefix.js';
49+
import identifyRedirects from './commands/identify-redirects.js';
4950

5051
/**
5152
* Returns all commands.
@@ -90,4 +91,5 @@ export default (context) => [
9091
detectBotBlocker(context),
9192
runPageCitability(context),
9293
runA11yCodefix(context),
94+
identifyRedirects(context),
9395
];
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { Site as SiteModel } from '@adobe/spacecat-shared-data-access';
14+
import { hasText } from '@adobe/spacecat-shared-utils';
15+
16+
import BaseCommand from './base.js';
17+
import { extractURLFromSlackInput, postErrorMessage } from '../../../utils/slack/base.js';
18+
19+
const PHRASES = ['identify-redirects'];
20+
const DEFAULT_MINUTES = 60;
21+
22+
export default function IdentifyRedirectsCommand(context) {
23+
const baseCommand = BaseCommand({
24+
id: 'identify-redirects',
25+
name: 'Identify Redirects',
26+
description: 'Detects common redirect-manager patterns using Splunk logs (AEM CS/CW only).',
27+
phrases: PHRASES,
28+
usageText: `${PHRASES[0]} {baseURL}`,
29+
});
30+
31+
const {
32+
dataAccess,
33+
env,
34+
log,
35+
sqs,
36+
} = context;
37+
const { Site } = dataAccess;
38+
39+
const handleExecution = async (args, slackContext) => {
40+
const { say, channelId, threadTs } = slackContext;
41+
42+
try {
43+
const [baseURLInput, minutesInput] = args;
44+
const baseURL = extractURLFromSlackInput(baseURLInput);
45+
const minutes = Number.isFinite(Number(minutesInput))
46+
? Number(minutesInput)
47+
: DEFAULT_MINUTES;
48+
49+
if (!baseURL) {
50+
await say(baseCommand.usage());
51+
return;
52+
}
53+
54+
const site = await Site.findByBaseURL(baseURL);
55+
if (!site) {
56+
await say(`:x: No site found with base URL '${baseURL}'.`);
57+
return;
58+
}
59+
60+
const authoringType = site.getAuthoringType();
61+
if (![
62+
SiteModel.AUTHORING_TYPES.CS,
63+
SiteModel.AUTHORING_TYPES.CS_CW,
64+
].includes(authoringType)) {
65+
await say(`:warning: identify-redirects currently supports AEM CS/CW only. This site authoringType is \`${authoringType}\`.`);
66+
return;
67+
}
68+
69+
const deliveryConfig = site.getDeliveryConfig?.() || {};
70+
const { programId, environmentId } = deliveryConfig;
71+
72+
if (!hasText(programId) || !hasText(environmentId)) {
73+
await say(':warning: This site is missing `deliveryConfig.programId` and/or `deliveryConfig.environmentId` required for Splunk queries.');
74+
return;
75+
}
76+
77+
if (!hasText(env?.AUDIT_JOBS_QUEUE_URL)) {
78+
await say(':x: Server misconfiguration: missing `AUDIT_JOBS_QUEUE_URL`.');
79+
return;
80+
}
81+
82+
if (!sqs) {
83+
await say(':x: Server misconfiguration: missing SQS client.');
84+
return;
85+
}
86+
87+
await say(`:mag: Queued redirect pattern detection for *${baseURL}* (last ${minutes}m). I’ll reply here when it’s ready.`);
88+
89+
await sqs.sendMessage(env.AUDIT_JOBS_QUEUE_URL, {
90+
type: 'identify-redirects',
91+
siteId: site.getId(),
92+
baseURL,
93+
programId: String(programId),
94+
environmentId: String(environmentId),
95+
minutes,
96+
slackContext: {
97+
channelId,
98+
threadTs,
99+
},
100+
});
101+
} catch (error) {
102+
log.error(error);
103+
await postErrorMessage(say, error);
104+
}
105+
};
106+
107+
baseCommand.init(context);
108+
109+
return {
110+
...baseCommand,
111+
handleExecution,
112+
};
113+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/* eslint-env mocha */
14+
15+
import { expect, use } from 'chai';
16+
import sinonChai from 'sinon-chai';
17+
import sinon from 'sinon';
18+
import esmock from 'esmock';
19+
20+
use(sinonChai);
21+
22+
describe('IdentifyRedirectsCommand', () => {
23+
const SiteModelStub = {
24+
AUTHORING_TYPES: {
25+
CS: 'CS',
26+
CS_CW: 'CS_CW',
27+
},
28+
};
29+
30+
let IdentifyRedirectsCommand;
31+
let context;
32+
let slackContext;
33+
let dataAccessStub;
34+
let sqsStub;
35+
let extractURLFromSlackInputStub;
36+
let postErrorMessageStub;
37+
38+
beforeEach(async function beforeEachHook() {
39+
this.timeout(10000);
40+
extractURLFromSlackInputStub = sinon.stub();
41+
postErrorMessageStub = sinon.stub().resolves();
42+
43+
IdentifyRedirectsCommand = (await esmock(
44+
'../../../../src/support/slack/commands/identify-redirects.js',
45+
{
46+
'@adobe/spacecat-shared-data-access': { Site: SiteModelStub },
47+
'../../../../src/utils/slack/base.js': {
48+
extractURLFromSlackInput: extractURLFromSlackInputStub,
49+
postErrorMessage: postErrorMessageStub,
50+
},
51+
},
52+
)).default;
53+
54+
dataAccessStub = {
55+
Site: {
56+
findByBaseURL: sinon.stub(),
57+
},
58+
};
59+
60+
sqsStub = {
61+
sendMessage: sinon.stub().resolves(),
62+
};
63+
64+
context = {
65+
dataAccess: dataAccessStub,
66+
env: { AUDIT_JOBS_QUEUE_URL: 'testQueueUrl' },
67+
log: {
68+
error: sinon.spy(),
69+
},
70+
sqs: sqsStub,
71+
};
72+
73+
slackContext = {
74+
say: sinon.stub().resolves(),
75+
channelId: 'C123',
76+
threadTs: '1712345678.9012',
77+
};
78+
});
79+
80+
it('initializes with base command metadata', () => {
81+
const command = IdentifyRedirectsCommand(context);
82+
expect(command.id).to.equal('identify-redirects');
83+
expect(command.name).to.equal('Identify Redirects');
84+
expect(command.phrases).to.deep.equal(['identify-redirects']);
85+
});
86+
87+
it('shows usage when baseURL is missing/invalid', async () => {
88+
extractURLFromSlackInputStub.returns(null);
89+
const command = IdentifyRedirectsCommand(context);
90+
91+
await command.handleExecution([], slackContext);
92+
93+
expect(slackContext.say).to.have.been.calledOnceWith(command.usage());
94+
});
95+
96+
it('notifies when site is not found', async () => {
97+
extractURLFromSlackInputStub.returns('https://example.com');
98+
dataAccessStub.Site.findByBaseURL.resolves(null);
99+
const command = IdentifyRedirectsCommand(context);
100+
101+
await command.handleExecution(['example.com'], slackContext);
102+
103+
expect(slackContext.say).to.have.been.calledWith(
104+
":x: No site found with base URL 'https://example.com'.",
105+
);
106+
});
107+
108+
it('rejects non CS/CW authoring types', async () => {
109+
extractURLFromSlackInputStub.returns('https://example.com');
110+
dataAccessStub.Site.findByBaseURL.resolves({
111+
getAuthoringType: () => 'AMS',
112+
getDeliveryConfig: () => ({ programId: 'p', environmentId: 'e' }),
113+
getId: () => 'site-1',
114+
});
115+
const command = IdentifyRedirectsCommand(context);
116+
117+
await command.handleExecution(['example.com'], slackContext);
118+
119+
expect(slackContext.say).to.have.been.calledWithMatch(
120+
'identify-redirects currently supports AEM CS/CW only',
121+
);
122+
expect(slackContext.say).to.have.been.calledWithMatch('`AMS`');
123+
});
124+
125+
it('warns when deliveryConfig is missing programId/environmentId', async () => {
126+
extractURLFromSlackInputStub.returns('https://example.com');
127+
dataAccessStub.Site.findByBaseURL.resolves({
128+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS,
129+
getDeliveryConfig: () => ({ programId: '', environmentId: 'e' }),
130+
getId: () => 'site-1',
131+
});
132+
const command = IdentifyRedirectsCommand(context);
133+
134+
await command.handleExecution(['example.com'], slackContext);
135+
136+
expect(slackContext.say).to.have.been.calledWithMatch(
137+
'missing `deliveryConfig.programId` and/or `deliveryConfig.environmentId`',
138+
);
139+
});
140+
141+
it('warns when getDeliveryConfig is not available on the site', async () => {
142+
extractURLFromSlackInputStub.returns('https://example.com');
143+
dataAccessStub.Site.findByBaseURL.resolves({
144+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS,
145+
getId: () => 'site-1',
146+
});
147+
const command = IdentifyRedirectsCommand(context);
148+
149+
await command.handleExecution(['example.com'], slackContext);
150+
151+
expect(slackContext.say).to.have.been.calledWithMatch(
152+
'missing `deliveryConfig.programId` and/or `deliveryConfig.environmentId`',
153+
);
154+
});
155+
156+
it('fails when AUDIT_JOBS_QUEUE_URL is missing', async () => {
157+
extractURLFromSlackInputStub.returns('https://example.com');
158+
dataAccessStub.Site.findByBaseURL.resolves({
159+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS_CW,
160+
getDeliveryConfig: () => ({ programId: 'p', environmentId: 'e' }),
161+
getId: () => 'site-1',
162+
});
163+
164+
const command = IdentifyRedirectsCommand({
165+
...context,
166+
env: {},
167+
});
168+
169+
await command.handleExecution(['example.com'], slackContext);
170+
171+
expect(slackContext.say).to.have.been.calledWithMatch(
172+
'missing `AUDIT_JOBS_QUEUE_URL`',
173+
);
174+
});
175+
176+
it('fails when SQS client is missing', async () => {
177+
extractURLFromSlackInputStub.returns('https://example.com');
178+
dataAccessStub.Site.findByBaseURL.resolves({
179+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS_CW,
180+
getDeliveryConfig: () => ({ programId: 'p', environmentId: 'e' }),
181+
getId: () => 'site-1',
182+
});
183+
184+
const command = IdentifyRedirectsCommand({
185+
...context,
186+
sqs: null,
187+
});
188+
189+
await command.handleExecution(['example.com'], slackContext);
190+
191+
expect(slackContext.say).to.have.been.calledWithMatch(
192+
'missing SQS client',
193+
);
194+
});
195+
196+
it('enqueues a job with default minutes when minutes is omitted', async () => {
197+
extractURLFromSlackInputStub.returns('https://example.com');
198+
dataAccessStub.Site.findByBaseURL.resolves({
199+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS,
200+
getDeliveryConfig: () => ({ programId: 'p', environmentId: 'e' }),
201+
getId: () => 'site-1',
202+
});
203+
const command = IdentifyRedirectsCommand(context);
204+
205+
await command.handleExecution(['example.com'], slackContext);
206+
207+
expect(slackContext.say).to.have.been.calledWithMatch('Queued redirect pattern detection');
208+
expect(sqsStub.sendMessage).to.have.been.calledOnce;
209+
expect(sqsStub.sendMessage.firstCall.args[0]).to.equal('testQueueUrl');
210+
expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.include({
211+
type: 'identify-redirects',
212+
siteId: 'site-1',
213+
baseURL: 'https://example.com',
214+
programId: 'p',
215+
environmentId: 'e',
216+
minutes: 60,
217+
});
218+
expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.include({
219+
slackContext: {
220+
channelId: 'C123',
221+
threadTs: '1712345678.9012',
222+
},
223+
});
224+
});
225+
226+
it('parses minutes and enqueues a job', async () => {
227+
extractURLFromSlackInputStub.returns('https://example.com');
228+
dataAccessStub.Site.findByBaseURL.resolves({
229+
getAuthoringType: () => SiteModelStub.AUTHORING_TYPES.CS_CW,
230+
getDeliveryConfig: () => ({ programId: 'p', environmentId: 'e' }),
231+
getId: () => 'site-1',
232+
});
233+
const command = IdentifyRedirectsCommand(context);
234+
235+
await command.handleExecution(['example.com', '15'], slackContext);
236+
237+
expect(sqsStub.sendMessage).to.have.been.calledOnce;
238+
expect(sqsStub.sendMessage.firstCall.args[1]).to.deep.include({
239+
minutes: 15,
240+
});
241+
});
242+
243+
it('logs and posts an error message when an exception occurs', async () => {
244+
extractURLFromSlackInputStub.returns('https://example.com');
245+
dataAccessStub.Site.findByBaseURL.rejects(new Error('boom'));
246+
const command = IdentifyRedirectsCommand(context);
247+
248+
await command.handleExecution(['example.com'], slackContext);
249+
250+
expect(context.log.error).to.have.been.calledOnce;
251+
expect(postErrorMessageStub).to.have.been.calledOnce;
252+
expect(postErrorMessageStub.firstCall.args[0]).to.equal(slackContext.say);
253+
expect(postErrorMessageStub.firstCall.args[1]).to.be.instanceOf(Error);
254+
});
255+
});

0 commit comments

Comments
 (0)