diff --git a/.gitignore b/.gitignore index bec92764b..e8b9e0490 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ admin-idp-p*.json # V1 to V2 migration scripts and test data (local only) scripts/ docs/index.html +mcp-server.log \ No newline at end of file diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index e59973514..6247b8781 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -534,6 +534,8 @@ paths: $ref: './plg-onboarding-api.yaml#/plg-onboard' /plg/sites: $ref: './plg-onboarding-api.yaml#/plg-sites' + /plg/onboard/{onboardingId}: + $ref: './plg-onboarding-api.yaml#/plg-onboard-update' /plg/onboard/status/{imsOrgId}: $ref: './plg-onboarding-api.yaml#/plg-onboard-status' diff --git a/docs/openapi/plg-onboarding-api.yaml b/docs/openapi/plg-onboarding-api.yaml index d3d4bd5b1..cafb496fe 100644 --- a/docs/openapi/plg-onboarding-api.yaml +++ b/docs/openapi/plg-onboarding-api.yaml @@ -98,6 +98,85 @@ plg-sites: security: - ims_key: [ ] +plg-onboard-update: + parameters: + - name: onboardingId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + description: The PLG onboarding record ID + patch: + tags: + - plg + summary: Review a waitlisted PLG onboarding + description: | + Admin-only endpoint for ESEs to review a waitlisted PLG onboarding. + Supports BYPASSED (skip the blocking check and re-run the onboarding flow) + or UPHELD (block stands, no re-run). + + Behavior per waitlist reason: + - **DOMAIN_ALREADY_ONBOARDED_IN_ORG**: Sets the old onboarded domain to INACTIVE, + then re-runs the flow for the new domain. + - **AEM_SITE_CHECK**: Requires `siteConfig.rumHost`. Derives delivery config (AEM CS) + or code/hlx config (AEM EDS) from rumHost, then re-runs the flow. + - **DOMAIN_ALREADY_ASSIGNED**: Sets the current record to INACTIVE, then runs the + flow under the existing org that owns the site. + operationId: updatePlgOnboard + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - decision + - justification + properties: + decision: + type: string + enum: + - BYPASSED + - UPHELD + description: "BYPASSED: skip check and resume onboarding. UPHELD: block stands." + justification: + type: string + description: ESE's reason for the decision + example: "Customer confirmed AEM migration in progress" + siteConfig: + type: object + description: Required for AEM_SITE_CHECK bypass. Provide rumHost. + properties: + rumHost: + type: string + description: | + The RUM host for the site. For AEM CS: publish-p{programId}-e{envId}.adobeaemcloud.com. + For AEM EDS: ref--repo--owner.aem.live. + example: "publish-p123-e456.adobeaemcloud.com" + responses: + '200': + description: | + BYPASSED: onboarding flow result (ONBOARDED or re-waitlisted at different check). + UPHELD: record returned as-is with WAITLISTED status. + content: + application/json: + schema: + $ref: './schemas.yaml#/PlgOnboarding' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '409': + description: Conflict - PLG onboarding already exists for domain under existing org + '500': + $ref: './responses.yaml#/500' + security: + - admin_key: [ ] + plg-onboard-status: parameters: - $ref: './parameters.yaml#/imsOrgId' diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index a2a81c37f..85aa9bdca 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -6779,6 +6779,7 @@ PlgOnboarding: - ERROR - WAITING_FOR_IP_ALLOWLISTING - WAITLISTED + - INACTIVE example: "ONBOARDED" siteId: $ref: '#/Id' @@ -6852,6 +6853,34 @@ PlgOnboarding: - 'null' description: Reason for waitlisting, if applicable example: null + reviews: + type: + - array + - 'null' + description: Array of ESE review decisions for waitlisted onboardings + items: + type: object + properties: + reason: + type: string + description: The waitlist reason at the time of review + decision: + type: string + enum: + - BYPASSED + - UPHELD + description: "BYPASSED: skip check and resume. UPHELD: block stands." + reviewedBy: + type: string + description: Email of the ESE who made the decision + reviewedAt: + type: string + format: date-time + description: Timestamp of the review + justification: + type: string + description: ESE's reason for the decision + example: null completedAt: $ref: '#/DateTime' createdAt: diff --git a/package-lock.json b/package-lock.json index c7c187a2b..4893c72d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -700,6 +700,7 @@ "integrity": "sha512-wgmjwo0xJkYhFQUmv6GTPvCFjDYBoT7zP3OuAxLN+FlHgS6kDkbJOtKxwQn9SrWbhoIfM8GdCnRDpBn6BmkASw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.3.0", "aws4": "1.13.2" @@ -10282,6 +10283,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -14406,6 +14408,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -14637,6 +14640,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -14843,6 +14847,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -15006,6 +15011,7 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -16999,6 +17005,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -17314,6 +17321,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -17361,6 +17369,7 @@ "integrity": "sha512-F+LMD2IDIXuHxgpLJh3nkLj9+tSaEzoUWd+7fONGq5pe2169FUDjpEkOfEpoGLz1sbZni/69p07OsecNfAOpqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17836,6 +17845,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -18498,6 +18508,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -20710,6 +20721,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -24974,6 +24986,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -26028,6 +26041,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -28609,6 +28623,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -29145,6 +29160,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -30390,6 +30406,7 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -30400,6 +30417,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -31169,6 +31187,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -32576,6 +32595,7 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -33673,6 +33693,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -34425,6 +34446,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -34689,6 +34711,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -34698,6 +34721,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/src/controllers/plg/plg-onboarding.js b/src/controllers/plg/plg-onboarding.js index eb0f004b9..7dec06b26 100644 --- a/src/controllers/plg/plg-onboarding.js +++ b/src/controllers/plg/plg-onboarding.js @@ -48,17 +48,62 @@ import { triggerBrandProfileAgent } from '../../support/brand-profile-trigger.js import { PlgOnboardingDto } from '../../dto/plg-onboarding.js'; import AccessControlUtil from '../../support/access-control-util.js'; -const { STATUSES } = PlgOnboardingModel; +const { STATUSES, REVIEW_DECISIONS } = PlgOnboardingModel; const ASO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.ASO; const ASO_TIER = EntitlementModel.TIERS.FREE_TRIAL; const PLG_PROFILE_KEY = 'aso_plg'; +const REVIEW_REASONS = { + DOMAIN_ALREADY_ONBOARDED_IN_ORG: 'DOMAIN_ALREADY_ONBOARDED_IN_ORG', + AEM_SITE_CHECK: 'AEM_SITE_CHECK', + DOMAIN_ALREADY_ASSIGNED: 'DOMAIN_ALREADY_ASSIGNED', +}; + const DOMAIN_ALREADY_ASSIGNED = 'already assigned to another organization'; const DOMAIN_ALREADY_ONBOARDED_IN_ORG = 'another domain is already onboarded for this IMS org'; +/** + * Derives the review check key from the onboarding record's current state. + * @param {object} onboarding - The PlgOnboarding record. + * @returns {string|null} The check key enum value, or null if unknown. + */ +function deriveCheckKey(onboarding) { + /* c8 ignore next */ + const waitlistReason = onboarding.getWaitlistReason() || ''; + if (waitlistReason.includes(DOMAIN_ALREADY_ONBOARDED_IN_ORG)) { + return REVIEW_REASONS.DOMAIN_ALREADY_ONBOARDED_IN_ORG; + } + if (waitlistReason.includes('is not an AEM site')) { + return REVIEW_REASONS.AEM_SITE_CHECK; + } + if (waitlistReason.includes(DOMAIN_ALREADY_ASSIGNED)) { + return REVIEW_REASONS.DOMAIN_ALREADY_ASSIGNED; + } + + return null; +} + +/** + * Checks whether a specific blocking reason has been bypassed by the most recent review. + * Only the last review in the array is considered — if a newer bypass was added for a + * different reason (e.g. DOMAIN_ALREADY_ONBOARDED_IN_ORG after AEM_SITE_CHECK), the + * earlier bypass is no longer active and the check will run again. + * @param {Array} reviews - The reviews array from the onboarding record. + * @param {string} reasonSubstring - Substring to match against the review reason. + * @returns {boolean} True if the most recent review matches the reason and is BYPASSED. + */ +function isBypassed(reviews, reasonSubstring) { + /* c8 ignore next 3 */ + const last = (reviews || []).at(-1); + return last?.reason?.includes(reasonSubstring) && last?.decision === REVIEW_DECISIONS.BYPASSED; +} + // EDS host pattern: ref--repo--owner.aem.live (or hlx.live) const EDS_HOST_PATTERN = /^([\w-]+)--([\w-]+)--([\w-]+)\.(aem\.live|hlx\.live)$/i; +// AEM CS publish host pattern: publish-p{programId}-e{environmentId}.adobeaemcloud.(com|net) +const AEM_CS_PUBLISH_HOST_PATTERN = /^publish-p(\d+)-e(\d+)\.adobeaemcloud\.(com|net)$/i; + // RFC 1123 hostname: labels of 1-63 alphanumeric/hyphen chars, separated by dots, max 253 chars const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; @@ -143,12 +188,13 @@ async function createOrFindProject(baseURL, organizationId, context) { * @param {object} params * @param {string} params.domain - The domain to onboard * @param {string} params.imsOrgId - The IMS Organization ID + * @param {string} [params.rumHost] - Optional pre-provided RUM host (for AEM_SITE_CHECK bypass) * @param {object} context - The request context * @returns {Promise} PlgOnboarding record */ -async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { +async function performAsoPlgOnboarding({ domain, imsOrgId, rumHost: presetRumHost }, context) { const { dataAccess, log, env } = context; - const { Site, PlgOnboarding } = dataAccess; + const { Site, PlgOnboarding, Organization } = dataAccess; if (!isValidHostname(domain)) { throw Object.assign( @@ -188,13 +234,21 @@ async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { } } // Guard: only one domain per IMS org can be onboarded + const reviews = onboarding.getReviews() || []; const existingRecords = await PlgOnboarding.allByImsOrgId(imsOrgId); const alreadyOnboarded = existingRecords .find((r) => r.getDomain() !== domain && r.getStatus() === STATUSES.ONBOARDED); if (alreadyOnboarded) { log.info(`IMS org ${imsOrgId} already has onboarded domain ${alreadyOnboarded.getDomain()}, waitlisting ${domain}`); + const existingOrgForOnboarded = alreadyOnboarded.getOrganizationId() + ? await Organization.findById(alreadyOnboarded.getOrganizationId()) + /* c8 ignore next */ + : null; + /* c8 ignore next */ + const existingOrgName = existingOrgForOnboarded?.getName?.() + || alreadyOnboarded.getOrganizationId(); onboarding.setStatus(STATUSES.WAITLISTED); - onboarding.setWaitlistReason(`Domain ${alreadyOnboarded.getDomain()} is ${DOMAIN_ALREADY_ONBOARDED_IN_ORG}`); + onboarding.setWaitlistReason(`Domain ${alreadyOnboarded.getDomain()} is ${DOMAIN_ALREADY_ONBOARDED_IN_ORG} (org: ${existingOrgName}, id: ${imsOrgId})`); await onboarding.save(); return onboarding; } @@ -240,7 +294,8 @@ async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { steps.rumVerified = false; log.info(`No RUM data for ${domain}, checking delivery type`); cachedDeliveryType = await findDeliveryType(baseURL); - if (cachedDeliveryType === SiteModel.DELIVERY_TYPES.OTHER) { + if (!isBypassed(reviews, 'is not an AEM site') + && cachedDeliveryType === SiteModel.DELIVERY_TYPES.OTHER) { log.info(`Domain ${domain} is not an AEM site, moving to waitlist`); onboarding.setStatus(STATUSES.WAITLISTED); onboarding.setWaitlistReason(`Domain ${domain} is not an AEM site`); @@ -258,8 +313,13 @@ async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { if (existingOrgId !== organizationId && !isInternalOrg(existingOrgId, env)) { + const existingOrg = await Organization.findById(existingOrgId); + /* c8 ignore next */ + const existingImsOrgId = existingOrg?.getImsOrgId?.() || existingOrgId; + /* c8 ignore next */ + const existingOrgName = existingOrg?.getName?.() || existingOrgId; onboarding.setStatus(STATUSES.WAITLISTED); - onboarding.setWaitlistReason(`Domain ${domain} is ${DOMAIN_ALREADY_ASSIGNED}`); + onboarding.setWaitlistReason(`Domain ${domain} is ${DOMAIN_ALREADY_ASSIGNED} (org: ${existingOrgName}, id: ${existingImsOrgId})`); onboarding.setSiteId(site.getId()); onboarding.setSteps(steps); await onboarding.save(); @@ -339,27 +399,51 @@ async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { } // Step 5c: Auto-resolve author URL and RUM host - let rumHost = null; - try { - const resolvedConfig = await autoResolveAuthorUrl(site, context); - rumHost = resolvedConfig?.host || null; - - // Only update deliveryConfig if authorURL is not already set + let rumHost = presetRumHost || null; + if (presetRumHost) { + // Derive AEM CS delivery config directly from preset rumHost + /* c8 ignore next */ const existingDeliveryConfig = site.getDeliveryConfig() || {}; - if (!existingDeliveryConfig.authorURL && resolvedConfig?.authorURL) { - site.setDeliveryConfig({ - ...existingDeliveryConfig, - authorURL: resolvedConfig.authorURL, - programId: resolvedConfig.programId, - environmentId: resolvedConfig.environmentId, - preferContentApi: true, - imsOrgId, - }); - log.info(`Auto-resolved author URL for site ${site.getId()}: ${resolvedConfig.authorURL}`); - steps.authorUrlResolved = true; + if (!existingDeliveryConfig.authorURL) { + const csMatch = presetRumHost.match(AEM_CS_PUBLISH_HOST_PATTERN); + if (csMatch) { + const [, programId, environmentId] = csMatch; + const authorURL = `https://author-p${programId}-e${environmentId}.adobeaemcloud.com`; + site.setDeliveryConfig({ + ...existingDeliveryConfig, + authorURL, + programId, + environmentId, + preferContentApi: true, + imsOrgId, + }); + site.setDeliveryType(SiteModel.DELIVERY_TYPES.AEM_CS); + log.info(`Derived author URL from preset rumHost: ${authorURL}`); + steps.authorUrlResolved = true; + } + } + } else { + try { + const resolvedConfig = await autoResolveAuthorUrl(site, context); + rumHost = resolvedConfig?.host || null; + + // Only update deliveryConfig if authorURL is not already set + const existingDeliveryConfig = site.getDeliveryConfig() || {}; + if (!existingDeliveryConfig.authorURL && resolvedConfig?.authorURL) { + site.setDeliveryConfig({ + ...existingDeliveryConfig, + authorURL: resolvedConfig.authorURL, + programId: resolvedConfig.programId, + environmentId: resolvedConfig.environmentId, + preferContentApi: true, + imsOrgId, + }); + log.info(`Auto-resolved author URL for site ${site.getId()}: ${resolvedConfig.authorURL}`); + steps.authorUrlResolved = true; + } + } catch (error) { + log.warn(`Failed to auto-resolve author URL for site ${site.getId()}: ${error.message}`); } - } catch (error) { - log.warn(`Failed to auto-resolve author URL for site ${site.getId()}: ${error.message}`); } // Step 5d: Resolve EDS code config and hlxConfig from RUM host @@ -383,6 +467,7 @@ async function performAsoPlgOnboarding({ domain, imsOrgId }, context) { ref, site: repo, owner, tld, }, }); + site.setDeliveryType(SiteModel.DELIVERY_TYPES.AEM_EDGE); log.info(`Set hlxConfig for site ${site.getId()}: ${ref}--${repo}--${owner}.${tld}`); steps.hlxConfigSet = true; } @@ -540,23 +625,34 @@ function PlgOnboardingController(ctx) { return badRequest('Authentication information is required'); } - const profile = authInfo.getProfile(); - - if (!profile?.tenants?.[0]?.id) { - return badRequest('User profile or organization ID not found in authentication token'); - } + const accessControlUtil = AccessControlUtil.fromContext(context); + const isAdmin = accessControlUtil.hasAdminAccess(); - // If caller specifies an imsOrgId, validate it matches one of their token's tenants + // Admins can onboard on behalf of any IMS org — imsOrgId must be explicitly provided let imsOrgId; - if (hasText(requestedImsOrgId)) { - const matchedTenant = profile.tenants - .find((t) => `${t.id}@AdobeOrg` === requestedImsOrgId); - if (!matchedTenant) { - return forbidden('Requested imsOrgId does not match any tenant in authentication token'); + if (isAdmin) { + if (!hasText(requestedImsOrgId)) { + return badRequest('imsOrgId is required when onboarding as admin'); } imsOrgId = requestedImsOrgId; } else { - imsOrgId = `${profile.tenants[0].id}@AdobeOrg`; + const profile = authInfo.getProfile(); + + if (!profile?.tenants?.[0]?.id) { + return badRequest('User profile or organization ID not found in authentication token'); + } + + // If caller specifies an imsOrgId, validate it matches one of their token's tenants + if (hasText(requestedImsOrgId)) { + const matchedTenant = profile.tenants + .find((t) => `${t.id}@AdobeOrg` === requestedImsOrgId); + if (!matchedTenant) { + return forbidden('Requested imsOrgId does not match any tenant in authentication token'); + } + imsOrgId = requestedImsOrgId; + } else { + imsOrgId = `${profile.tenants[0].id}@AdobeOrg`; + } } try { @@ -689,7 +785,210 @@ function PlgOnboardingController(ctx) { }; /** - * POST /plg/admin/onboardings + * PATCH /plg/onboard/:onboardingId + * Admin-only: review a blocked onboarding (BYPASS or UPHOLD). + * On BYPASS, performs scenario-specific prep and re-runs the PLG flow. + */ + const update = async (context) => { + const { + dataAccess: da, params, data, attributes, + } = context; + + const accessControlUtil = AccessControlUtil.fromContext(context); + if (!accessControlUtil.hasAdminAccess()) { + return forbidden('Only admins can review onboarding records'); + } + + const { onboardingId } = params; + if (!hasText(onboardingId)) { + return badRequest('onboardingId is required'); + } + + if (!data || typeof data !== 'object') { + return badRequest('Request body is required'); + } + + const { decision, justification, siteConfig } = data; + + if (!hasText(decision) + || !Object.values(REVIEW_DECISIONS).includes(decision)) { + return badRequest(`decision is required and must be one of: ${Object.values(REVIEW_DECISIONS).join(', ')}`); + } + + if (!hasText(justification)) { + return badRequest('justification is required'); + } + + const { PlgOnboarding, Site } = da; + const onboarding = await PlgOnboarding.findById(onboardingId); + + if (!onboarding) { + return notFound('Onboarding record not found'); + } + + const status = onboarding.getStatus(); + if (status !== STATUSES.WAITLISTED) { + return badRequest('Onboarding record is not in a waitlisted state'); + } + + const checkKey = deriveCheckKey(onboarding); + if (!checkKey) { + return badRequest('Unable to determine the review reason from the onboarding record'); + } + + /* c8 ignore next */ + const reason = onboarding.getWaitlistReason() || ''; + + // Get reviewer identity from auth + const { authInfo } = attributes; + /* c8 ignore next */ + const reviewedBy = authInfo?.getProfile()?.email || 'admin'; + + // Build review entry + const reviewEntry = { + reason, + decision, + reviewedBy, + reviewedAt: new Date().toISOString(), + justification, + }; + + const existingReviews = onboarding.getReviews() || []; + const updatedReviews = [...existingReviews, reviewEntry]; + onboarding.setReviews(updatedReviews); + + // UPHOLD: just store the review and return + if (decision === REVIEW_DECISIONS.UPHELD) { + await onboarding.save(); + return ok(PlgOnboardingDto.toJSON(onboarding)); + } + + // BYPASS: scenario-specific prep, then re-run the flow + try { + switch (checkKey) { + case REVIEW_REASONS.DOMAIN_ALREADY_ONBOARDED_IN_ORG: { + // Find and offboard the old onboarded domain + const imsOrgId = onboarding.getImsOrgId(); + const records = await PlgOnboarding.allByImsOrgId(imsOrgId); + const oldOnboarded = records.find( + (r) => r.getDomain() !== onboarding.getDomain() + && r.getStatus() === STATUSES.ONBOARDED, + ); + if (oldOnboarded) { + oldOnboarded.setStatus(STATUSES.INACTIVE); + // Add offboard review to old record + const oldReviews = oldOnboarded.getReviews() || []; + oldOnboarded.setReviews([...oldReviews, { + reason: `Offboarded to onboard ${onboarding.getDomain()} for same IMS org`, + decision: REVIEW_DECISIONS.BYPASSED, + reviewedBy, + reviewedAt: reviewEntry.reviewedAt, + justification: `Offboarded to onboard ${onboarding.getDomain()} for same IMS org`, + }]); + await oldOnboarded.save(); + log.info(`Offboarded old domain ${oldOnboarded.getDomain()} for IMS org ${imsOrgId}`); + } + // Re-run PLG flow for the current domain + await onboarding.save(); + const result = await performAsoPlgOnboarding( + { domain: onboarding.getDomain(), imsOrgId }, + context, + ); + return ok(PlgOnboardingDto.toJSON(result)); + } + + case REVIEW_REASONS.AEM_SITE_CHECK: { + // Validate siteConfig — rumHost is always required + if (!siteConfig || !hasText(siteConfig.rumHost)) { + return badRequest('siteConfig with rumHost is required for AEM_SITE_CHECK bypass'); + } + if (!AEM_CS_PUBLISH_HOST_PATTERN.test(siteConfig.rumHost) + && !EDS_HOST_PATTERN.test(siteConfig.rumHost)) { + return badRequest( + 'rumHost must be a valid AEM CS publish host (publish-pXXX-eYYY.adobeaemcloud.com) ' + + 'or EDS host (ref--repo--owner.aem.live / hlx.live)', + ); + } + + // Re-run PLG flow with pre-set rumHost + // Step 5c will derive CS delivery config (authorURL, programId, environmentId) + // from rumHost if it matches AEM_CS_PUBLISH_HOST_PATTERN + // Steps 5d/5e will derive EDS config (code config, hlxConfig) + // from rumHost if it matches EDS_HOST_PATTERN + await onboarding.save(); + const result = await performAsoPlgOnboarding( + { + domain: onboarding.getDomain(), + imsOrgId: onboarding.getImsOrgId(), + rumHost: siteConfig.rumHost, + }, + context, + ); + return ok(PlgOnboardingDto.toJSON(result)); + } + + case REVIEW_REASONS.DOMAIN_ALREADY_ASSIGNED: { + // Find the existing org that owns the site + const domain = onboarding.getDomain(); + const baseURL = onboarding.getBaseURL(); + const site = await Site.findByBaseURL(baseURL); + + if (!site) { + return badRequest('Site no longer exists for this domain'); + } + + const existingOrgId = site.getOrganizationId(); + // Derive the IMS org ID for the existing org + const { Organization } = da; + const existingOrg = await Organization.findById(existingOrgId); + if (!existingOrg || !existingOrg.getImsOrgId()) { + return badRequest('Cannot determine IMS org for the existing site owner'); + } + + const existingImsOrgId = existingOrg.getImsOrgId(); + + // Offboard the original record (OrgA's) since domain belongs to OrgB + onboarding.setStatus(STATUSES.INACTIVE); + await onboarding.save(); + log.info(`Offboarded onboarding ${onboarding.getId()} for domain ${domain} (belongs to org ${existingImsOrgId})`); + + // Check if PLG onboarding already exists for (domain, existingOrg) + const existingPlgOnboarding = await PlgOnboarding + .findByImsOrgIdAndDomain(existingImsOrgId, domain); + if (existingPlgOnboarding) { + return createResponse( + { message: 'There is already an onboarding entry for this domain and org' }, + 409, + ); + } + + // Run the flow under the existing org — it will create the PlgOnboarding record + const result = await performAsoPlgOnboarding( + { domain, imsOrgId: existingImsOrgId }, + context, + ); + return ok(PlgOnboardingDto.toJSON(result)); + } + + /* c8 ignore next 2 */ + default: + return badRequest('Unknown review reason'); + } + } catch (error) { + log.error(`PLG onboarding bypass failed for ${onboarding.getId()}: ${error.message}`); + + if (error.conflict) { + return createResponse({ message: error.message }, 409); + } + if (error.clientError) { + return badRequest(error.message); + } + return internalServerError('Onboarding bypass failed. Please try again later.'); + } + }; + + /** + * POST /plg/records * Admin: create a PLG onboarding record with a given status (defaults to INACTIVE). * Body: { imsOrgId, domain, status? } */ @@ -727,7 +1026,7 @@ function PlgOnboardingController(ctx) { }; /** - * PATCH /plg/admin/onboardings/:plgOnboardingId + * PATCH /plg/records/:plgOnboardingId * Admin: update the status of a PLG onboarding record. * Body: { status } */ @@ -757,7 +1056,7 @@ function PlgOnboardingController(ctx) { }; /** - * DELETE /plg/admin/onboardings/:plgOnboardingId + * DELETE /plg/records/:plgOnboardingId * Admin: delete a PLG onboarding record. */ const deleteOnboarding = async (context) => { @@ -783,6 +1082,7 @@ function PlgOnboardingController(ctx) { onboard, getStatus, getAllOnboardings, + update, createOnboarding, updateOnboardingStatus, deleteOnboarding, diff --git a/src/dto/plg-onboarding.js b/src/dto/plg-onboarding.js index fc9c6f073..823c3c6d1 100644 --- a/src/dto/plg-onboarding.js +++ b/src/dto/plg-onboarding.js @@ -31,6 +31,7 @@ export const PlgOnboardingDto = { error: onboarding.getError(), botBlocker: onboarding.getBotBlocker(), waitlistReason: onboarding.getWaitlistReason(), + reviews: onboarding.getReviews(), completedAt: onboarding.getCompletedAt(), createdAt: onboarding.getCreatedAt(), updatedAt: onboarding.getUpdatedAt(), diff --git a/src/routes/index.js b/src/routes/index.js index e71bb329a..c17b4626d 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -465,6 +465,7 @@ export default function getRouteHandlers( 'POST /plg/onboard': plgOnboardingController.onboard, 'GET /plg/sites': plgOnboardingController.getAllOnboardings, 'GET /plg/onboard/status/:imsOrgId': plgOnboardingController.getStatus, + 'PATCH /plg/onboard/:onboardingId': plgOnboardingController.update, 'POST /plg/records': plgOnboardingController.createOnboarding, 'PATCH /plg/records/:plgOnboardingId': plgOnboardingController.updateOnboardingStatus, 'DELETE /plg/records/:plgOnboardingId': plgOnboardingController.deleteOnboarding, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index c057b21ad..8059d2dfc 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -80,6 +80,7 @@ export const INTERNAL_ROUTES = [ 'POST /plg/onboard', 'GET /plg/sites', 'GET /plg/onboard/status/:imsOrgId', + 'PATCH /plg/onboard/:onboardingId', 'POST /plg/records', 'PATCH /plg/records/:plgOnboardingId', 'DELETE /plg/records/:plgOnboardingId', diff --git a/test/controllers/plg/plg-onboarding.test.js b/test/controllers/plg/plg-onboarding.test.js index 5cd8de35f..2fe6e1c2d 100644 --- a/test/controllers/plg/plg-onboarding.test.js +++ b/test/controllers/plg/plg-onboarding.test.js @@ -101,6 +101,7 @@ describe('PlgOnboardingController', () => { setProjectId: sandbox.stub(), getAuthoringType: sandbox.stub().returns(overrides.authoringType ?? null), getDeliveryType: sandbox.stub().returns(overrides.deliveryType ?? null), + setDeliveryType: sandbox.stub(), save: sandbox.stub().resolves(), }; } @@ -118,6 +119,7 @@ describe('PlgOnboardingController', () => { error: overrides.error || null, botBlocker: overrides.botBlocker || null, waitlistReason: overrides.waitlistReason || null, + reviews: overrides.reviews || null, completedAt: overrides.completedAt || null, createdAt: overrides.createdAt || '2026-03-09T12:00:00.000Z', updatedAt: overrides.updatedAt || '2026-03-09T12:00:00.000Z', @@ -135,6 +137,7 @@ describe('PlgOnboardingController', () => { getError: sandbox.stub().returns(record.error), getBotBlocker: sandbox.stub().returns(record.botBlocker), getWaitlistReason: sandbox.stub().returns(record.waitlistReason), + getReviews: sandbox.stub().returns(record.reviews), getCompletedAt: sandbox.stub().returns(record.completedAt), getCreatedAt: sandbox.stub().returns(record.createdAt), getUpdatedAt: sandbox.stub().returns(record.updatedAt), @@ -145,6 +148,7 @@ describe('PlgOnboardingController', () => { setError: sandbox.stub(), setBotBlocker: sandbox.stub(), setWaitlistReason: sandbox.stub(), + setReviews: sandbox.stub(), setCompletedAt: sandbox.stub(), save: sandbox.stub().resolves(), remove: sandbox.stub().resolves(), @@ -166,6 +170,7 @@ describe('PlgOnboardingController', () => { // LLMO onboarding stubs mockOrganization = { getId: sandbox.stub().returns(TEST_ORG_ID), + getImsOrgId: sandbox.stub().returns(TEST_IMS_ORG_ID), }; createOrFindOrganizationStub = sandbox.stub().resolves(mockOrganization); enableAuditsStub = sandbox.stub().resolves(); @@ -226,6 +231,7 @@ describe('PlgOnboardingController', () => { }, Organization: { findByImsOrgId: sandbox.stub().resolves(mockOrganization), + findById: sandbox.stub().resolves(mockOrganization), }, Project: { allByOrganizationId: sandbox.stub().resolves([]), @@ -305,6 +311,17 @@ describe('PlgOnboardingController', () => { ERROR: 'ERROR', WAITING_FOR_IP_ALLOWLISTING: 'WAITING_FOR_IP_ALLOWLISTING', WAITLISTED: 'WAITLISTED', + INACTIVE: 'INACTIVE', + }, + REVIEW_REASONS: { + DOMAIN_ALREADY_ONBOARDED_IN_ORG: 'DOMAIN_ALREADY_ONBOARDED_IN_ORG', + AEM_SITE_CHECK: 'AEM_SITE_CHECK', + DOMAIN_ALREADY_ASSIGNED: 'DOMAIN_ALREADY_ASSIGNED', + BOT_BLOCKER: 'BOT_BLOCKER', + }, + REVIEW_DECISIONS: { + BYPASSED: 'BYPASSED', + UPHELD: 'UPHELD', }, }, }, @@ -448,6 +465,129 @@ describe('PlgOnboardingController', () => { }); }); + // --- Admin onboard access --- + + describe('onboard - admin access', () => { + let adminController; + + beforeEach(async () => { + const AdminPlgOnboardingController = (await esmock( + '../../../src/controllers/plg/plg-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + composeBaseURL: composeBaseURLStub, + detectBotBlocker: detectBotBlockerStub, + detectLocale: detectLocaleStub, + hasText: (val) => typeof val === 'string' && val.trim().length > 0, + isValidIMSOrgId: (val) => typeof val === 'string' && val.endsWith('@AdobeOrg'), + resolveCanonicalUrl: resolveCanonicalUrlStub, + }, + '@adobe/spacecat-shared-http-utils': { + badRequest: (msg) => ({ status: 400, value: msg }), + createResponse: (body, status) => ({ status, value: body }), + forbidden: (msg) => ({ status: 403, value: msg }), + internalServerError: (msg) => ({ status: 500, value: msg }), + notFound: (msg) => ({ status: 404, value: msg }), + ok: (data) => ({ status: 200, value: data }), + }, + '@adobe/spacecat-shared-rum-api-client': { + default: { + createFrom: sandbox.stub().returns({ + retrieveDomainkey: rumRetrieveDomainkeyStub, + }), + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: { createForSite: tierClientCreateForSiteStub }, + }, + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem: configToDynamoItemStub }, + }, + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { ASO: 'aso_optimizer' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-data-access/src/models/plg-onboarding/plg-onboarding.model.js': { + default: { + STATUSES: { + IN_PROGRESS: 'IN_PROGRESS', + ONBOARDED: 'ONBOARDED', + PRE_ONBOARDING: 'PRE_ONBOARDING', + ERROR: 'ERROR', + WAITING_FOR_IP_ALLOWLISTING: 'WAITING_FOR_IP_ALLOWLISTING', + WAITLISTED: 'WAITLISTED', + INACTIVE: 'INACTIVE', + }, + REVIEW_REASONS: { + DOMAIN_ALREADY_ONBOARDED_IN_ORG: 'DOMAIN_ALREADY_ONBOARDED_IN_ORG', + AEM_SITE_CHECK: 'AEM_SITE_CHECK', + DOMAIN_ALREADY_ASSIGNED: 'DOMAIN_ALREADY_ASSIGNED', + BOT_BLOCKER: 'BOT_BLOCKER', + }, + REVIEW_DECISIONS: { + BYPASSED: 'BYPASSED', + UPHELD: 'UPHELD', + }, + }, + }, + '../../../src/controllers/llmo/llmo-onboarding.js': { + createOrFindOrganization: createOrFindOrganizationStub, + enableAudits: enableAuditsStub, + enableImports: enableImportsStub, + triggerAudits: triggerAuditsStub, + ASO_DEMO_ORG: DEMO_ORG_ID, + }, + '../../../src/support/utils.js': { + autoResolveAuthorUrl: autoResolveAuthorUrlStub, + updateCodeConfig: updateCodeConfigStub, + findDeliveryType: findDeliveryTypeStub, + deriveProjectName: deriveProjectNameStub, + queueDeliveryConfigWriter: queueDeliveryConfigWriterStub, + }, + '../../../src/utils/slack/base.js': { + loadProfileConfig: loadProfileConfigStub, + }, + '../../../src/support/brand-profile-trigger.js': { + triggerBrandProfileAgent: triggerBrandProfileAgentStub, + }, + '../../../src/support/access-control-util.js': { + default: { + fromContext: () => ({ hasAdminAccess: () => true }), + }, + }, + }, + )).default; + + adminController = AdminPlgOnboardingController({ log: mockLog }); + }); + + it('returns 400 when imsOrgId is missing in admin onboard call', async () => { + const context = buildContext({ domain: TEST_DOMAIN }); + const res = await adminController.onboard(context); + expect(res.status).to.equal(400); + expect(res.value).to.equal('imsOrgId is required when onboarding as admin'); + }); + + it('returns 400 when imsOrgId is empty string in admin onboard call', async () => { + const context = buildContext({ domain: TEST_DOMAIN, imsOrgId: '' }); + const res = await adminController.onboard(context); + expect(res.status).to.equal(400); + expect(res.value).to.equal('imsOrgId is required when onboarding as admin'); + }); + + it('onboards successfully when admin provides imsOrgId', async () => { + const context = buildContext({ domain: TEST_DOMAIN, imsOrgId: TEST_IMS_ORG_ID }); + const res = await adminController.onboard(context); + expect(res.status).to.equal(200); + expect(createOrFindOrganizationStub).to.have.been.calledWith( + TEST_IMS_ORG_ID, + sinon.match.any, + ); + }); + }); + // --- SSRF protection --- describe('onboard - SSRF protection', () => { @@ -1281,7 +1421,6 @@ describe('PlgOnboardingController', () => { const context = buildContext({ domain: TEST_DOMAIN }); const res = await controller.onboard(context); - expect(res.status).to.equal(200); // Should NOT modify the site expect(existingSite.setOrganizationId).to.not.have.been.called; @@ -1295,6 +1434,23 @@ describe('PlgOnboardingController', () => { // Should NOT proceed to bot blocker or site creation expect(detectBotBlockerStub).to.not.have.been.called; }); + + it('uses org ID as fallback in waitlist reason when Organization.findById returns null', async () => { + const existingSite = createMockSite({ orgId: OTHER_CUSTOMER_ORG_ID }); + mockDataAccess.Site.findByBaseURL.resolves(existingSite); + mockDataAccess.Organization.findById.resolves(null); // triggers || existingOrgId fallback + + const context = buildContext({ domain: TEST_DOMAIN }); + const res = await controller.onboard(context); + + expect(res.status).to.equal(200); + expect(mockOnboarding.setStatus).to.have.been.calledWith('WAITLISTED'); + expect(mockOnboarding.setWaitlistReason) + .to.have.been.calledWithMatch(/already assigned to another organization/); + // Falls back to org UUID in the reason since no org was found + expect(mockOnboarding.setWaitlistReason) + .to.have.been.calledWithMatch(new RegExp(OTHER_CUSTOMER_ORG_ID)); + }); }); // --- AEM site verification --- @@ -1368,6 +1524,25 @@ describe('PlgOnboardingController', () => { expect(mockDataAccess.Site.create).to.not.have.been.called; }); + it('waitlists and uses org ID as fallback name when Organization.findById returns null for already-onboarded record', async () => { + const onboardedRecord = createMockOnboarding({ + id: 'other-onboarding-id', + domain: 'other-domain.com', + status: 'ONBOARDED', + organizationId: OTHER_CUSTOMER_ORG_ID, // has org ID so findById is called + }); + mockDataAccess.PlgOnboarding.allByImsOrgId.resolves([onboardedRecord]); + mockDataAccess.Organization.findById.resolves(null); // org not found — fallback to org ID + + const context = buildContext({ domain: TEST_DOMAIN }); + const res = await controller.onboard(context); + + expect(res.status).to.equal(200); + expect(mockOnboarding.setStatus).to.have.been.calledWith('WAITLISTED'); + expect(mockOnboarding.setWaitlistReason) + .to.have.been.calledWithMatch(/another domain is already onboarded for this IMS org/); + }); + it('allows onboarding when the same domain is already onboarded (re-onboard)', async () => { const onboardedRecord = createMockOnboarding({ domain: TEST_DOMAIN, @@ -2210,13 +2385,13 @@ describe('PlgOnboardingController', () => { }); }); - // --- Admin: createOnboarding / updateOnboardingStatus / deleteOnboarding --- + // --- PATCH /plg/onboard/:onboardingId (update) + admin PLG record APIs --- - describe('admin onboarding management', () => { - let AdminController; + describe('update and admin PLG record APIs', () => { + let AdminAccessPlgController; beforeEach(async () => { - AdminController = (await esmock( + AdminAccessPlgController = (await esmock( '../../../src/controllers/plg/plg-onboarding.js', { '@adobe/spacecat-shared-utils': { @@ -2267,6 +2442,15 @@ describe('PlgOnboardingController', () => { WAITLISTED: 'WAITLISTED', INACTIVE: 'INACTIVE', }, + REVIEW_REASONS: { + DOMAIN_ALREADY_ONBOARDED_IN_ORG: 'DOMAIN_ALREADY_ONBOARDED_IN_ORG', + AEM_SITE_CHECK: 'AEM_SITE_CHECK', + DOMAIN_ALREADY_ASSIGNED: 'DOMAIN_ALREADY_ASSIGNED', + }, + REVIEW_DECISIONS: { + BYPASSED: 'BYPASSED', + UPHELD: 'UPHELD', + }, }, }, '../../../src/controllers/llmo/llmo-onboarding.js': { @@ -2292,180 +2476,689 @@ describe('PlgOnboardingController', () => { )).default; }); - describe('createOnboarding', () => { - it('returns 403 when caller is not admin', async () => { - const res = await PlgOnboardingController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + describe('update', () => { + const adminAuthAttributes = { + authInfo: { + getProfile: () => ({ email: 'ese@adobe.com' }), + }, + }; + + it('returns 403 for non-admin users', async () => { + const NonAdminController = (await esmock( + '../../../src/controllers/plg/plg-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + composeBaseURL: composeBaseURLStub, + detectBotBlocker: detectBotBlockerStub, + detectLocale: detectLocaleStub, + hasText: (val) => typeof val === 'string' && val.trim().length > 0, + isValidIMSOrgId: () => true, + resolveCanonicalUrl: resolveCanonicalUrlStub, + }, + '@adobe/spacecat-shared-http-utils': { + badRequest: (msg) => ({ status: 400, value: msg }), + createResponse: (body, status) => ({ status, value: body }), + forbidden: (msg) => ({ status: 403, value: msg }), + internalServerError: (msg) => ({ status: 500, value: msg }), + notFound: (msg) => ({ status: 404, value: msg }), + ok: (data) => ({ status: 200, value: data }), + }, + '@adobe/spacecat-shared-rum-api-client': { + default: { + createFrom: sandbox.stub().returns({ + retrieveDomainkey: sandbox.stub(), + }), + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: { createForSite: sandbox.stub() }, + }, + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem: sandbox.stub() }, + }, + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { PRODUCT_CODES: { ASO: 'aso_optimizer' }, TIERS: { FREE_TRIAL: 'FREE_TRIAL' } }, + }, + '@adobe/spacecat-shared-data-access/src/models/plg-onboarding/plg-onboarding.model.js': { + default: { + STATUSES: { + IN_PROGRESS: 'IN_PROGRESS', + ONBOARDED: 'ONBOARDED', + PRE_ONBOARDING: 'PRE_ONBOARDING', + ERROR: 'ERROR', + WAITING_FOR_IP_ALLOWLISTING: 'WAITING_FOR_IP_ALLOWLISTING', + WAITLISTED: 'WAITLISTED', + INACTIVE: 'INACTIVE', + }, + REVIEW_REASONS: { + DOMAIN_ALREADY_ONBOARDED_IN_ORG: 'DOMAIN_ALREADY_ONBOARDED_IN_ORG', + AEM_SITE_CHECK: 'AEM_SITE_CHECK', + DOMAIN_ALREADY_ASSIGNED: 'DOMAIN_ALREADY_ASSIGNED', + }, + REVIEW_DECISIONS: { BYPASSED: 'BYPASSED', UPHELD: 'UPHELD' }, + }, + }, + '../../../src/controllers/llmo/llmo-onboarding.js': { + createOrFindOrganization: sandbox.stub(), + enableAudits: sandbox.stub(), + enableImports: sandbox.stub(), + triggerAudits: sandbox.stub(), + ASO_DEMO_ORG: DEMO_ORG_ID, + }, + '../../../src/support/utils.js': { + autoResolveAuthorUrl: sandbox.stub(), + updateCodeConfig: sandbox.stub(), + findDeliveryType: sandbox.stub(), + deriveProjectName: sandbox.stub(), + queueDeliveryConfigWriter: sandbox.stub(), + }, + '../../../src/utils/slack/base.js': { loadProfileConfig: sandbox.stub().returns(PLG_PROFILE) }, + '../../../src/support/brand-profile-trigger.js': { triggerBrandProfileAgent: sandbox.stub() }, + '../../../src/support/access-control-util.js': { + default: { fromContext: () => ({ hasAdminAccess: () => false }) }, + }, + }, + )).default; + + const res = await NonAdminController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(403); }); - it('returns 400 when data is null', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: null, + it('returns 400 for missing onboardingId', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: {}, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); }); - it('returns 400 when imsOrgId is missing', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { domain: TEST_DOMAIN }, + it('returns 400 for missing request body', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: null, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); }); - it('returns 400 when domain is missing', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID }, + it('returns 400 for invalid decision', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'INVALID', justification: 'test' }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); }); - it('returns 400 when status is invalid', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'BOGUS' }, + it('returns 400 for missing justification', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED' }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); }); - it('returns 409 when record already exists', async () => { - mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(mockOnboarding); - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + it('returns 404 when onboarding record not found', async () => { + mockDataAccess.PlgOnboarding.findById.resolves(null); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, }); - expect(res.status).to.equal(409); + + expect(res.status).to.equal(404); }); - it('creates record with INACTIVE status by default and returns 201', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + it('returns 400 when onboarding is not in a blocked state', async () => { + const record = createMockOnboarding({ status: 'ONBOARDED' }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, }); - expect(res.status).to.equal(201); - expect(mockDataAccess.PlgOnboarding.create).to.have.been.calledWith( - sinon.match({ imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'INACTIVE' }), - ); + + expect(res.status).to.equal(400); + expect(res.value).to.equal('Onboarding record is not in a waitlisted state'); }); - it('creates record with explicit status when provided', async () => { - const res = await AdminController({ log: mockLog }).createOnboarding({ - data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'PRE_ONBOARDING' }, + it('stores UPHOLD review and keeps status unchanged', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain site-a.com is another domain is already onboarded for this IMS org', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'UPHELD', justification: 'Not ready to proceed' }, + attributes: adminAuthAttributes, }); - expect(res.status).to.equal(201); - expect(mockDataAccess.PlgOnboarding.create).to.have.been.calledWith( - sinon.match({ status: 'PRE_ONBOARDING' }), - ); - }); - }); - describe('updateOnboardingStatus', () => { - it('returns 403 when caller is not admin', async () => { - const res = await PlgOnboardingController({ log: mockLog }).updateOnboardingStatus({ - data: { status: 'INACTIVE' }, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + expect(res.status).to.equal(200); + expect(record.setReviews).to.have.been.calledOnce; + const reviews = record.setReviews.firstCall.args[0]; + expect(reviews).to.have.length(1); + expect(reviews[0].reason).to.equal('Domain site-a.com is another domain is already onboarded for this IMS org'); + expect(reviews[0].decision).to.equal('UPHELD'); + expect(reviews[0].justification).to.equal('Not ready to proceed'); + expect(record.setStatus).to.not.have.been.called; + expect(record.save).to.have.been.calledOnce; + }); + + it('BYPASS DOMAIN_ALREADY_ONBOARDED_IN_ORG: replaces old domain and re-runs flow', async () => { + const waitlistedRecord = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain site-a.com is another domain is already onboarded for this IMS org', + }); + const oldOnboardedRecord = createMockOnboarding({ + id: 'old-onboarding-id', + domain: 'site-a.com', + status: 'ONBOARDED', + }); + + mockDataAccess.PlgOnboarding.findById.resolves(waitlistedRecord); + mockDataAccess.PlgOnboarding.allByImsOrgId.resolves([waitlistedRecord, oldOnboardedRecord]); + // After INACTIVE, the re-run finds the existing record + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(waitlistedRecord); + mockDataAccess.Site.create.resolves(mockSite); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'Customer wants new domain' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); - expect(res.status).to.equal(403); + + expect(res.status).to.equal(200); + expect(oldOnboardedRecord.setStatus).to.have.been.calledWith('INACTIVE'); + expect(oldOnboardedRecord.setReviews).to.have.been.calledOnce; + const oldReviews = oldOnboardedRecord.setReviews.firstCall.args[0]; + expect(oldReviews).to.have.length(1); + expect(oldReviews[0].reason).to.include('Offboarded to onboard'); + expect(oldReviews[0].justification).to.include('Offboarded to onboard'); + expect(oldOnboardedRecord.save).to.have.been.called; }); - it('returns 400 when data is null', async () => { - const res = await AdminController({ log: mockLog }).updateOnboardingStatus({ - data: null, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS AEM_SITE_CHECK: returns 400 when siteConfig is missing', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is not an AEM site', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'AEM migration confirmed' }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); + expect(res.value).to.include('rumHost'); }); - it('returns 400 when status is missing', async () => { - const res = await AdminController({ log: mockLog }).updateOnboardingStatus({ - data: {}, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS AEM_SITE_CHECK: returns 400 when rumHost format is invalid', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is not an AEM site', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { + decision: 'BYPASSED', + justification: 'AEM migration confirmed', + siteConfig: { rumHost: 'not-a-valid-rum-host.example.com' }, + }, + attributes: adminAuthAttributes, }); + expect(res.status).to.equal(400); + expect(res.value).to.include('rumHost must be a valid'); }); - it('returns 400 when status is invalid', async () => { - const res = await AdminController({ log: mockLog }).updateOnboardingStatus({ - data: { status: 'BOGUS' }, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS AEM_SITE_CHECK: pre-sets delivery config and re-runs flow', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is not an AEM site', + siteId: TEST_SITE_ID, + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(record); + mockDataAccess.Site.create.resolves(mockSite); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { + decision: 'BYPASSED', + justification: 'AEM migration confirmed', + siteConfig: { + rumHost: 'publish-p123-e456.adobeaemcloud.com', + }, + }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); - expect(res.status).to.equal(400); + + expect(res.status).to.equal(200); }); - it('returns 404 when record not found', async () => { - const res = await AdminController({ log: mockLog }).updateOnboardingStatus({ - data: { status: 'INACTIVE' }, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS DOMAIN_ALREADY_ASSIGNED: returns 409 when onboarding exists for existing org', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is already assigned to another organization', + siteId: TEST_SITE_ID, + }); + const existingSite = createMockSite({ orgId: OTHER_CUSTOMER_ORG_ID }); + const existingOrg = { + getId: sandbox.stub().returns(OTHER_CUSTOMER_ORG_ID), + getImsOrgId: sandbox.stub().returns('OTHERORG123@AdobeOrg'), + }; + const existingPlgOnboarding = createMockOnboarding({ + imsOrgId: 'OTHERORG123@AdobeOrg', + status: 'ONBOARDED', + }); + + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.Site.findByBaseURL.resolves(existingSite); + mockDataAccess.Organization.findById.resolves(existingOrg); + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(existingPlgOnboarding); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'Run under existing org' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); - expect(res.status).to.equal(404); + + expect(res.status).to.equal(409); + expect(res.value.message).to.include('already an onboarding entry'); }); - it('updates status and returns 200', async () => { - mockDataAccess.PlgOnboarding.findById.resolves(mockOnboarding); - const res = await AdminController({ log: mockLog }).updateOnboardingStatus({ - data: { status: 'INACTIVE' }, - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS DOMAIN_ALREADY_ASSIGNED: offboards original and runs flow under existing org', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is already assigned to another organization', + siteId: TEST_SITE_ID, + }); + const existingSite = createMockSite({ orgId: OTHER_CUSTOMER_ORG_ID }); + const existingOrg = { + getId: sandbox.stub().returns(OTHER_CUSTOMER_ORG_ID), + getImsOrgId: sandbox.stub().returns('OTHERORG123@AdobeOrg'), + }; + + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.Site.findByBaseURL.resolves(existingSite); + mockDataAccess.Organization.findById.resolves(existingOrg); + // No existing PLG onboarding for (domain, OrgB) + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(null); + mockDataAccess.Site.create.resolves(mockSite); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'Run under existing org' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); + expect(res.status).to.equal(200); - expect(mockOnboarding.setStatus).to.have.been.calledWith('INACTIVE'); - expect(mockOnboarding.save).to.have.been.called; + // Original record should be offboarded + expect(record.setStatus).to.have.been.calledWith('INACTIVE'); + expect(record.save).to.have.been.called; }); - }); - describe('deleteOnboarding', () => { - it('returns 403 when caller is not admin', async () => { - const res = await PlgOnboardingController({ log: mockLog }).deleteOnboarding({ - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS DOMAIN_ALREADY_ASSIGNED: returns 400 when site no longer exists', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is already assigned to another organization', + siteId: TEST_SITE_ID, + }); + + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.Site.findByBaseURL.resolves(null); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'Run under existing org' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); - expect(res.status).to.equal(403); + + expect(res.status).to.equal(400); + expect(res.value).to.equal('Site no longer exists for this domain'); }); - it('returns 404 when record not found', async () => { - const res = await AdminController({ log: mockLog }).deleteOnboarding({ - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS DOMAIN_ALREADY_ASSIGNED: returns 400 when existing org has no IMS org ID', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain example.com is already assigned to another organization', + siteId: TEST_SITE_ID, + }); + const existingSite = createMockSite({ orgId: OTHER_CUSTOMER_ORG_ID }); + const orgWithoutIms = { + getId: sandbox.stub().returns(OTHER_CUSTOMER_ORG_ID), + getImsOrgId: sandbox.stub().returns(null), + }; + + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.Site.findByBaseURL.resolves(existingSite); + mockDataAccess.Organization.findById.resolves(orgWithoutIms); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'Run under existing org' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, }); - expect(res.status).to.equal(404); + + expect(res.status).to.equal(400); + expect(res.value).to.equal('Cannot determine IMS org for the existing site owner'); + }); + + it('returns 400 for unknown waitlist reason', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Some completely unknown reason', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ + dataAccess: mockDataAccess, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, + }); + + expect(res.status).to.equal(400); + expect(res.value).to.equal('Unable to determine the review reason from the onboarding record'); + }); + + it('BYPASS returns 409 on conflict error during flow', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain site-a.com is another domain is already onboarded for this IMS org', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.PlgOnboarding.allByImsOrgId.resolves([record]); + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(record); + mockDataAccess.Site.create.rejects( + Object.assign(new Error('Org conflict'), { conflict: true }), + ); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ + dataAccess: mockDataAccess, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, + }); + + expect(res.status).to.equal(409); + expect(res.value.message).to.equal('Org conflict'); }); - it('deletes record and returns 204', async () => { - mockDataAccess.PlgOnboarding.findById.resolves(mockOnboarding); - const res = await AdminController({ log: mockLog }).deleteOnboarding({ - params: { plgOnboardingId: TEST_ONBOARDING_ID }, + it('BYPASS returns 400 on client error during flow', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain site-a.com is another domain is already onboarded for this IMS org', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.PlgOnboarding.allByImsOrgId.resolves([record]); + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(record); + mockDataAccess.Site.create.rejects( + Object.assign(new Error('Bad domain'), { clientError: true }), + ); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ dataAccess: mockDataAccess, - attributes: {}, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, + }); + + expect(res.status).to.equal(400); + expect(res.value).to.equal('Bad domain'); + }); + + it('BYPASS returns 500 on unexpected error during flow', async () => { + const record = createMockOnboarding({ + status: 'WAITLISTED', + waitlistReason: 'Domain site-a.com is another domain is already onboarded for this IMS org', + }); + mockDataAccess.PlgOnboarding.findById.resolves(record); + mockDataAccess.PlgOnboarding.allByImsOrgId.resolves([record]); + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(record); + mockDataAccess.Site.create.rejects(new Error('DB connection failed')); + + const res = await AdminAccessPlgController({ log: mockLog }).update({ + dataAccess: mockDataAccess, + params: { onboardingId: TEST_ONBOARDING_ID }, + data: { decision: 'BYPASSED', justification: 'test' }, + attributes: adminAuthAttributes, + env: mockEnv, + log: mockLog, + }); + + expect(res.status).to.equal(500); + expect(res.value).to.equal('Onboarding bypass failed. Please try again later.'); + }); + }); + + describe('admin onboarding management', () => { + describe('createOnboarding', () => { + it('returns 403 when caller is not admin', async () => { + const res = await PlgOnboardingController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(403); + }); + + it('returns 400 when data is null', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: null, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when imsOrgId is missing', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { domain: TEST_DOMAIN }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when domain is missing', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when status is invalid', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'BOGUS' }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 409 when record already exists', async () => { + mockDataAccess.PlgOnboarding.findByImsOrgIdAndDomain.resolves(mockOnboarding); + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(409); + }); + + it('creates record with INACTIVE status by default and returns 201', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(201); + expect(mockDataAccess.PlgOnboarding.create).to.have.been.calledWith( + sinon.match({ imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'INACTIVE' }), + ); + }); + + it('creates record with explicit status when provided', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).createOnboarding({ + data: { imsOrgId: TEST_IMS_ORG_ID, domain: TEST_DOMAIN, status: 'PRE_ONBOARDING' }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(201); + expect(mockDataAccess.PlgOnboarding.create).to.have.been.calledWith( + sinon.match({ status: 'PRE_ONBOARDING' }), + ); + }); + }); + + describe('updateOnboardingStatus', () => { + it('returns 403 when caller is not admin', async () => { + const res = await PlgOnboardingController({ log: mockLog }).updateOnboardingStatus({ + data: { status: 'INACTIVE' }, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(403); + }); + + it('returns 400 when data is null', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).updateOnboardingStatus({ + data: null, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when status is missing', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).updateOnboardingStatus({ + data: {}, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when status is invalid', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).updateOnboardingStatus({ + data: { status: 'BOGUS' }, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when record not found', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).updateOnboardingStatus({ + data: { status: 'INACTIVE' }, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(404); + }); + + it('updates status and returns 200', async () => { + mockDataAccess.PlgOnboarding.findById.resolves(mockOnboarding); + const res = await AdminAccessPlgController({ log: mockLog }).updateOnboardingStatus({ + data: { status: 'INACTIVE' }, + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(200); + expect(mockOnboarding.setStatus).to.have.been.calledWith('INACTIVE'); + expect(mockOnboarding.save).to.have.been.called; + }); + }); + + describe('deleteOnboarding', () => { + it('returns 403 when caller is not admin', async () => { + const res = await PlgOnboardingController({ log: mockLog }).deleteOnboarding({ + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(403); + }); + + it('returns 404 when record not found', async () => { + const res = await AdminAccessPlgController({ log: mockLog }).deleteOnboarding({ + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(404); + }); + + it('deletes record and returns 204', async () => { + mockDataAccess.PlgOnboarding.findById.resolves(mockOnboarding); + const res = await AdminAccessPlgController({ log: mockLog }).deleteOnboarding({ + params: { plgOnboardingId: TEST_ONBOARDING_ID }, + dataAccess: mockDataAccess, + attributes: {}, + }); + expect(res.status).to.equal(204); + expect(mockOnboarding.remove).to.have.been.called; }); - expect(res.status).to.equal(204); - expect(mockOnboarding.remove).to.have.been.called; }); }); }); diff --git a/test/dto/plg-onboarding.test.js b/test/dto/plg-onboarding.test.js index dc89c7653..340fb1c6f 100644 --- a/test/dto/plg-onboarding.test.js +++ b/test/dto/plg-onboarding.test.js @@ -29,6 +29,7 @@ describe('PlgOnboardingDto', () => { getError: () => null, getBotBlocker: () => null, getWaitlistReason: () => null, + getReviews: () => null, getCompletedAt: () => '2026-03-09T15:00:00.000Z', getCreatedAt: () => '2026-03-09T12:00:00.000Z', getUpdatedAt: () => '2026-03-09T15:00:00.000Z', @@ -48,6 +49,7 @@ describe('PlgOnboardingDto', () => { error: null, botBlocker: null, waitlistReason: null, + reviews: null, completedAt: '2026-03-09T15:00:00.000Z', createdAt: '2026-03-09T12:00:00.000Z', updatedAt: '2026-03-09T15:00:00.000Z', @@ -71,6 +73,7 @@ describe('PlgOnboardingDto', () => { userAgent: 'SpaceCat/1.0', }), getWaitlistReason: () => null, + getReviews: () => null, getCompletedAt: () => null, getCreatedAt: () => '2026-03-09T12:00:00.000Z', getUpdatedAt: () => '2026-03-09T12:05:00.000Z', diff --git a/test/it/postgres/docker-compose.yml b/test/it/postgres/docker-compose.yml index 3058af193..3ad25fec6 100644 --- a/test/it/postgres/docker-compose.yml +++ b/test/it/postgres/docker-compose.yml @@ -41,7 +41,7 @@ services: retries: 10 data-service: - image: 682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service:v1.40.0 + image: 682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service:v1.56.0 container_name: spacecat-it-data-service depends_on: db: diff --git a/test/it/postgres/seed-data/plg-onboardings.js b/test/it/postgres/seed-data/plg-onboardings.js index 54fc81572..64ba5a120 100644 --- a/test/it/postgres/seed-data/plg-onboardings.js +++ b/test/it/postgres/seed-data/plg-onboardings.js @@ -35,4 +35,16 @@ export const plgOnboardings = [ }, completed_at: '2026-01-20T12:00:00.000Z', }, + { + id: 'd2222222-2222-4222-b222-222222222222', + ims_org_id: 'AAAAAAAABBBBBBBBCCCCCCCC@AdobeOrg', + domain: 'waitlisted-site.example.com', + base_url: 'https://www.waitlisted-site.example.com', + status: 'WAITLISTED', + organization_id: '11111111-1111-4111-b111-111111111111', + waitlist_reason: 'Domain site1.example.com is another domain is already onboarded for this IMS org', + steps: { + orgResolved: true, + }, + }, ]; diff --git a/test/it/shared/seed-ids.js b/test/it/shared/seed-ids.js index d8214ce7d..7494b872e 100644 --- a/test/it/shared/seed-ids.js +++ b/test/it/shared/seed-ids.js @@ -135,6 +135,8 @@ export const CONSUMER_1_IMS_ORG_ID = ORG_1_IMS_ORG_ID; export const PLG_ONBOARDING_1_ID = 'd1111111-1111-4111-b111-111111111111'; export const PLG_ONBOARDING_1_DOMAIN = 'site1.example.com'; +export const PLG_ONBOARDING_2_ID = 'd2222222-2222-4222-b222-222222222222'; +export const PLG_ONBOARDING_2_DOMAIN = 'waitlisted-site.example.com'; // ── ORG_3: Delegate Agency Org ── diff --git a/test/it/shared/tests/plg-onboarding.js b/test/it/shared/tests/plg-onboarding.js index 76935843b..afabd6d10 100644 --- a/test/it/shared/tests/plg-onboarding.js +++ b/test/it/shared/tests/plg-onboarding.js @@ -17,6 +17,7 @@ import { ORG_2_IMS_ORG_ID, PLG_ONBOARDING_1_ID, PLG_ONBOARDING_1_DOMAIN, + PLG_ONBOARDING_2_ID, NON_EXISTENT_IMS_ORG_ID, } from '../seed-ids.js'; @@ -183,10 +184,9 @@ export default function plgOnboardingTests(getHttpClient, resetData, options = { const res = await http.admin.get(`/plg/onboard/status/${ORG_1_IMS_ORG_ID}`); expect(res.status).to.equal(200); - expect(res.body).to.be.an('array').with.lengthOf(1); - const record = res.body[0]; + expect(res.body).to.be.an('array').with.length.of.at.least(2); + const record = res.body.find((r) => r.id === PLG_ONBOARDING_1_ID); expectPlgOnboardingDto(record); - expect(record.id).to.equal(PLG_ONBOARDING_1_ID); expect(record.imsOrgId).to.equal(ORG_1_IMS_ORG_ID); expect(record.domain).to.equal(PLG_ONBOARDING_1_DOMAIN); expect(record.status).to.equal('ONBOARDED'); @@ -198,7 +198,83 @@ export default function plgOnboardingTests(getHttpClient, resetData, options = { const http = getHttpClient(); const res = await http.user.get(`/plg/onboard/status/${ORG_1_IMS_ORG_ID}`); expect(res.status).to.equal(200); - expect(res.body).to.be.an('array').with.lengthOf(1); + expect(res.body).to.be.an('array').with.length.of.at.least(2); + }); + } + }); + + // ── PATCH /plg/onboard/:onboardingId ── + + describe('PATCH /plg/onboard/:onboardingId', () => { + it('returns 403 for non-admin user', async () => { + const http = getHttpClient(); + const res = await http.user.patch(`/plg/onboard/${PLG_ONBOARDING_2_ID}`, { + decision: 'BYPASSED', + justification: 'test', + }); + expect(res.status).to.equal(403); + }); + + it('returns 400 for missing decision', async () => { + const http = getHttpClient(); + const res = await http.admin.patch(`/plg/onboard/${PLG_ONBOARDING_2_ID}`, { + justification: 'test', + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 for missing justification', async () => { + const http = getHttpClient(); + const res = await http.admin.patch(`/plg/onboard/${PLG_ONBOARDING_2_ID}`, { + decision: 'BYPASSED', + }); + expect(res.status).to.equal(400); + }); + + it('returns 400 for invalid decision value', async () => { + const http = getHttpClient(); + const res = await http.admin.patch(`/plg/onboard/${PLG_ONBOARDING_2_ID}`, { + decision: 'INVALID', + justification: 'test', + }); + expect(res.status).to.equal(400); + }); + + it('returns 404 for non-existent onboardingId', async () => { + const http = getHttpClient(); + const res = await http.admin.patch('/plg/onboard/aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', { + decision: 'BYPASSED', + justification: 'test', + }); + expect(res.status).to.equal(404); + }); + + it('returns 400 when onboarding is not waitlisted', async () => { + const http = getHttpClient(); + // PLG_ONBOARDING_1 is ONBOARDED, not WAITLISTED + const res = await http.admin.patch(`/plg/onboard/${PLG_ONBOARDING_1_ID}`, { + decision: 'BYPASSED', + justification: 'test', + }); + expect(res.status).to.equal(400); + }); + + if (!skipPlgOnboardingTests) { + it('UPHELD: stores review and keeps WAITLISTED status', async () => { + const http = getHttpClient(); + const res = await http.admin.patch(`/plg/onboard/${PLG_ONBOARDING_2_ID}`, { + decision: 'UPHELD', + justification: 'Not ready to proceed', + }); + expect(res.status).to.equal(200); + expectPlgOnboardingDto(res.body); + expect(res.body.status).to.equal('WAITLISTED'); + expect(res.body.reviews).to.be.an('array').with.lengthOf(1); + expect(res.body.reviews[0].decision).to.equal('UPHELD'); + expect(res.body.reviews[0].justification).to.equal('Not ready to proceed'); + expect(res.body.reviews[0].reason).to.include('already onboarded'); + expect(res.body.reviews[0].reviewedBy).to.be.a('string'); + expectISOTimestamp(res.body.reviews[0].reviewedAt, 'reviewedAt'); }); } }); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 35357cbda..5682e8325 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -387,6 +387,7 @@ describe('getRouteHandlers', () => { onboard: sinon.stub(), getAllOnboardings: sinon.stub(), getStatus: sinon.stub(), + update: sinon.stub(), }; const mockImsOrgAccessController = { @@ -804,7 +805,6 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/llmo/edge-optimize-status', 'POST /sites/:siteId/llmo/edge-optimize-routing', 'PUT /sites/:siteId/llmo/opportunities-reviewed', - 'GET /plg/onboard/status/:imsOrgId', 'GET /sites/:siteId/user-activities', 'POST /sites/:siteId/user-activities', 'GET /sites/:siteId/site-enrollments', @@ -843,6 +843,8 @@ describe('getRouteHandlers', () => { 'PATCH /consumers/:consumerId', 'POST /consumers/:consumerId/revoke', 'GET /sites/:siteId/tokens/by-type/:tokenType', + 'GET /plg/onboard/status/:imsOrgId', + 'PATCH /plg/onboard/:onboardingId', 'PATCH /plg/records/:plgOnboardingId', 'DELETE /plg/records/:plgOnboardingId', 'POST /sites/:siteId/ims-org-access',