Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
62 changes: 32 additions & 30 deletions src/controllers/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,35 +483,27 @@ function PreflightController(ctx, log, env) {
}
}

// Mint a spacecat-api-service IMS v3 service token for the Mystique CGW
// edge gate (SITES-43236 / mystique-deploy PR #463). Sent on the dedicated
// x-ims-authorization header — separate from `Authorization` above, which
// carries the customer site's page-auth token for DRS upstream.
// Mint a spacecat-api-service IMS service token for the Mystique CGW
// edge gate (SITES-43236 / SITES-46699 / mystique-deploy PR #463). Sent
// on the dedicated x-ims-authorization header — separate from
// `Authorization` above, which carries the customer site's page-auth
// token for DRS upstream.
//
// Constructs a custom-env ImsClient with IMS_SCOPE='system' hardcoded
// because the default spacecat IMS client (`aem-project-collab-service`
// per Vault `dx_mysticat`) has no `IMS_SCOPE` provisioned for the v3
// client_credentials grant — empirically confirmed in dev where the
// wrapper-wired `context.imsClient` produced an IMS 400 (invalid_scope).
// 'system' is the Adobe IMS mandated scope for v3 service-client tokens
// (per ASO team Slack thread, 2026-06-17). This mirrors the existing
// pattern in `email-service.js` and `trial-users.js` of overriding IMS
// env at construction, but differs in intent: those callers have their
// own dedicated IMS clients (LLMO email, etc.) and MUST swap credentials
// entirely; we keep the default client and only override scope. That
// makes this hardcode a transitional bypass for missing Vault config,
// NOT the desired permanent shape.
// Constructs a custom-env ImsClient with the dedicated PREFLIGHT_IMS_*
// credentials provisioned in Vault for this S2S use case (SITES-46699).
// ASO team manages a dedicated IMS client (`aem_site_optimizer_preflight`)
// for this purpose, following the established spacecat
// one-IMS-client-per-purpose convention (see `email-service.js` /
// `trial-users.js` / cloud-manager-client / brand-client — each swaps
// IMS env at construction for their own dedicated identity).
//
// Reversibility: if `IMS_SCOPE=system` is provisioned in Vault for
// `aem-project-collab-service`, this hardcode can be reverted to the
// wrapper-wired `await context.imsClient.getServiceAccessTokenV3()` and
// the `imsClient` destructure + `isNonEmptyObject(imsClient)` constructor
// guard on `PreflightController` restored alongside (both removed in the
// same PR as this hardcode — see git history under `SITES-43236`). The
// revert also restores singleton token caching across warm-Lambda
// invocations, which this construction loses — accepted cost during the
// transition, since per-invocation mint sidesteps the not-expiry-aware
// cache concern from the prior review thread on PR #2611.
// Uses getServiceAccessToken (v2 authorization_code) against the
// IMSS-provisioned permanent authorization code (Service Token row on
// the client) — the synthetic service-account identity is encoded in
// the code itself, so no org_id is needed at mint time and no Service
// Principal Binding is required. The CGW-Flex edge gate validates the
// `client_id` claim, which is identical across v2 and v3 tokens for
// this client, so v2 satisfies the gate.
//
// Mint before the AsyncJob / Preflight DB writes so a transient IMS
// failure doesn't leave orphaned IN_PROGRESS records to clean up.
Expand All @@ -524,11 +516,21 @@ function PreflightController(ctx, log, env) {
// this a narrow availability cost in practice.
let imsServiceToken;
try {
const scopedImsClient = ImsClient.createFrom({
const preflightImsEnv = {
...env,
IMS_CLIENT_ID: env.PREFLIGHT_IMS_CLIENT_ID,
IMS_CLIENT_SECRET: env.PREFLIGHT_IMS_CLIENT_SECRET,
IMS_CLIENT_CODE: env.PREFLIGHT_IMS_CLIENT_CODE,
// IMS_SCOPE is unused by v2 getServiceAccessToken (scope is bound
// to the permanent code at IMSS registration time), but kept here
// for forward-compat if/when this client moves to v3.
IMS_SCOPE: env.PREFLIGHT_IMS_SCOPE,
};
const preflightImsClient = ImsClient.createFrom({
...context,
env: { ...context.env, IMS_SCOPE: 'system' },
env: preflightImsEnv,
});
const tokenPayload = await scopedImsClient.getServiceAccessTokenV3();
const tokenPayload = await preflightImsClient.getServiceAccessToken();
imsServiceToken = tokenPayload?.access_token;
// Post-condition: a successful mint must yield a non-empty access_token.
// Guards against an SDK shape change (e.g. `{ accessToken }` or `{}`)
Expand Down
56 changes: 36 additions & 20 deletions test/controllers/preflight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1133,22 +1133,24 @@ describe('Preflight Controller', () => {
mockDataAccess.Preflight.create = sandbox.stub().resolves(mockPreflight);
hasAccessStub = sandbox.stub().resolves(true);

// SITES-43236: createPreflight constructs a custom-env ImsClient with
// IMS_SCOPE='system' hardcoded at the mint call site (see preflight.js
// for rationale). Tests esmock `ImsClient.createFrom` to return the
// shared mockImsClient stub; tests control mint behavior by overriding
// getServiceAccessTokenV3 on that stub per case.
// SITES-43236 / SITES-46699: createPreflight constructs a custom-env
// ImsClient with the dedicated PREFLIGHT_IMS_* credentials at the mint
// call site (see preflight.js for rationale). Tests esmock
// `ImsClient.createFrom` to return the shared mockImsClient stub; tests
// control mint behavior by overriding getServiceAccessToken on that
// stub per case (v2 authorization_code service-token mint).
mockImsClient = {
getServiceAccessTokenV3: sandbox.stub().resolves({
getServiceAccessToken: sandbox.stub().resolves({
access_token: 'test-ims-service-token',
token_type: 'bearer',
expires_in: 3600,
}),
};
// Stub-ify ImsClient.createFrom so tests can assert that the source
// passes IMS_SCOPE='system' through to the factory (the load-bearing
// contract of the hardcode; if a future refactor accidentally drops
// the override, this stub's call args won't match and assertions fail).
// passes the dedicated PREFLIGHT_IMS_* credentials through to the
// factory (the load-bearing contract of SITES-46699; if a future
// refactor accidentally swaps the wrong env keys, this stub's call
// args won't match and assertions fail).
createFromStub = sandbox.stub().returns(mockImsClient);

// SITES-46202: createPreflight no longer consults Configuration / TierClient
Expand Down Expand Up @@ -1176,6 +1178,11 @@ describe('Preflight Controller', () => {
AUDIT_JOBS_QUEUE_URL: 'https://sqs.test.amazonaws.com/audit-queue',
MYSTIQUE_API_BASE_URL: 'https://mysticat.example.com',
AWS_ENV: 'prod',
// SITES-46699: dedicated IMS client credentials used by callMysticatAnalyze.
PREFLIGHT_IMS_CLIENT_ID: 'test-preflight-client-id',
PREFLIGHT_IMS_CLIENT_SECRET: 'test-preflight-client-secret',
PREFLIGHT_IMS_CLIENT_CODE: 'test-preflight-client-code',
PREFLIGHT_IMS_SCOPE: 'test-preflight-scope',
},
);
});
Expand Down Expand Up @@ -1600,15 +1607,24 @@ describe('Preflight Controller', () => {
attributes: { authInfo: mockAuthInfo },
});
expect(response.status).to.equal(202);
expect(mockImsClient.getServiceAccessTokenV3).to.have.been.calledOnce;
// Load-bearing contract of this PR: the custom-env ImsClient construction
// MUST pass IMS_SCOPE='system' through to ImsClient.createFrom — that's
// the hardcoded scope value that lets the v3 client_credentials grant
// succeed against `aem-project-collab-service` (per SITES-43236). If a
// future refactor accidentally drops the override, this assertion fails.
// Load-bearing contracts (SITES-46699):
// 1. Custom-env ImsClient construction passes the dedicated
// PREFLIGHT_IMS_* credentials through to ImsClient.createFrom.
// 2. Mint uses getServiceAccessToken (v2 authorization_code against
// the IMSS-provisioned permanent code — no org_id, no SP binding).
// If a future refactor swaps to v3 client_credentials or wires the
// wrong env keys, one of these assertions fails.
expect(createFromStub).to.have.been.calledWith(
sinon.match({ env: sinon.match({ IMS_SCOPE: 'system' }) }),
sinon.match({
env: sinon.match({
IMS_CLIENT_ID: 'test-preflight-client-id',
IMS_CLIENT_SECRET: 'test-preflight-client-secret',
IMS_CLIENT_CODE: 'test-preflight-client-code',
IMS_SCOPE: 'test-preflight-scope',
}),
}),
);
expect(mockImsClient.getServiceAccessToken).to.have.been.calledOnce;
const [, calledOptions] = fetchStub.secondCall.args;
// Bearer prefix mirrors the v3-token convention; verified against the
// mystique-deploy CGW-Flex validator (Authorization on /v1/apply uses
Expand Down Expand Up @@ -1672,7 +1688,7 @@ describe('Preflight Controller', () => {
});

it('returns 500 PREFLIGHT_INTERNAL_ERROR when IMS service-token mint fails', async () => {
mockImsClient.getServiceAccessTokenV3.rejects(new Error('IMS down'));
mockImsClient.getServiceAccessToken.rejects(new Error('IMS down'));
const response = await preflightController.createPreflight({
params: { siteId: 'test-site-123' },
data: { url: 'https://main--example-site.aem.page/test.html' },
Expand All @@ -1687,7 +1703,7 @@ describe('Preflight Controller', () => {
it('does not create AsyncJob or Preflight records when IMS mint fails', async () => {
// Mint happens before DB writes — a transient IMS failure must not
// leave orphaned IN_PROGRESS records that the caller can't reconcile.
mockImsClient.getServiceAccessTokenV3.rejects(new Error('IMS down'));
mockImsClient.getServiceAccessToken.rejects(new Error('IMS down'));
await preflightController.createPreflight({
params: { siteId: 'test-site-123' },
data: { url: 'https://main--example-site.aem.page/test.html' },
Expand All @@ -1698,7 +1714,7 @@ describe('Preflight Controller', () => {
});

it('does not call Mysticat when IMS mint fails', async () => {
mockImsClient.getServiceAccessTokenV3.rejects(new Error('IMS down'));
mockImsClient.getServiceAccessToken.rejects(new Error('IMS down'));
await preflightController.createPreflight({
params: { siteId: 'test-site-123' },
data: { url: 'https://main--example-site.aem.page/test.html' },
Expand All @@ -1717,7 +1733,7 @@ describe('Preflight Controller', () => {
// SDK shape drift guard (e.g. `{ accessToken: ... }` after a version bump
// or `{}` on partial responses) — must surface as an explicit 500, not
// silently drop the header.
mockImsClient.getServiceAccessTokenV3.resolves({});
mockImsClient.getServiceAccessToken.resolves({});
const response = await preflightController.createPreflight({
params: { siteId: 'test-site-123' },
data: { url: 'https://main--example-site.aem.page/test.html' },
Expand Down
Loading