From 62b7ffd237c01ee776e64ec50dadfbbd293bdffb Mon Sep 17 00:00:00 2001 From: Tej Kotthakota Date: Fri, 11 Sep 2026 09:54:14 -0500 Subject: [PATCH] fix(demo-url): resolve tenant slug from IMS, not the brand name The onboarding "Access your environment here" demo URL derived the Experience Cloud tenant slug by slugifying the SpaceCat org's name when the IMS lookup failed (name.toLowerCase().replace(/\s+/g,'')). Internally onboarded sites live under the shared "Sites Internal" IMS org, so the org name is the customer brand (e.g. "Dave and Busters") and slugifying it produced a non-existent tenant ("daveandbusters") -> a broken deep link that forced users to manually switch IMS orgs. Resolve the tenant slug in order: 1. IMS_ORG_TENANT_ID_MAPPINGS[imsOrgId] - explicit ops-curated override (this secret was referenced by tests but the handler never read it) 2. imsClient.getImsOrganizationDetails(imsOrgId).tenantId (only when truthy) 3. DEFAULT_TENANT_ID - known-good fallback Remove the brand-name slugification entirely and the dead organization.tenantId read (the Organization model has no such attribute). Tenant resolution now depends only on the IMS org id, not the org record. Co-Authored-By: Claude Opus 4.8 --- src/tasks/demo-url-processor/handler.js | 89 +++++++--- .../demo-url-processor.test.js | 156 ++++++++---------- 2 files changed, 129 insertions(+), 116 deletions(-) diff --git a/src/tasks/demo-url-processor/handler.js b/src/tasks/demo-url-processor/handler.js index c81e066f..5b2e238c 100644 --- a/src/tasks/demo-url-processor/handler.js +++ b/src/tasks/demo-url-processor/handler.js @@ -16,39 +16,73 @@ import { say } from '../../utils/slack-utils.js'; const TASK_TYPE = 'demo-url-processor'; /** - * Gets the IMS tenant ID from the organization + * Reads the explicit IMS-org-id -> Experience Cloud tenant slug override from + * the IMS_ORG_TENANT_ID_MAPPINGS secret (a JSON object keyed by IMS org id). + * @param {string} imsOrgId - The IMS organization ID + * @param {object} env - The environment object + * @param {object} log - The logger + * @returns {string|undefined} The mapped tenant slug, or undefined when absent/unparseable + */ +function getMappedTenantId(imsOrgId, env, log) { + const raw = env.IMS_ORG_TENANT_ID_MAPPINGS; + if (!raw) { + return undefined; + } + try { + const mappings = JSON.parse(raw); + return mappings?.[imsOrgId]; + } catch (error) { + log.error(`Failed to parse IMS_ORG_TENANT_ID_MAPPINGS: ${error.message}`); + return undefined; + } +} + +/** + * Resolves the Experience Cloud tenant slug for the demo URL. + * + * Resolution order: + * 1. IMS_ORG_TENANT_ID_MAPPINGS[imsOrgId] - explicit, ops-curated override + * 2. IMS product-context tenant_id (getImsOrganizationDetails) + * 3. DEFAULT_TENANT_ID + * + * The SpaceCat org name is deliberately NOT slugified as a fallback: internally + * onboarded sites live under the shared "Sites Internal" IMS org, so the org + * name is the customer brand (e.g. "Dave and Busters") and slugifying it yields + * a tenant that does not exist in Experience Cloud (e.g. "daveandbusters"), + * producing a broken deep link. When the tenant cannot be determined we fall + * back to a known-good default instead. + * * @param {string} imsOrgId - The IMS organization ID - * @param {object} organization - The organization object * @param {object} context - The context object * @param {object} slackContext - The Slack context object - * @returns {string} The IMS tenant ID + * @returns {Promise} The Experience Cloud tenant slug */ -async function getImsTenantId(imsOrgId, organization, context, slackContext) { - // Get tenantId from organization - const { name, tenantId } = organization; +async function getImsTenantId(imsOrgId, context, slackContext) { const { log, env, imsClient } = context; - if (tenantId) { - log.info(`Tenant ID found in organization: ${tenantId}`); - return tenantId; - } else { - // Get tenantId from IMS org details if tenantId is not there in organization - let imsOrgDetails; - try { - imsOrgDetails = await imsClient.getImsOrganizationDetails(imsOrgId); - log.info(`IMS Org Details - tenantId: ${imsOrgDetails.tenantId}`); + + // 1. Explicit ops-curated override (imsOrgId -> tenant slug) + const mappedTenantId = getMappedTenantId(imsOrgId, env, log); + if (mappedTenantId) { + log.info(`Tenant ID resolved from IMS_ORG_TENANT_ID_MAPPINGS: ${mappedTenantId}`); + return mappedTenantId; + } + + // 2. IMS product-context tenant_id + try { + const imsOrgDetails = await imsClient.getImsOrganizationDetails(imsOrgId); + if (imsOrgDetails?.tenantId) { + log.info(`Tenant ID resolved from IMS org details: ${imsOrgDetails.tenantId}`); return imsOrgDetails.tenantId; - } catch (error) { - log.error(`Error retrieving IMS Org details: ${error.message}`); } + log.warn(`IMS org details returned no tenantId for imsOrgId: ${imsOrgId}`); + } catch (error) { + log.error(`Error retrieving IMS Org details: ${error.message}`); } - // As a fallback option, use name to generate tenant id (backward compatible for existing orgs) - if (name) { - log.info(`Using organization name to generate tenant ID: ${name}`); - return name.toLowerCase().replace(/\s+/g, ''); - } - log.error('Using default tenant ID'); - await say(env, log, slackContext, ':x: Using default tenant ID'); - return context.env.DEFAULT_TENANT_ID; + + // 3. Known-good default (never the customer brand name) + log.error('Falling back to default tenant ID'); + await say(env, log, slackContext, ':warning: Using default tenant ID for demo URL'); + return env.DEFAULT_TENANT_ID; } /** @@ -75,7 +109,6 @@ export async function runDemoUrlProcessor(message, context) { organizationId, }); - let imsTenantId = context.env.DEFAULT_TENANT_ID; try { const organization = await Organization.findById(organizationId); if (!organization) { @@ -85,11 +118,13 @@ export async function runDemoUrlProcessor(message, context) { } return ok({ message: 'Organization not found' }); } - imsTenantId = await getImsTenantId(imsOrgId, organization, context, slackContext); } catch (error) { log.error(`Error finding organization for organizationId: ${organizationId}`, error); } + // Tenant resolution depends only on the IMS org id, not the org record. + const imsTenantId = await getImsTenantId(imsOrgId, context, slackContext); + const demoUrl = `${experienceUrl}?organizationId=${organizationId}#/@${imsTenantId}/sites-optimizer/sites/${siteId}/home`; const slackMessage = `:white_check_mark: Onboarding setup completed for the site ${siteUrl}!\nAccess your environment here: ${demoUrl}`; diff --git a/test/tasks/demo-url-processor/demo-url-processor.test.js b/test/tasks/demo-url-processor/demo-url-processor.test.js index b4946cb0..cda2519c 100644 --- a/test/tasks/demo-url-processor/demo-url-processor.test.js +++ b/test/tasks/demo-url-processor/demo-url-processor.test.js @@ -17,6 +17,8 @@ import { MockContextBuilder } from '../../shared.js'; // Dynamic import for ES modules let runDemoUrlProcessor; +const IMS_ORG_ID = '8C6043F15F43B6390A49401A@AdobeOrg'; + describe('Demo URL Processor', () => { let context; let message; @@ -32,15 +34,15 @@ describe('Demo URL Processor', () => { // Create sandbox const sandbox = sinon.createSandbox(); - // Mock context + // Mock context. The Organization record carries the customer brand name + // ("Dave and Busters"); it must NOT be slugified into the tenant slug. context = new MockContextBuilder() .withSandbox(sandbox) .withDataAccess({ Organization: { findById: sandbox.stub().resolves({ - name: 'Adobe Sites Engineering', - tenantId: 'adobe-sites-engineering', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', + name: 'Dave and Busters', + imsOrgId: IMS_ORG_ID, }), }, }) @@ -49,15 +51,17 @@ describe('Demo URL Processor', () => { // Add imsClient to context context.imsClient = { getImsOrganizationDetails: sandbox.stub().resolves({ - tenantId: 'ims-tenant-id', + tenantId: 'sitesinternal', }), }; + context.env.DEFAULT_TENANT_ID = 'sitesinternal'; + // Mock message message = { siteId: 'test-site-id', siteUrl: 'example.com', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', + imsOrgId: IMS_ORG_ID, organizationId: 'test-org-id', taskContext: { experienceUrl: 'https://example.com', @@ -73,136 +77,110 @@ describe('Demo URL Processor', () => { sinon.restore(); }); - describe('runDemoUrlProcessor', () => { - it('should process demo URL successfully', async () => { - // Set up the IMS_ORG_TENANT_ID_MAPPINGS secret in context - context.env.IMS_ORG_TENANT_ID_MAPPINGS = JSON.stringify({ - '8C6043F15F43B6390A49401A@AdobeOrg': 'aem-sites-engineering', - }); + const expectDemoUrl = (tenant) => `https://example.com?organizationId=test-org-id#/@${tenant}/sites-optimizer/sites/test-site-id/home`; + describe('runDemoUrlProcessor', () => { + it('logs the processing context', async () => { await runDemoUrlProcessor(message, context); expect(context.log.info.calledWith('Processing demo url for site:', { taskType: 'demo-url-processor', siteId: 'test-site-id', siteUrl: 'example.com', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', + imsOrgId: IMS_ORG_ID, experienceUrl: 'https://example.com', organizationId: 'test-org-id', })).to.be.true; - const expectedDemoUrl = 'https://example.com?organizationId=test-org-id#/@adobe-sites-engineering/sites-optimizer/sites/test-site-id/home'; - expect(context.log.info.calledWith(`Onboarding setup completed for the site example.com! Access your environment here: ${expectedDemoUrl}`)).to.be.true; }); - it('should handle organization not found error', async () => { - // Mock Organization.findById to return null - context.dataAccess.Organization.findById.resolves(null); + it('uses the IMS_ORG_TENANT_ID_MAPPINGS override when present (highest priority)', async () => { + context.env.IMS_ORG_TENANT_ID_MAPPINGS = JSON.stringify({ + [IMS_ORG_ID]: 'sitesinternal', + }); + // Even if the IMS lookup would return something else, the mapping wins. + context.imsClient.getImsOrganizationDetails.resolves({ tenantId: 'some-other-tenant' }); await runDemoUrlProcessor(message, context); - // Should log error and return early - expect(context.log.error.calledWith('Organization not found for organizationId: test-org-id')).to.be.true; - // Should not log the success message - expect(context.log.info.calledWithMatch(sinon.match('Onboarding setup completed for the site example.com!'))).to.be.false; + expect(context.imsClient.getImsOrganizationDetails.called).to.be.false; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; }); - it('should use tenantId when available (highest priority)', async () => { - // Mock Organization.findById to return organization with tenantId - context.dataAccess.Organization.findById.resolves({ - name: 'Adobe Sites Engineering', - tenantId: 'adobe-sites-engineering', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', - }); + it('falls back to the IMS org tenantId when no mapping is present', async () => { + context.imsClient.getImsOrganizationDetails.resolves({ tenantId: 'sitesinternal' }); await runDemoUrlProcessor(message, context); - // Should use the tenantId (highest priority) - const expectedDemoUrl = 'https://example.com?organizationId=test-org-id#/@adobe-sites-engineering/sites-optimizer/sites/test-site-id/home'; - expect(context.log.info.calledWith(`Onboarding setup completed for the site example.com! Access your environment here: ${expectedDemoUrl}`)).to.be.true; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; }); - it('should fallback to name when tenantId is missing (backward compatibility)', async () => { - // Mock Organization.findById to return organization with name but no tenantId - context.dataAccess.Organization.findById.resolves({ - name: 'Adobe Sites Engineering', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', - // tenantId property is missing - }); - - // Mock imsClient to fail so it falls back to name - context.imsClient.getImsOrganizationDetails.rejects(new Error('IMS API error')); + it('ignores a malformed IMS_ORG_TENANT_ID_MAPPINGS and falls back to the IMS org tenantId', async () => { + context.env.IMS_ORG_TENANT_ID_MAPPINGS = '{ not valid json'; + context.imsClient.getImsOrganizationDetails.resolves({ tenantId: 'sitesinternal' }); await runDemoUrlProcessor(message, context); - // Should use the name-based tenant (lowercase, no spaces) as fallback - const expectedDemoUrl = 'https://example.com?organizationId=test-org-id#/@adobesitesengineering/sites-optimizer/sites/test-site-id/home'; - expect(context.log.info.calledWith(`Onboarding setup completed for the site example.com! Access your environment here: ${expectedDemoUrl}`)).to.be.true; + expect(context.log.error.calledWithMatch(sinon.match('Failed to parse IMS_ORG_TENANT_ID_MAPPINGS'))).to.be.true; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; }); - it('should fallback to DEFAULT_TENANT_ID when both name and tenantId are missing', async () => { - // Mock Organization.findById to return organization without name and tenantId - context.dataAccess.Organization.findById.resolves({ - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', - // name and tenantId properties are missing - }); - - // Mock imsClient to fail so it falls back to DEFAULT_TENANT_ID + it('never derives the tenant from the org name: uses DEFAULT_TENANT_ID when the IMS lookup throws', async () => { context.imsClient.getImsOrganizationDetails.rejects(new Error('IMS API error')); - // Set default tenant ID - context.env.DEFAULT_TENANT_ID = 'default-tenant'; + await runDemoUrlProcessor(message, context); + + // The brand name "Dave and Busters" must NOT become the tenant slug. + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('daveandbusters')}`, + )).to.be.false; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; + }); + + it('uses DEFAULT_TENANT_ID when the IMS lookup returns no tenantId', async () => { + context.imsClient.getImsOrganizationDetails.resolves({}); await runDemoUrlProcessor(message, context); - // Should log error about using default tenant ID - expect(context.log.error.calledWith('Using default tenant ID')).to.be.true; - const expectedDemoUrl = 'https://example.com?organizationId=test-org-id#/@default-tenant/sites-optimizer/sites/test-site-id/home'; - expect(context.log.info.calledWith(`Onboarding setup completed for the site example.com! Access your environment here: ${expectedDemoUrl}`)).to.be.true; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; }); - it('should return success message when processing completes', async () => { - // Set up the IMS_ORG_TENANT_ID_MAPPINGS secret in context - context.env.IMS_ORG_TENANT_ID_MAPPINGS = JSON.stringify({ - '8C6043F15F43B6390A49401A@AdobeOrg': 'aem-sites-engineering', - }); + it('handles organization not found', async () => { + context.dataAccess.Organization.findById.resolves(null); - // The function should complete without throwing an error await runDemoUrlProcessor(message, context); - // Verify that the success message was logged - expect(context.log.info.calledWithMatch(sinon.match('Onboarding setup completed for the site example.com!'))).to.be.true; + expect(context.log.error.calledWith('Organization not found for organizationId: test-org-id')).to.be.true; + expect(context.log.info.calledWithMatch(sinon.match('Onboarding setup completed for the site example.com!'))).to.be.false; }); - it('should handle error when Organization.findById throws an exception', async () => { - // Set up the IMS_ORG_TENANT_ID_MAPPINGS secret in context + it('continues and still builds the URL when Organization.findById throws', async () => { context.env.IMS_ORG_TENANT_ID_MAPPINGS = JSON.stringify({ - '8C6043F15F43B6390A49401A@AdobeOrg': 'aem-sites-engineering', + [IMS_ORG_ID]: 'sitesinternal', }); - - // Mock Organization.findById to throw an error context.dataAccess.Organization.findById.rejects(new Error('Database connection failed')); - // The function should handle the error gracefully without throwing await runDemoUrlProcessor(message, context); - // Verify that the error was logged expect(context.log.error.calledWith('Error finding organization for organizationId: test-org-id', sinon.match.any)).to.be.true; + expect(context.log.info.calledWith( + `Onboarding setup completed for the site example.com! Access your environment here: ${expectDemoUrl('sitesinternal')}`, + )).to.be.true; + }); - // Note: The current implementation continues execution even after errors, - // so the success message will still be logged. This test verifies that - // the error handling works and the function completes successfully. - - // Verify that the success message was still logged (since the function continues) + it('returns a success result', async () => { + const result = await runDemoUrlProcessor(message, context); + expect(result).to.exist; + expect(result.status).to.equal(200); expect(context.log.info.calledWithMatch(sinon.match('Onboarding setup completed for the site example.com!'))).to.be.true; - - // Verify that the processing log was recorded - expect(context.log.info.calledWith('Processing demo url for site:', { - taskType: 'demo-url-processor', - siteId: 'test-site-id', - siteUrl: 'example.com', - imsOrgId: '8C6043F15F43B6390A49401A@AdobeOrg', - experienceUrl: 'https://example.com', - organizationId: 'test-org-id', - })).to.be.true; }); }); });