diff --git a/docs/specs/2026-08-12-llmo-6930-api-service-promise-audience.md b/docs/specs/2026-08-12-llmo-6930-api-service-promise-audience.md new file mode 100644 index 0000000000..5c2ca2d1e4 --- /dev/null +++ b/docs/specs/2026-08-12-llmo-6930-api-service-promise-audience.md @@ -0,0 +1,170 @@ +# LLMO-6930 — audience-driven IMS promise pair in api-service + +| Field | Value | +|-------|-------| +| **Status** | Draft | +| **Author** | Char | +| **Created** | 2026-08-12 | +| **Updated** | 2026-08-12 | +| **Decided** | N/A | +| **Approvers** | N/A | +| **Jira** | LLMO-6930 (parent LLMO-6623) | + +## Summary + +Let a request choose the dedicated Semrush IMS promise-token pair, via an +optional `x-promise-audience: semrush` header, for both the synchronous +serenity/elements/brands surfaces and the asynchronous Path-B classify-prompts +job. Absent the header, everything uses today's default pair — no behavior +change. Consumes the `opts.pair` selector shipped in +`@adobe/spacecat-shared-ims-client@1.16.0` (LLMO-6928). + +This spec covers api-service only. The header is minted UI-side and accepted by +auth-service (LLMO-6929); the cross-repo design and rollout live in LLMO-6623. + +## Problem Statement + +### Current State + +Every promise-token mint and exchange in api-service uses the single default +pair, because `ImsPromiseClient.createFrom(context, type)` reads one fixed set +of env vars. Three selection sites exist: + +- **Sync serenity/elements/brands:** all funnel through + `resolveSemrushImsToken` (`src/support/utils.js:929`), which reads + `x-promise-token` and exchanges it via `exchangePromiseToken` (CONSUMER). +- **Path-B mint:** `createAndEnqueueJob` (`async-job-runner.js:94`) mints via + `getIMSPromiseToken` (EMITTER) and stores the token on job metadata. +- **Path-B exchange/invalidate:** `exchangeAndPersistPromiseToken` and + `invalidateJobPromiseToken` build a CONSUMER client from job metadata. + +None can select the Semrush pair. + +### Desired State + +- A request carrying `x-promise-audience: semrush` uses the Semrush pair end to + end (mint + exchange + invalidate); the exchanged token then carries the + `semrush` scope (proven at the IMS level for both stage and prod pairs). +- No header → default pair, byte-identical to today. +- An unknown audience value → 400 (fail closed), matching auth-service. +- The five stay-behind `getIMSPromiseToken` callers (`edge-routing-auth`, + `fixes`, `page-relationships`, `scrapeJob`, `suggestions`) are untouched and + stay on the default pair. + +## Goals and Non-Goals + +### Goals + +- One helper, `resolvePromisePair(context)`, that maps the header to a pair + selector (`'SEMRUSH'` | undefined) or throws 400. +- Thread an optional `pair` through `getIMSPromiseToken` / `exchangePromiseToken` + and into `resolveSemrushImsToken`, so all sync Semrush surfaces get audience + support with no per-call-site change. +- Persist the pair on Path-B job metadata at enqueue; read it back at + exchange/invalidate. Absent metadata → default pair (old queued jobs keep + working). +- Bump `@adobe/spacecat-shared-ims-client` 1.14.0 → 1.16.0; fix the stale + "pinned at 1.12.7" comment in `async-job-runner.js`. + +### Non-Goals + +- No UI change, no auth-service change (their own sub-tasks). +- No Vault change and NO flip of `SEMRUSH_PROJECTS_BASE_URL` (separate step, + after Path A+B proofs). +- The header path stays dormant until the UI sends it — this PR is inert in prod. + +## Proposed Solution + +### The audience → pair mapping (one place) + +`resolvePromisePair(context)` in `src/support/utils.js`, keyed on a new +`X_PROMISE_AUDIENCE_HEADER = 'x-promise-audience'` constant: + +- header absent/empty → `undefined` (default pair) +- `'semrush'` → `ImsPromiseClient.PROMISE_PAIR.SEMRUSH` +- any other value → throw `ErrorWithStatusCode(400)` + +### Sync path (no call-site changes) + +- `getIMSPromiseToken(context, pair)` and `exchangePromiseToken(context, token, pair)` + gain an optional trailing `pair`, forwarded as `createFrom(context, TYPE, { pair })`. + `pair === undefined` resolves the default pair (the ims-client selector treats + absent as default). +- `resolveSemrushImsToken` computes `pair = resolvePromisePair(context)` and + passes it to `exchangePromiseToken`. Every sync Semrush surface + (serenity ×~17, elements, brands, brand-provisioning, url-inspector) inherits + audience support unchanged, because they already call this helper. + +### Async path (Path B) + +- `createAndEnqueueJob(context, { jobType, metadata, promiseToken, promisePair })`: + `pair = promisePair ?? resolvePromisePair(context)`; mint via + `getIMSPromiseToken(context, pair)`; persist `metadata.promisePair = pair`. + The `promisePair` param mirrors the existing `promiseToken` param for the + worker self-requeue path (the worker has no request headers). +- `exchangeAndPersistPromiseToken` / `invalidateJobPromiseToken`: read + `job.getMetadata().promisePair` and pass it to `createFrom(CONSUMER, { pair })`. +- The worker self-requeue (`handlers/classify-prompts-job.js`) forwards the + processing job's `promisePair` into its `createAndEnqueueJob` call. + +## Alternatives Considered + +| Approach | Pros | Cons | Verdict | +|----------|------|------|---------| +| Read the header once in `resolveSemrushImsToken` + `resolvePromisePair` helper | ~25 call sites unchanged; one source of the mapping | none material | Selected | +| Add an `audience` argument to every serenity/elements/brands call site | explicit at each site | ~25 edits, easy to miss one → silent default-pair | Rejected | +| Persist the whole audience string on job metadata | flexible | leaks the header contract into stored data; pair enum is enough | Rejected | + +## Success Criteria + +### Functional Requirements + +- [ ] No header anywhere → identical mint/exchange/invalidate behavior to today. +- [ ] `x-promise-audience: semrush` → Semrush pair on the sync path (via + `resolveSemrushImsToken` → `exchangePromiseToken`). +- [ ] `x-promise-audience: semrush` → Semrush pair minted at enqueue, persisted, + and used at exchange + invalidate for Path B. +- [ ] Unknown audience value → 400. +- [ ] A queued job with no `promisePair` in metadata exchanges on the default + pair (backward compatible). +- [ ] The five stay-behind `getIMSPromiseToken` callers are unmodified. + +### Validation Plan + +- [ ] Unit: `test/support/utils.test.js` (resolvePromisePair cases; pair + pass-through on the two helpers; resolveSemrushImsToken audience) and + `test/support/serenity/async-job-runner.test.js` (pair persisted at + enqueue; read at exchange/invalidate; absent → default). Bar 90/90/90. +- [ ] `npm run type-check` — the `// @ts-check` typedef for `createFrom` in + `async-job-runner.js` must gain the optional `opts` arg. +- [ ] `npm run build` — bundle healthcheck (per repo CI gate). +- [ ] End-to-end proof of the Semrush pair is done in LLMO-6623's Path A/B runs, + not here. + +## Dependencies + +- `@adobe/spacecat-shared-ims-client@1.16.0` (LLMO-6928, published) — provides + `createFrom(context, type, { pair })` and `PROMISE_PAIR`. + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| A sync surface silently stays on the default pair | Low | Med | Selection lives in the shared `resolveSemrushImsToken`, not per-site | +| Old in-flight Path-B jobs break at exchange | Low | High | Missing `promisePair` → default pair; explicit test | +| `// @ts-check` breaks on the new arg | Med | Low | Update the local `TypedImsPromiseClient.createFrom` typedef | +| Unknown audience silently defaults | Low | Med | `resolvePromisePair` throws 400 on unknown | + +## References + +- Parent: LLMO-6623; ims-client selector: LLMO-6928 (published 1.16.0). +- `src/support/utils.js` (`resolveSemrushImsToken`, `getIMSPromiseToken`, `exchangePromiseToken`). +- `src/support/serenity/async-job-runner.js` (Path B). + +--- + +## Revision History + +| Date | Author | Changes | +|------|--------|---------| +| 2026-08-12 | Char | Initial draft | diff --git a/package-lock.json b/package-lock.json index 372e8980b5..8044f85ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@adobe/spacecat-shared-drs-client": "1.14.0", "@adobe/spacecat-shared-gpt-client": "1.7.1", "@adobe/spacecat-shared-http-utils": "1.35.0", - "@adobe/spacecat-shared-ims-client": "1.14.0", + "@adobe/spacecat-shared-ims-client": "1.16.0", "@adobe/spacecat-shared-launchdarkly-client": "1.3.1", "@adobe/spacecat-shared-project-engine-client": "1.18.1", "@adobe/spacecat-shared-rum-api-client": "2.44.1", @@ -5581,9 +5581,9 @@ } }, "node_modules/@adobe/spacecat-shared-ims-client": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-ims-client/-/spacecat-shared-ims-client-1.14.0.tgz", - "integrity": "sha512-jR5lwJTntZvd67QnDTdl5NMjMGePobi5Glw8gLW7iNZi6CbD/g+fQPanIjka6NVOYPNgW3nDzgf32QqCsr7Irw==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-ims-client/-/spacecat-shared-ims-client-1.16.0.tgz", + "integrity": "sha512-FXe5oUHnbAkaxp0esH5hwseO1fBqollGmizWPKsmodipT5iG6C81Era5tvskpX4u5wioxcXys6GGIBQnl1XDZQ==", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.3.0", diff --git a/package.json b/package.json index 06463a0a1f..4f1d4a091d 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@adobe/spacecat-shared-drs-client": "1.14.0", "@adobe/spacecat-shared-gpt-client": "1.7.1", "@adobe/spacecat-shared-http-utils": "1.35.0", - "@adobe/spacecat-shared-ims-client": "1.14.0", + "@adobe/spacecat-shared-ims-client": "1.16.0", "@adobe/spacecat-shared-launchdarkly-client": "1.3.1", "@adobe/spacecat-shared-project-engine-client": "1.18.1", "@adobe/spacecat-shared-rum-api-client": "2.44.1", diff --git a/src/index.js b/src/index.js index 217637c807..35094965d2 100644 --- a/src/index.js +++ b/src/index.js @@ -165,7 +165,7 @@ function localCORSWrapper(fn) { response.headers.set( 'Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key, x-ims-org-id, x-client-type, x-import-api-key, ' - + 'x-trigger-audits, x-requested-with, origin, accept, x-view-as-trial, x-product, x-promise-token', + + 'x-trigger-audits, x-requested-with, origin, accept, x-view-as-trial, x-product, x-promise-token, x-promise-audience', ); response.headers.set('Access-Control-Max-Age', '86400'); } @@ -228,7 +228,7 @@ async function run(request, context) { if (method === 'OPTIONS') { return noContent({ 'access-control-allow-methods': 'GET, HEAD, PATCH, POST, OPTIONS, DELETE', - 'access-control-allow-headers': 'x-api-key, authorization, origin, x-requested-with, content-type, accept, x-import-api-key, x-client-type, x-trigger-audits, x-view-as-trial, x-promise-token', + 'access-control-allow-headers': 'x-api-key, authorization, origin, x-requested-with, content-type, accept, x-import-api-key, x-client-type, x-trigger-audits, x-view-as-trial, x-promise-token, x-promise-audience', 'access-control-max-age': '86400', 'access-control-allow-origin': '*', }); diff --git a/src/support/serenity/async-job-runner.js b/src/support/serenity/async-job-runner.js index 58f9fdee5c..10639235bf 100644 --- a/src/support/serenity/async-job-runner.js +++ b/src/support/serenity/async-job-runner.js @@ -13,7 +13,7 @@ // @ts-check import * as imsClientPkg from '@adobe/spacecat-shared-ims-client'; -import { getIMSPromiseToken } from '../utils.js'; +import { getIMSPromiseToken, resolvePromisePair } from '../utils.js'; /** * Deferred user-context Semrush job runner (serenity-docs#186). @@ -32,7 +32,7 @@ import { getIMSPromiseToken } from '../utils.js'; // through a namespace import rather than widening anything shared. /** * @typedef {object} TypedImsPromiseClient - * @property {(context: object, type: string) => { + * @property {(context: object, type: string, opts?: { pair?: string }) => { * exchangeToken: (promiseToken: string, enableEncryption: boolean) => Promise<{ * access_token: string, * promise_token: string, @@ -57,8 +57,8 @@ export const NEEDS_REAUTH_ERROR_CODE = 'NEEDS_REAUTH'; * TODO: replace with `NeedsReauthError` from `@adobe/spacecat-shared-ims-client` once * adobe/spacecat-shared#1843 (PromiseTokenSession) is merged and published — that PR * adds the same typed error upstream, keyed on the real HTTP status rather than this - * message-parsing workaround. `@adobe/spacecat-shared-ims-client` is pinned at 1.12.7 - * today, which predates that change. + * message-parsing workaround. That PR is not yet published, so the message-parsing + * workaround stays. */ export class NeedsReauthError extends Error { constructor(message, cause) { @@ -88,15 +88,29 @@ const REAUTH_STATUS_PATTERN = /status: (401|403)\b/; * HTTP request context (`getIMSPromiseToken` reads the caller's `Authorization` * header, which does not exist there) — the worker instead forwards the token it * already exchanged for the job it is currently processing. + * @param {string} [params.promisePair] - IMS promise-pair selector to mint with and + * persist on the job (see `resolvePromisePair`). Pass this on the worker self-requeue + * path (the worker has no request headers); from a request it defaults to the + * `x-promise-audience` header. Persisted so the worker exchanges/invalidates on the + * same pair. * @returns {Promise} The created job (an AsyncJob instance). * @throws On SQS send failure, after rolling back the created job record. */ -export async function createAndEnqueueJob(context, { jobType, metadata = {}, promiseToken }) { +export async function createAndEnqueueJob( + context, + { + jobType, metadata = {}, promiseToken, promisePair, + }, +) { const { dataAccess, sqs, env, log, } = context; - const promiseTokenResponse = promiseToken ?? await getIMSPromiseToken(context); + // When a pre-minted token is supplied (worker self-requeue), the pair must come + // from the explicit promisePair only — never re-derived from the request context, + // which could diverge from the pair that actually minted that token. + const pair = promisePair ?? (promiseToken ? undefined : resolvePromisePair(context)); + const promiseTokenResponse = promiseToken ?? await getIMSPromiseToken(context, pair); const job = await dataAccess.AsyncJob.create({ status: 'IN_PROGRESS', @@ -104,6 +118,7 @@ export async function createAndEnqueueJob(context, { jobType, metadata = {}, pro ...metadata, jobType, promiseToken: promiseTokenResponse, + promisePair: pair, }, }); @@ -146,13 +161,14 @@ export async function createAndEnqueueJob(context, { jobType, metadata = {}, pro */ export async function exchangeAndPersistPromiseToken(context, job) { const metadata = job.getMetadata() ?? {}; - const { promiseToken } = metadata; + const { promiseToken, promisePair } = metadata; const enableEncryption = !!context.env?.AUTOFIX_CRYPT_SECRET && !!context.env?.AUTOFIX_CRYPT_SALT; const consumerClient = ImsPromiseClient.createFrom( context, ImsPromiseClient.CLIENT_TYPE.CONSUMER, + { pair: promisePair }, ); let exchangeResult; @@ -191,7 +207,7 @@ export async function exchangeAndPersistPromiseToken(context, job) { * @param {object} job - An AsyncJob instance. */ export async function invalidateJobPromiseToken(context, job) { - const { promiseToken } = job.getMetadata() ?? {}; + const { promiseToken, promisePair } = job.getMetadata() ?? {}; if (!promiseToken?.promise_token) { return; } @@ -201,6 +217,7 @@ export async function invalidateJobPromiseToken(context, job) { const consumerClient = ImsPromiseClient.createFrom( context, ImsPromiseClient.CLIENT_TYPE.CONSUMER, + { pair: promisePair }, ); try { diff --git a/src/support/serenity/handlers/classify-prompts-job.js b/src/support/serenity/handlers/classify-prompts-job.js index ac667d14cb..4abb184d79 100644 --- a/src/support/serenity/handlers/classify-prompts-job.js +++ b/src/support/serenity/handlers/classify-prompts-job.js @@ -117,6 +117,7 @@ async function requeuePending(context, job, semrushWorkspaceId, items) { const newJob = await createAndEnqueueJob(context, { jobType: CLASSIFY_PROMPTS_JOB_TYPE, promiseToken: currentMetadata.promiseToken, + promisePair: currentMetadata.promisePair, metadata: { mode: 'reclassify', semrushWorkspaceId, items, requeueDepth: currentDepth + 1, }, diff --git a/src/support/utils.js b/src/support/utils.js index 4271722b2d..49179eab5d 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -13,6 +13,7 @@ import { Site as SiteModel } from '@adobe/spacecat-shared-data-access'; import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js'; import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js'; import { ImsPromiseClient } from '@adobe/spacecat-shared-ims-client'; +import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; import URI from 'urijs'; import { hasText, @@ -37,6 +38,7 @@ import { STATUS_BAD_REQUEST, STATUS_UNAUTHORIZED, X_PROMISE_TOKEN_HEADER, + X_PROMISE_AUDIENCE_HEADER, PROMISE_TOKEN_REQUIRED_ERROR_CODE, } from '../utils/constants.js'; import { updateRumConfig } from './rum-config-service.js'; @@ -811,9 +813,40 @@ export function getImsUserTokenStrict(context) { return getImsUserToken(context); } +/** + * Resolves which IMS promise-token pair a request selects, from the optional + * `x-promise-audience` header. Absent/empty => `undefined` (the default pair, + * unchanged behavior); `semrush` => the dedicated Semrush pair. Any other value + * throws 400 (fail closed) rather than silently minting/exchanging on the wrong + * pair. The pair selector is forwarded to `ImsPromiseClient.createFrom`. + * @param {object} context - The request context. + * @returns {string|undefined} The ImsPromiseClient pair selector, or undefined. + * @throws {ErrorWithStatusCode} 400 when the header carries an unknown audience. + */ +export function resolvePromisePair(context) { + const raw = context?.pathInfo?.headers?.[X_PROMISE_AUDIENCE_HEADER]; + if (!hasText(raw)) { + return undefined; + } + const audience = raw.trim(); + if (audience.toLowerCase() === 'semrush') { + return ImsPromiseClient.PROMISE_PAIR.SEMRUSH; + } + // Reflect the rejected value back, but sanitize (strip CR/LF and non-ASCII, per + // the repo's header-hygiene convention) and bound its length so a hostile header + // cannot inject into or bloat the 400 body / logs. + throw new ErrorWithStatusCode( + `Unknown promise audience: ${cleanupHeaderValue(audience).slice(0, 40)}`, + STATUS_BAD_REQUEST, + ); +} + /** * Get an IMS promise token from the authorization header in context. * @param {object} context - The context of the request. + * @param {string} [pair] - Optional IMS promise-pair selector (see + * {@link resolvePromisePair}). Selects which emitter client-id/secret/definition + * set mints the token. Omitted => the default pair. * @returns {Promise<{ * promise_token: string, * expires_in: number, @@ -821,7 +854,7 @@ export function getImsUserTokenStrict(context) { * }>} - The promise token response. * @throws {ErrorWithStatusCode} - If the Authorization header is missing. */ -export async function getIMSPromiseToken(context) { +export async function getIMSPromiseToken(context, pair) { // get IMS promise token and attach to queue message let userToken; try { @@ -832,6 +865,7 @@ export async function getIMSPromiseToken(context) { const imsPromiseClient = ImsPromiseClient.createFrom( context, ImsPromiseClient.CLIENT_TYPE.EMITTER, + { pair }, ); return imsPromiseClient.getPromiseToken( @@ -844,10 +878,13 @@ export async function getIMSPromiseToken(context) { * Exchange a promise token for an IMS access token. * @param {object} context - The context of the request. * @param {string} promiseToken - The promise token to exchange (e.g. from request payload). + * @param {string} [pair] - Optional IMS promise-pair selector (see + * {@link resolvePromisePair}). Omitted => the default pair. MUST match the pair + * that minted the token, or IMS rejects the exchange. * @returns {Promise<{ access_token: string }>} The access token response. * @throws {ErrorWithStatusCode} - If the promise token is missing. */ -export async function exchangePromiseToken(context, promiseToken) { +export async function exchangePromiseToken(context, promiseToken, pair) { if (!promiseToken) { throw new ErrorWithStatusCode('Missing promise token', STATUS_BAD_REQUEST); } @@ -855,6 +892,7 @@ export async function exchangePromiseToken(context, promiseToken) { const imsClient = ImsPromiseClient.createFrom( context, ImsPromiseClient.CLIENT_TYPE.CONSUMER, + { pair }, ); const accessToken = (await imsClient.exchangeToken( @@ -924,7 +962,8 @@ export function resolveCallerImsUserId(context) { * @param {(context: object) => string} [fallback] - Called when no promise token is * present; defaults to `getImsUserTokenStrict`. * @returns {Promise} The IMS access token to forward upstream. - * @throws {ErrorWithStatusCode} 401 when neither a promise token nor the fallback works. + * @throws {ErrorWithStatusCode} 401 when neither a promise token nor the fallback works; + * 400 when `x-promise-audience` carries an unknown value (see {@link resolvePromisePair}). */ export async function resolveSemrushImsToken( context, @@ -932,6 +971,7 @@ export async function resolveSemrushImsToken( logLabel = 'utils', fallback = getImsUserTokenStrict, ) { + const pair = resolvePromisePair(context); const promiseTokenHeader = context?.pathInfo?.headers?.[X_PROMISE_TOKEN_HEADER]; if (hasText(promiseTokenHeader)) { let decoded = promiseTokenHeader; @@ -941,7 +981,7 @@ export async function resolveSemrushImsToken( // Bearer-style tokens may contain literal %; use as-is. } try { - return await exchangePromiseToken(context, decoded); + return await exchangePromiseToken(context, decoded, pair); } catch (e) { log?.error(`${logLabel}: promise token exchange failed`, { error: e?.message }); throw new ErrorWithStatusCode('Invalid or expired promise token', STATUS_UNAUTHORIZED); diff --git a/src/utils/constants.js b/src/utils/constants.js index 0691c48007..905f471a24 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -28,6 +28,13 @@ export const STATUS_INTERNAL_SERVER_ERROR = 500; export const X_PROMISE_TOKEN_HEADER = 'x-promise-token'; +/** + * Optional header selecting which IMS promise-token pair mints/exchanges the + * request's token. Absent => the default pair. `semrush` => the dedicated + * Semrush-scoped pair. Any other value is rejected (see resolvePromisePair). + */ +export const X_PROMISE_AUDIENCE_HEADER = 'x-promise-audience'; + export const MISSING_X_PROMISE_TOKEN_MESSAGE = `Invalid request: missing required header: ${X_PROMISE_TOKEN_HEADER}`; /** Error code for a non-IMS caller hitting an IMS-bearer gate with no x-promise-token. */ diff --git a/test/index.test.js b/test/index.test.js index 2a4129e56d..20fca4ab0a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -220,7 +220,7 @@ describe('Index Tests', () => { expect(resp.status).to.equal(204); expect(resp.headers.plain()).to.eql({ 'access-control-allow-methods': 'GET, HEAD, PATCH, POST, OPTIONS, DELETE', - 'access-control-allow-headers': 'x-api-key, authorization, origin, x-requested-with, content-type, accept, x-import-api-key, x-client-type, x-trigger-audits, x-view-as-trial, x-promise-token', + 'access-control-allow-headers': 'x-api-key, authorization, origin, x-requested-with, content-type, accept, x-import-api-key, x-client-type, x-trigger-audits, x-view-as-trial, x-promise-token, x-promise-audience', 'access-control-max-age': '86400', 'access-control-allow-origin': '*', 'content-type': 'application/json; charset=utf-8', diff --git a/test/support/serenity/async-job-runner.test.js b/test/support/serenity/async-job-runner.test.js index 0be72a501d..9101c0deba 100644 --- a/test/support/serenity/async-job-runner.test.js +++ b/test/support/serenity/async-job-runner.test.js @@ -41,6 +41,8 @@ describe('async-job-runner', () => { let exchangeTokenStub; let invalidatePromiseTokenStub; let getPromiseTokenStub; + let resolvePromisePairStub; + let createFromStub; let createAndEnqueueJob; let exchangeAndPersistPromiseToken; let invalidateJobPromiseToken; @@ -51,6 +53,11 @@ describe('async-job-runner', () => { exchangeTokenStub = sandbox.stub(); invalidatePromiseTokenStub = sandbox.stub().resolves(); getPromiseTokenStub = sandbox.stub(); + resolvePromisePairStub = sandbox.stub().returns(undefined); + createFromStub = sandbox.stub().returns({ + exchangeToken: exchangeTokenStub, + invalidatePromiseToken: invalidatePromiseTokenStub, + }); ({ createAndEnqueueJob, @@ -60,15 +67,13 @@ describe('async-job-runner', () => { } = await esmock('../../../src/support/serenity/async-job-runner.js', { '@adobe/spacecat-shared-ims-client': { ImsPromiseClient: { - createFrom: () => ({ - exchangeToken: exchangeTokenStub, - invalidatePromiseToken: invalidatePromiseTokenStub, - }), + createFrom: createFromStub, CLIENT_TYPE: { CONSUMER: 'consumer', EMITTER: 'emitter' }, }, }, '../../../src/support/utils.js': { getIMSPromiseToken: getPromiseTokenStub, + resolvePromisePair: resolvePromisePairStub, }, })); }); @@ -100,6 +105,7 @@ describe('async-job-runner', () => { foo: 'bar', jobType: 'serenity-classify-prompts', promiseToken: { promise_token: 'ptok', expires_in: 14399 }, + promisePair: undefined, }, }); expect(sendMessageStub).to.have.been.calledWith('queue-url', { @@ -125,16 +131,59 @@ describe('async-job-runner', () => { }); expect(getPromiseTokenStub).to.not.have.been.called; + // A pre-minted token must not re-derive the pair from the request header. + expect(resolvePromisePairStub).to.not.have.been.called; expect(createStub).to.have.been.calledWith({ status: 'IN_PROGRESS', metadata: { mode: 'reclassify', jobType: 'serenity-classify-prompts', promiseToken: { promise_token: 'forwarded-ptok' }, + promisePair: undefined, }, }); }); + it('mints with the audience pair from the request and persists it on metadata', async () => { + resolvePromisePairStub.returns('SEMRUSH'); + getPromiseTokenStub.resolves({ promise_token: 'ptok' }); + const job = makeJob(); + const createStub = sandbox.stub().resolves(job); + const context = { + dataAccess: { AsyncJob: { create: createStub } }, + sqs: { sendMessage: sandbox.stub().resolves() }, + env: { SERENITY_JOB_RUNNER_QUEUE_URL: 'queue-url' }, + log: { error: sandbox.stub(), warn: sandbox.stub() }, + }; + + await createAndEnqueueJob(context, { jobType: 'serenity-classify-prompts' }); + + expect(getPromiseTokenStub).to.have.been.calledWith(context, 'SEMRUSH'); + const created = createStub.firstCall.args[0]; + expect(created.metadata.promisePair).to.equal('SEMRUSH'); + }); + + it('prefers an explicit promisePair over the request header (worker self-requeue)', async () => { + getPromiseTokenStub.resolves({ promise_token: 'ptok' }); + const job = makeJob(); + const createStub = sandbox.stub().resolves(job); + const context = { + dataAccess: { AsyncJob: { create: createStub } }, + sqs: { sendMessage: sandbox.stub().resolves() }, + env: { SERENITY_JOB_RUNNER_QUEUE_URL: 'queue-url' }, + log: { error: sandbox.stub(), warn: sandbox.stub() }, + }; + + await createAndEnqueueJob(context, { + jobType: 'serenity-classify-prompts', + promisePair: 'SEMRUSH', + }); + + expect(resolvePromisePairStub).to.not.have.been.called; + expect(getPromiseTokenStub).to.have.been.calledWith(context, 'SEMRUSH'); + expect(createStub.firstCall.args[0].metadata.promisePair).to.equal('SEMRUSH'); + }); + it('rolls back the created job when the SQS send fails', async () => { getPromiseTokenStub.resolves({ promise_token: 'ptok' }); const job = makeJob(); @@ -210,6 +259,38 @@ describe('async-job-runner', () => { await expect(exchangeAndPersistPromiseToken(context, job)) .to.be.rejectedWith('network error'); }); + + it('exchanges on the pair stored in job metadata and keeps it on the record', async () => { + const job = makeJob({ + promiseToken: { promise_token: 'old-ptok', token_type: 'bearer' }, + promisePair: 'SEMRUSH', + }); + exchangeTokenStub.resolves({ + access_token: 'access-abc', + promise_token: 'new-ptok', + promise_token_expires_in: 14399, + }); + + await exchangeAndPersistPromiseToken({ env: {} }, job); + + const [, type, opts] = createFromStub.firstCall.args; + expect(type).to.equal('consumer'); + expect(opts).to.deep.equal({ pair: 'SEMRUSH' }); + expect(job.getMetadata().promisePair).to.equal('SEMRUSH'); + }); + + it('exchanges on the default pair when metadata carries no promisePair', async () => { + const job = makeJob({ promiseToken: { promise_token: 'old-ptok' } }); + exchangeTokenStub.resolves({ + access_token: 'access-abc', + promise_token: 'new-ptok', + promise_token_expires_in: 1, + }); + + await exchangeAndPersistPromiseToken({ env: {} }, job); + + expect(createFromStub.firstCall.args[2]).to.deep.equal({ pair: undefined }); + }); }); describe('invalidateJobPromiseToken', () => { @@ -252,5 +333,16 @@ describe('async-job-runner', () => { await expect(invalidateJobPromiseToken(context, job)).to.be.fulfilled; expect(warnStub).to.have.been.called; }); + + it('invalidates on the pair stored in job metadata', async () => { + const job = makeJob({ promiseToken: { promise_token: 'ptok' }, promisePair: 'SEMRUSH' }); + const context = { env: {}, log: { warn: sandbox.stub() } }; + + await invalidateJobPromiseToken(context, job); + + const [, type, opts] = createFromStub.firstCall.args; + expect(type).to.equal('consumer'); + expect(opts).to.deep.equal({ pair: 'SEMRUSH' }); + }); }); }); diff --git a/test/support/utils.test.js b/test/support/utils.test.js index 814d0b48a3..5b39402751 100644 --- a/test/support/utils.test.js +++ b/test/support/utils.test.js @@ -1931,6 +1931,127 @@ describe('utils', () => { }); }); + describe('resolvePromisePair', () => { + let resolvePromisePair; + + beforeEach(async () => { + ({ resolvePromisePair } = await esmock('../../src/support/utils.js', { + '@adobe/spacecat-shared-ims-client': { + ImsPromiseClient: { + PROMISE_PAIR: { SEMRUSH: 'SEMRUSH' }, + CLIENT_TYPE: { CONSUMER: 'consumer', EMITTER: 'emitter' }, + }, + }, + })); + }); + + const ctx = (audience) => ({ + pathInfo: { headers: audience ? { 'x-promise-audience': audience } : {} }, + }); + + it('returns undefined when the audience header is absent', () => { + expect(resolvePromisePair(ctx())).to.equal(undefined); + }); + + it('returns undefined when the audience header is empty', () => { + expect(resolvePromisePair(ctx(''))).to.equal(undefined); + }); + + it('returns the SEMRUSH pair for x-promise-audience: semrush', () => { + expect(resolvePromisePair(ctx('semrush'))).to.equal('SEMRUSH'); + }); + + it('is case-insensitive on the audience value', () => { + expect(resolvePromisePair(ctx('SemRush'))).to.equal('SEMRUSH'); + }); + + it('trims surrounding whitespace before matching', () => { + expect(resolvePromisePair(ctx(' semrush '))).to.equal('SEMRUSH'); + }); + + it('throws 400 on an unknown audience', () => { + let err; + try { + resolvePromisePair(ctx('bogus')); + } catch (e) { + err = e; + } + expect(err).to.exist; + expect(err.message).to.contain('Unknown promise audience: bogus'); + expect(err.status).to.equal(400); + }); + + it('sanitizes CR/LF from the reflected value in the 400 message', () => { + let err; + try { + resolvePromisePair(ctx('bad\r\ninjected')); + } catch (e) { + err = e; + } + expect(err.status).to.equal(400); + expect(err.message).to.not.contain('\n'); + expect(err.message).to.not.contain('\r'); + }); + }); + + describe('resolveSemrushImsToken audience selection', () => { + let resolveSemrushImsToken; + let createFromStub; + let exchangeTokenStub; + + beforeEach(async () => { + exchangeTokenStub = sinon.stub().resolves({ access_token: 'exchanged' }); + createFromStub = sinon.stub().returns({ exchangeToken: exchangeTokenStub }); + ({ resolveSemrushImsToken } = await esmock('../../src/support/utils.js', { + '@adobe/spacecat-shared-ims-client': { + ImsPromiseClient: { + createFrom: createFromStub, + PROMISE_PAIR: { SEMRUSH: 'SEMRUSH' }, + CLIENT_TYPE: { CONSUMER: 'consumer', EMITTER: 'emitter' }, + }, + }, + })); + }); + + const ctx = (headers) => ({ pathInfo: { headers } }); + + it('selects the SEMRUSH consumer pair when x-promise-audience: semrush accompanies the token', async () => { + await resolveSemrushImsToken( + ctx({ 'x-promise-token': 'pt', 'x-promise-audience': 'semrush' }), + { error: sinon.stub() }, + 'label', + ); + const [, type, opts] = createFromStub.firstCall.args; + expect(type).to.equal('consumer'); + expect(opts).to.deep.equal({ pair: 'SEMRUSH' }); + }); + + it('uses the default pair (undefined) when no audience header is present', async () => { + await resolveSemrushImsToken( + ctx({ 'x-promise-token': 'pt' }), + { error: sinon.stub() }, + 'label', + ); + const [, , opts] = createFromStub.firstCall.args; + expect(opts).to.deep.equal({ pair: undefined }); + }); + + it('throws 400 and never exchanges when the audience is unknown', async () => { + let err; + try { + await resolveSemrushImsToken( + ctx({ 'x-promise-token': 'pt', 'x-promise-audience': 'bogus' }), + { error: sinon.stub() }, + 'label', + ); + } catch (e) { + err = e; + } + expect(err?.status).to.equal(400); + expect(createFromStub).to.not.have.been.called; + }); + }); + describe('sendGlobalImportRunMessage', () => { it('sends a message without a siteId when none is provided', async () => { const sqs = { sendMessage: sinon.stub().resolves() };