Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions src/controllers/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {
isNonEmptyObject, isValidUUID, isValidUrl, isNonEmptyArray,
hasText, isNonEmptyObject, isValidUUID, isValidUrl, isNonEmptyArray,
} from '@adobe/spacecat-shared-utils';
import {
badRequest, internalServerError, notFound, ok, accepted,
Expand Down Expand Up @@ -85,12 +85,16 @@ function PreflightController(ctx, log, env) {
}

/**
* Creates a new preflight job
* Creates a new preflight job. For promise-based authoring types (CS, CS_CW, AMS),
* the promise token is resolved from the x-promise-token request header if present,
* otherwise falls back to creating one from the Authorization header via IMS.
* @param {Object} context - The request context
* @param {Object} context.data - The request data
* @param {string[]} context.data.urls - Array of URLs to process
* @param {string} context.data.step - The audit step
* @param {string} context.data.siteId - The siteId, if it's an AMS site
* @param {Object} [context.request] - The request object
* @param {Object} [context.request.headers] - Request headers (x-promise-token preferred)
* @returns {Promise<Object>} The HTTP response object
*/
const createPreflightJob = async (context) => {
Expand Down Expand Up @@ -134,14 +138,19 @@ function PreflightController(ctx, log, env) {

let promiseTokenResponse;
if (promiseBasedTypes.includes(site.getAuthoringType())) {
try {
promiseTokenResponse = await getIMSPromiseToken(context);
} catch (e) {
log.error(`Failed to get promise token: ${e.message}`);
if (e instanceof ErrorWithStatusCode) {
return badRequest(e.message);
const headerToken = context.request?.headers?.get?.('x-promise-token');
if (hasText(headerToken)) {
promiseTokenResponse = { promise_token: headerToken };
} else {
try {
promiseTokenResponse = await getIMSPromiseToken(context);
} catch (e) {
log.error(`Failed to get promise token: ${e.message}`);
if (e instanceof ErrorWithStatusCode) {
return badRequest(e.message);
}
return internalServerError('Error getting promise token');
}
return internalServerError('Error getting promise token');
}
}

Expand Down
31 changes: 25 additions & 6 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,20 @@ function SuggestionsController(ctx, sqs, env) {
};
return createResponse(fullResponse, 207);
};
/**
* Triggers auto-fix for the given suggestions. Validates the site, opportunity, and
* suggestions, then queues an autofix message via SQS.
*
* For promise token resolution, prefers the x-promise-token request header if present.
* Falls back to obtaining a token via IMS when the header is absent or empty.
*
* @param {Object} context - The request context
* @param {Object} [context.request] - The request object
* @param {Object} [context.request.headers] - Request headers (x-promise-token preferred)
* @param {Object} context.params - Path parameters (siteId, opportunityId)
* @param {Object} context.data - Request body containing suggestionIds
* @returns {Promise<Response>} 207 multi-status response with per-suggestion results
*/
const autofixSuggestions = async (context) => {
const siteId = context.params?.siteId;
const opportunityId = context.params?.opportunityId;
Expand Down Expand Up @@ -894,13 +908,18 @@ function SuggestionsController(ctx, sqs, env) {
}

let promiseTokenResponse;
try {
promiseTokenResponse = await getIMSPromiseToken(context);
} catch (e) {
if (e instanceof ErrorWithStatusCode) {
return badRequest(e.message);
const headerToken = context.request?.headers?.get?.('x-promise-token');
if (hasText(headerToken)) {
promiseTokenResponse = { promise_token: headerToken };
} else {
try {
promiseTokenResponse = await getIMSPromiseToken(context);
} catch (e) {
if (e instanceof ErrorWithStatusCode) {
return badRequest(e.message);
}
return createResponse({ message: 'Error getting promise token' }, 500);
}
return createResponse({ message: 'Error getting promise token' }, 500);
}

const response = {
Expand Down
146 changes: 146 additions & 0 deletions test/controllers/preflight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,152 @@ describe('Preflight Controller', () => {
message: 'Error getting promise token',
});
});

it('uses x-promise-token header when present instead of IMS', async () => {
const aemCsSite = {
getId: () => 'test-site-123',
getAuthoringType: () => SiteModel.AUTHORING_TYPES.CS,
};
mockDataAccess.Site.findByPreviewURL.resolves(aemCsSite);

const getIMSPromiseTokenStub = sandbox.stub();
const PreflightControllerWithMock = await esmock('../../src/controllers/preflight.js', {
'../../src/support/utils.js': {
...utils,
getIMSPromiseToken: getIMSPromiseTokenStub,
ErrorWithStatusCode: utils.ErrorWithStatusCode,
},
});

const preflightControllerWithMock = PreflightControllerWithMock(
{ dataAccess: mockDataAccess, sqs: mockSqs },
loggerStub,
{
AUDIT_JOBS_QUEUE_URL: 'https://sqs.test.amazonaws.com/audit-queue',
AWS_ENV: 'prod',
},
);

const context = {
data: {
urls: ['https://example.com/test.html'],
step: 'identify',
},
request: {
headers: {
get: (name) => (name === 'x-promise-token' ? 'headerToken123' : null),
},
},
};

const response = await preflightControllerWithMock.createPreflightJob(context);
expect(response.status).to.equal(202);
expect(getIMSPromiseTokenStub).to.not.have.been.called;
expect(mockSqs.sendMessage).to.have.been.calledWith(
'https://sqs.test.amazonaws.com/audit-queue',
{
jobId,
siteId: 'test-site-123',
type: 'preflight',
promiseToken: { promise_token: 'headerToken123' },
},
);
});

it('falls back to IMS when x-promise-token header is absent', async () => {
const aemCsSite = {
getId: () => 'test-site-123',
getAuthoringType: () => SiteModel.AUTHORING_TYPES.CS_CW,
};
mockDataAccess.Site.findByPreviewURL.resolves(aemCsSite);

const mockPromiseToken = { promise_token: 'ims-token', expires_in: 3600, token_type: 'Bearer' };
const PreflightControllerWithMock = await esmock('../../src/controllers/preflight.js', {
'../../src/support/utils.js': {
...utils,
getIMSPromiseToken: async () => mockPromiseToken,
ErrorWithStatusCode: utils.ErrorWithStatusCode,
},
});

const preflightControllerWithMock = PreflightControllerWithMock(
{ dataAccess: mockDataAccess, sqs: mockSqs },
loggerStub,
{
AUDIT_JOBS_QUEUE_URL: 'https://sqs.test.amazonaws.com/audit-queue',
AWS_ENV: 'prod',
},
);

const context = {
data: {
urls: ['https://example.com/test.html'],
step: 'identify',
},
};

const response = await preflightControllerWithMock.createPreflightJob(context);
expect(response.status).to.equal(202);
expect(mockSqs.sendMessage).to.have.been.calledWith(
'https://sqs.test.amazonaws.com/audit-queue',
{
jobId,
siteId: mockSite.getId(),
type: 'preflight',
promiseToken: mockPromiseToken,
},
);
});

it('falls back to IMS when x-promise-token header is empty', async () => {
const aemCsSite = {
getId: () => 'test-site-123',
getAuthoringType: () => SiteModel.AUTHORING_TYPES.AMS,
};
mockDataAccess.Site.findByPreviewURL.resolves(aemCsSite);

const mockPromiseToken = { promise_token: 'ims-fallback', expires_in: 3600, token_type: 'Bearer' };
const PreflightControllerWithMock = await esmock('../../src/controllers/preflight.js', {
'../../src/support/utils.js': {
...utils,
getIMSPromiseToken: async () => mockPromiseToken,
ErrorWithStatusCode: utils.ErrorWithStatusCode,
},
});

const preflightControllerWithMock = PreflightControllerWithMock(
{ dataAccess: mockDataAccess, sqs: mockSqs },
loggerStub,
{
AUDIT_JOBS_QUEUE_URL: 'https://sqs.test.amazonaws.com/audit-queue',
AWS_ENV: 'prod',
},
);

const context = {
data: {
urls: ['https://example.com/test.html'],
step: 'identify',
},
request: {
headers: {
get: () => '',
},
},
};

const response = await preflightControllerWithMock.createPreflightJob(context);
expect(response.status).to.equal(202);
expect(mockSqs.sendMessage).to.have.been.calledWith(
'https://sqs.test.amazonaws.com/audit-queue',
{
jobId,
siteId: 'test-site-123',
type: 'preflight',
promiseToken: mockPromiseToken,
},
);
});
});

describe('getPreflightJobStatusAndResult', () => {
Expand Down
96 changes: 96 additions & 0 deletions test/controllers/suggestions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3296,6 +3296,102 @@ describe('Suggestions Controller', () => {
const error = await response.json();
expect(error).to.have.property('message', 'Error getting promise token');
});

it('uses x-promise-token header when present instead of IMS', async () => {
mockSuggestion.allByOpportunityId.resolves(
[mockSuggestionEntity(suggs[0]),
mockSuggestionEntity(suggs[2]),
],
);
mockSuggestion.bulkUpdateStatus.resolves([mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }),
mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' })]);

const getPromiseTokenStub = imsPromiseClient.createFrom().getPromiseToken;

const response = await suggestionsControllerWithIms.autofixSuggestions({
env: {
AUTOFIX_CRYPT_SECRET: 'superSecret',
AUTOFIX_CRYPT_SALT: 'salt',
},
pathInfo: {
headers: {
authorization: 'Bearer token123',
},
},
request: {
headers: {
get: (name) => (name?.toLowerCase?.() === 'x-promise-token' ? 'header-promise-token' : null),
},
},
params: {
siteId: SITE_ID,
opportunityId: OPPORTUNITY_ID,
},
data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[2]] },
});

expect(response.status).to.equal(207);
expect(sqsSpy.firstCall.args[1]).to.have.property('promiseToken');
expect(sqsSpy.firstCall.args[1].promiseToken).to.have.property('promise_token', 'header-promise-token');
expect(getPromiseTokenStub).to.not.have.been.called;
});

it('falls back to IMS when x-promise-token header is absent', async () => {
mockSuggestion.allByOpportunityId.resolves(
[mockSuggestionEntity(suggs[0]),
mockSuggestionEntity(suggs[2]),
],
);
mockSuggestion.bulkUpdateStatus.resolves([mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }),
mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' })]);
const response = await suggestionsControllerWithIms.autofixSuggestions({
pathInfo: {
headers: {
authorization: 'Bearer token123',
},
},
params: {
siteId: SITE_ID,
opportunityId: OPPORTUNITY_ID,
},
data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[2]] },
});

expect(response.status).to.equal(207);
expect(sqsSpy.firstCall.args[1]).to.have.property('promiseToken');
expect(sqsSpy.firstCall.args[1].promiseToken).to.have.property('promise_token', 'promiseTokenExample');
});

it('falls back to IMS when x-promise-token header is empty', async () => {
mockSuggestion.allByOpportunityId.resolves(
[mockSuggestionEntity(suggs[0]),
mockSuggestionEntity(suggs[2]),
],
);
mockSuggestion.bulkUpdateStatus.resolves([mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }),
mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' })]);
const response = await suggestionsControllerWithIms.autofixSuggestions({
pathInfo: {
headers: {
authorization: 'Bearer token123',
},
},
request: {
headers: {
get: () => '',
},
},
params: {
siteId: SITE_ID,
opportunityId: OPPORTUNITY_ID,
},
data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[2]] },
});

expect(response.status).to.equal(207);
expect(sqsSpy.firstCall.args[1]).to.have.property('promiseToken');
expect(sqsSpy.firstCall.args[1].promiseToken).to.have.property('promise_token', 'promiseTokenExample');
});
});

describe('removeSuggestion', () => {
Expand Down