From f53800b9494d7a7099e60da1a310c113275faafa Mon Sep 17 00:00:00 2001 From: alinarublea Date: Wed, 15 Jul 2026 18:34:28 +0200 Subject: [PATCH 1/3] feat(onboarding): verify Cloud Manager program via connector (SITES-47815) During onboarding, api-service derives programId/environmentId purely from a regex on the AEM CS preview hostname (extractDeliveryConfigFromPreviewUrl), never checked against Cloud Manager. CM's private API is not directly reachable from api-service's own network, so this adds a client that invokes a dedicated connector Lambda and best-effort verifies the program. - src/support/cloud-manager.js: CloudManagerClient (sync Lambda.invoke of the connector) + createCloudManagerClient(context). Methods degrade gracefully (never throw): when CM_CONNECTOR_FUNCTION_NAME is unset or the invoke fails, verifyProgram returns { verified:false, degraded:true } and onboarding proceeds on the regex-derived values. - src/support/utils.js: queueDeliveryConfigWriter (post-ack, off the Slack 3s window) verifies the program and stamps additive `programVerified` telemetry onto the delivery-config-writer SQS payload. - test: 13 unit tests for the client. Requires the connector Lambda + its invoke permission and CM_CONNECTOR_FUNCTION_NAME in the deploy env; until then it is a no-op. Draft. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/cloud-manager.js | 137 +++++++++++++++++++++++++++++ src/support/utils.js | 18 ++++ test/support/cloud-manager.test.js | 122 +++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 src/support/cloud-manager.js create mode 100644 test/support/cloud-manager.test.js diff --git a/src/support/cloud-manager.js b/src/support/cloud-manager.js new file mode 100644 index 0000000000..93947f999a --- /dev/null +++ b/src/support/cloud-manager.js @@ -0,0 +1,137 @@ +/* + * 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 { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; + +/** + * Client for the Cloud Manager (CM) connector Lambda. + * + * api-service cannot reach Cloud Manager's private API directly from its own + * network; a dedicated connector service reaches it (SITES-47815) and this + * client synchronously invokes that connector Lambda. + * + * It is invoked from the post-ack onboarding path only (queueDeliveryConfigWriter), + * never before the Slack 3s ack — the connector retries a lossy network path + * and can take a few seconds. + * + * All methods degrade gracefully (never throw to the caller) so onboarding is + * never blocked by CM: when the connector isn't configured/deployed/authorized, + * they return `{ verified: false, degraded: true }` and log a warning. + * + * @class + */ +export class CloudManagerClient { + /** + * @param {object} opts + * @param {string} [opts.region] - AWS region (from context.runtime.region). + * @param {string} [opts.functionName] - CM connector Lambda name + * (env.CM_CONNECTOR_FUNCTION_NAME). + * @param {object} [opts.log] - logger. + */ + constructor({ region, functionName, log } = {}) { + this.functionName = functionName; + this.log = log; + this.client = functionName ? new LambdaClient(region ? { region } : {}) : null; + } + + /** @returns {boolean} whether the connector is configured and can be invoked. */ + get enabled() { + return Boolean(this.functionName && this.client); + } + + /** + * Invoke the connector with an action payload and return its parsed JSON result. + * @param {object} payload - e.g. { action: 'get_program', programId }. + * @returns {Promise} the connector's response body. + * @throws if the connector is not configured or the invocation errors. + */ + async invokeAction(payload) { + if (!this.enabled) { + throw new Error('CloudManager connector is not configured (CM_CONNECTOR_FUNCTION_NAME)'); + } + const res = await this.client.send(new InvokeCommand({ + FunctionName: this.functionName, + InvocationType: 'RequestResponse', + Payload: Buffer.from(JSON.stringify(payload)), + })); + const text = res.Payload ? Buffer.from(res.Payload).toString('utf8') : ''; + if (res.FunctionError) { + throw new Error(`CloudManager connector error (${res.FunctionError}): ${text.slice(0, 300)}`); + } + try { + return text ? JSON.parse(text) : {}; + } catch { + throw new Error(`CloudManager connector returned non-JSON: ${text.slice(0, 300)}`); + } + } + + /** + * GET /api/program/{programId}. + * @param {string} programId + * @returns {Promise} connector response { ok, statusCode, data }. + */ + async getProgram(programId) { + return this.invokeAction({ action: 'get_program', programId }); + } + + /** + * GET /api/program/{programId}/environments. + * @param {string} programId + * @returns {Promise} connector response { ok, statusCode, data }. + */ + async listEnvironments(programId) { + return this.invokeAction({ action: 'list_environments', programId }); + } + + /** + * Best-effort verification that a (host-regex-derived) programId actually + * exists in Cloud Manager. Never throws — degrades so onboarding continues. + * @param {string} programId + * @returns {Promise<{verified: boolean, degraded?: boolean, statusCode?: number, + * program?: object, error?: string}>} + */ + async verifyProgram(programId) { + if (!this.enabled) { + this.log?.info('[cloud-manager] connector not configured; skipping program verification'); + return { verified: false, degraded: true }; + } + if (!programId) { + return { verified: false, degraded: true }; + } + try { + const r = await this.getProgram(programId); + if (!r?.ok) { + this.log?.warn(`[cloud-manager] program ${programId} not verified (status ${r?.statusCode})`); + return { verified: false, statusCode: r?.statusCode, program: r?.data }; + } + return { + verified: true, statusCode: r.statusCode, program: r.data, + }; + } catch (e) { + this.log?.warn(`[cloud-manager] verifyProgram(${programId}) failed, degrading: ${e.message}`); + return { verified: false, degraded: true, error: e.message }; + } + } +} + +/** + * Build a CloudManagerClient from a Lambda context (env + runtime + log). + * @param {object} context + * @returns {CloudManagerClient} + */ +export function createCloudManagerClient(context = {}) { + const { env = {}, runtime = {}, log } = context; + return new CloudManagerClient({ + region: runtime.region || env.AWS_REGION, + functionName: env.CM_CONNECTOR_FUNCTION_NAME, + log, + }); +} diff --git a/src/support/utils.js b/src/support/utils.js index bb7b86dfb0..f64d24a151 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -40,6 +40,7 @@ import { PROMISE_TOKEN_REQUIRED_ERROR_CODE, } from '../utils/constants.js'; import { updateRumConfig } from './rum-config-service.js'; +import { createCloudManagerClient } from './cloud-manager.js'; // Two signals indicate a previous paid onboarding: // 1. ahref-paid-pages import — unique to the paid profile's import set. // 2. onboardConfig.lastProfile === 'paid' — set for sites backfilled via script or onboarded @@ -1507,6 +1508,23 @@ export async function queueDeliveryConfigWriter( minutes, updateRedirects, }; + + // SITES-47815: the programId/environmentId above are derived by regex on + // the AEM CS hostname (extractDeliveryConfigFromPreviewUrl), never checked + // against Cloud Manager. Best-effort verify the program actually exists in + // CM via the connector Lambda (the service that can reach CM's private + // API). This runs post-ack, so its latency is off the Slack 3s window, and + // it degrades gracefully: when the connector is not configured/deployed/ + // authorized it is a no-op and onboarding proceeds on the regex-derived + // values. `programVerified` is additive telemetry for the audit-worker. + const cloudManager = createCloudManagerClient(context); + const { verified, degraded } = await cloudManager.verifyProgram(String(programId)); + redirectParams.programVerified = verified; + if (!verified && !degraded) { + log.warn( + `[delivery-config-writer] Cloud Manager did not verify program ${programId} for ${resolvedBaseURL}; proceeding on regex-derived delivery config.`, + ); + } } else { log.info( `[delivery-config-writer] ${skipMessage}; CDN detection only.`, diff --git a/test/support/cloud-manager.test.js b/test/support/cloud-manager.test.js new file mode 100644 index 0000000000..45e17bf7c0 --- /dev/null +++ b/test/support/cloud-manager.test.js @@ -0,0 +1,122 @@ +/* + * 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 } from 'chai'; +import sinon from 'sinon'; +import { CloudManagerClient, createCloudManagerClient } from '../../src/support/cloud-manager.js'; + +const payload = (obj) => ({ Payload: Buffer.from(JSON.stringify(obj)) }); +const log = { info: () => {}, warn: () => {}, error: () => {} }; + +describe('CloudManagerClient', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + + describe('enabled', () => { + it('is false without a function name', () => { + expect(new CloudManagerClient({ log }).enabled).to.equal(false); + }); + it('is true with a function name', () => { + expect(new CloudManagerClient({ functionName: 'fn', log }).enabled).to.equal(true); + }); + }); + + describe('invokeAction', () => { + it('sends an InvokeCommand and parses the JSON payload', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200 })); + const res = await c.invokeAction({ action: 'get_program', programId: 'p1' }); + expect(res).to.deep.equal({ ok: true, statusCode: 200 }); + const sent = send.firstCall.args[0].input; + expect(sent.FunctionName).to.equal('fn'); + expect(sent.InvocationType).to.equal('RequestResponse'); + expect(JSON.parse(Buffer.from(sent.Payload).toString())).to.deep.equal({ action: 'get_program', programId: 'p1' }); + }); + + it('throws when not configured', async () => { + const c = new CloudManagerClient({ log }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/not configured/); + }); + + it('throws on a Lambda FunctionError', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves({ FunctionError: 'Unhandled', Payload: Buffer.from('{"errorMessage":"boom"}') }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/connector error \(Unhandled\)/); + }); + + it('throws on a non-JSON payload', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves({ Payload: Buffer.from('not json') }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/non-JSON/); + }); + }); + + describe('verifyProgram (graceful degradation)', () => { + it('degrades (no throw) when the connector is not configured', async () => { + const res = await new CloudManagerClient({ log }).verifyProgram('p1'); + expect(res).to.deep.equal({ verified: false, degraded: true }); + }); + + it('degrades when no programId is supplied', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send'); + const res = await c.verifyProgram(undefined); + expect(res.degraded).to.equal(true); + expect(send.called).to.equal(false); + }); + + it('returns verified:true when CM confirms the program', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200, data: { id: 'p1' } })); + const res = await c.verifyProgram('p1'); + expect(res.verified).to.equal(true); + expect(res.program).to.deep.equal({ id: 'p1' }); + }); + + it('returns verified:false (not degraded) when CM responds not-ok', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves(payload({ ok: false, statusCode: 404 })); + const res = await c.verifyProgram('p1'); + expect(res).to.include({ verified: false, statusCode: 404 }); + expect(res.degraded).to.equal(undefined); + }); + + it('degrades when the invocation throws (e.g. AccessDenied before IAM grant)', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').rejects(new Error('AccessDeniedException')); + const res = await c.verifyProgram('p1'); + expect(res).to.include({ verified: false, degraded: true }); + expect(res.error).to.match(/AccessDenied/); + }); + }); + + describe('createCloudManagerClient', () => { + it('reads function name from env and region from runtime', () => { + const c = createCloudManagerClient({ + env: { CM_CONNECTOR_FUNCTION_NAME: 'spacecat-services--cm-connector' }, + runtime: { region: 'us-east-1' }, + log, + }); + expect(c.enabled).to.equal(true); + expect(c.functionName).to.equal('spacecat-services--cm-connector'); + }); + + it('is disabled when the env var is absent', () => { + expect(createCloudManagerClient({ env: {}, log }).enabled).to.equal(false); + }); + }); +}); From 01de708090222a68e4ea32cf3aad2d374845f24e Mon Sep 17 00:00:00 2001 From: alinarublea Date: Thu, 16 Jul 2026 12:07:07 +0200 Subject: [PATCH 2/3] test: cover Cloud Manager verification branches (SITES-47815) Add tests for the verifyProgram branches in queueDeliveryConfigWriter (verified / not-verified+warn / degraded) via an esmock'd connector client, fixing the codecov/patch gap on the new code. --- test/support/utils.test.js | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/support/utils.test.js b/test/support/utils.test.js index f7c34afcdd..7e1a64c954 100644 --- a/test/support/utils.test.js +++ b/test/support/utils.test.js @@ -1431,6 +1431,54 @@ describe('utils', () => { }); }); + describe('queueDeliveryConfigWriter — Cloud Manager verification (SITES-47815)', () => { + const makeSite = () => ({ + getId: () => 'site-1', + getBaseURL: () => 'https://example.com', + getAuthoringType: () => 'cs', + getDeliveryType: () => 'aem_cs', + getDeliveryConfig: () => ({ programId: 'p1', environmentId: 'e1' }), + }); + + const makeContext = () => ({ + env: { AUDIT_JOBS_QUEUE_URL: 'https://sqs.example.com/queue' }, + log: { error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() }, + sqs: { sendMessage: sinon.stub().resolves() }, + }); + + // Load utils with a controllable CloudManager client so we can exercise the + // verified / not-verified / degraded branches of queueDeliveryConfigWriter. + const loadWith = (cmResult) => esmock('../../src/support/utils.js', { + '../../src/support/cloud-manager.js': { + createCloudManagerClient: () => ({ verifyProgram: async () => cmResult }), + }, + }); + + it('stamps programVerified=true when Cloud Manager verifies the program', async () => { + const { queueDeliveryConfigWriter: q } = await loadWith({ verified: true }); + const context = makeContext(); + await q({ site: makeSite(), baseURL: 'https://example.com', slackContext: {} }, context); + expect(context.sqs.sendMessage.firstCall.args[1].programVerified).to.equal(true); + expect(context.log.warn).to.not.have.been.called; + }); + + it('warns and stamps programVerified=false when CM reports not-verified (not degraded)', async () => { + const { queueDeliveryConfigWriter: q } = await loadWith({ verified: false, degraded: false }); + const context = makeContext(); + await q({ site: makeSite(), baseURL: 'https://example.com', slackContext: {} }, context); + expect(context.sqs.sendMessage.firstCall.args[1].programVerified).to.equal(false); + expect(context.log.warn).to.have.been.calledOnce; + }); + + it('does not warn when the connector is unavailable (degraded)', async () => { + const { queueDeliveryConfigWriter: q } = await loadWith({ verified: false, degraded: true }); + const context = makeContext(); + await q({ site: makeSite(), baseURL: 'https://example.com', slackContext: {} }, context); + expect(context.sqs.sendMessage.firstCall.args[1].programVerified).to.equal(false); + expect(context.log.warn).to.not.have.been.called; + }); + }); + describe('sendAutofixMessage', () => { let mockSqs; From 138b88bebc767574e5f9414e73dce7d4cfe6c91c Mon Sep 17 00:00:00 2001 From: alinarublea Date: Thu, 16 Jul 2026 12:36:49 +0200 Subject: [PATCH 3/3] test: cover getProgram/listEnvironments to satisfy codecov patch target listEnvironments had no direct test (only reached via verifyProgram->getProgram), leaving cloud-manager.js:90-92 uncovered and diff coverage at 98.70% vs the 99.90% target. Add direct getProgram + listEnvironments tests -> 100%. --- test/support/cloud-manager.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/support/cloud-manager.test.js b/test/support/cloud-manager.test.js index 45e17bf7c0..4bc6a01028 100644 --- a/test/support/cloud-manager.test.js +++ b/test/support/cloud-manager.test.js @@ -65,6 +65,26 @@ describe('CloudManagerClient', () => { }); }); + describe('action methods', () => { + it('getProgram hits /api/program/{id}', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200, data: { id: 'p1' } })); + const res = await c.getProgram('p1'); + expect(res.data).to.deep.equal({ id: 'p1' }); + expect(JSON.parse(Buffer.from(send.firstCall.args[0].input.Payload).toString())) + .to.deep.equal({ action: 'get_program', programId: 'p1' }); + }); + + it('listEnvironments hits list_environments for the program', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200, data: { _embedded: { environments: [] } } })); + const res = await c.listEnvironments('p1'); + expect(res.ok).to.equal(true); + expect(JSON.parse(Buffer.from(send.firstCall.args[0].input.Payload).toString())) + .to.deep.equal({ action: 'list_environments', programId: 'p1' }); + }); + }); + describe('verifyProgram (graceful degradation)', () => { it('degrades (no throw) when the connector is not configured', async () => { const res = await new CloudManagerClient({ log }).verifyProgram('p1');