diff --git a/README.md b/README.md index 1ff56c8..9f568b9 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit ## CRM Service Request Badges +On the first Opportunities modal opening in a page session, `requestCurrentUser` +fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls, +Reference changes and modal reopenings reuse that result. Users with `SALES_REP` +do not trigger CRM Service Request or Workload Actions queries or see their badges. +If identity or roles cannot be validated, only these two features stay disabled; +other opportunity features continue. Reload the page to refresh identity/roles. +The unused identity token is discarded and redacted from debug responses. + Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of `CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`, using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest` diff --git a/dist/chromium/background.js b/dist/chromium/background.js index 9c28c7e..98bef3a 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -14,6 +14,7 @@ function createMessageTypes() { requestInstallBase: "opportunitiesExtension.requestInstallBase", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestWlActions: "opportunitiesExtension.requestWlActions", + requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" @@ -643,6 +644,35 @@ function createSpaWorkloadActions({ fetchJson }) { return { requestWlActions }; } +// Source: src/services/spa/current-user.js +function createSpaCurrentUser({ fetchJson }) { + async function requestCurrentUser() { + const debugRequests = []; + let user = null; + try { + const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { + throw new Error("requestCurrentUser returned invalid user roles."); + } + user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } + error.debugRequests = debugRequests; + throw error; + } finally { + // The identity token is not needed by content scripts or debug, including on failures. + for (const entry of debugRequests) { + entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2); + entry.responseBodyTruncated = false; + } + } + } + return { requestCurrentUser }; +} + // Source: src/services/spa/consumption.js function createSpaConsumption({ CONSUMPTION_TREND_REQUESTS, @@ -1696,6 +1726,7 @@ function registerBackgroundMessages({ requestInstallBase, requestCustomerServiceRequest, requestWlActions, + requestCurrentUser, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, @@ -1777,6 +1808,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.type === messageTypes.requestCurrentUser) { + requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + ok: false, error: error.message || "Unable to load current user.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + })); + return true; + } + if (message.type === messageTypes.requestWlActions) { requestWlActions(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load workload actions.", @@ -1859,6 +1898,8 @@ function createBackgroundApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ @@ -1973,7 +2014,7 @@ function createBackgroundApplication(overrides = {}) { const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; function start() { - startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 668db49..4dc40cb 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -15,6 +15,7 @@ function createMessageTypes() { requestInstallBase: "opportunitiesExtension.requestInstallBase", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestWlActions: "opportunitiesExtension.requestWlActions", + requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" @@ -470,6 +471,7 @@ function createContentModal({ resetWorkloadActions, reopenWorkloadActions, requestTokenRelayOnce, + ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, resetOpportunitiesTable, restoreFilterPreferences, @@ -496,11 +498,16 @@ function createContentModal({ if (existingModal) { existingModal.hidden = false; - reopenWorkloadActions(); existingModal.querySelector("select").focus(); document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); - requestTokenRelayOnce().then(token => loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion)) + ensureCurrentUser().then(() => { + if (existingModal.hidden) return; + reopenWorkloadActions(); + return requestTokenRelayOnce().then(token => { + if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); + }); + }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; } @@ -1186,6 +1193,25 @@ function createAuthContentSession({ return { requestTokenRelayOnce, requestConsumerTokenServiceOnce }; } +// Source: src/auth/current-user.js +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { + let pending = null; + let allowed = false; + function ensure() { + if (!pending) { + pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { + appendBackgroundRequestLog(response?.debugRequests); + const roles = response?.user?.roles; + allowed = response?.ok === true && Array.isArray(roles) + && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); + return allowed; + }).catch(() => { allowed = false; return false; }); + } + return pending; + } + return { ensure, allowsCrmActivities: () => allowed }; +} + // Source: src/auth/sales-common.js function createAuthSalesCommon({ TOKEN_RELAY_URL, @@ -1568,11 +1594,14 @@ function createOpportunitiesController({ resetWorkloadActions, requestOpptyProductsInBackground, requestTokenRelayOnce, + ensureCurrentUser, setOpportunitiesTableState }) { async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + await ensureCurrentUser(); + if (!isCurrentPeriodRequest(requestVersion)) return; if (preferencesState.selectedOpportunityTypeValues.size === 0) { setOpportunitiesTableState({ @@ -1933,14 +1962,14 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest, } // Source: src/features/opportunities/crm-service-requests.js -function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) { +function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) { let status = "idle"; let byOpportunity = new Map(); let pending = null; let generation = 0; function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); } function load(token, version) { - if (!isCurrentPeriodRequest(version)) return; + if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return; if (!pending) pending = run(token, version, generation); return pending; } @@ -1965,7 +1994,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre } } function createBadge(optyNumber) { - if (!optyNumber || status === "idle" || status === "loading") return null; + if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const items = byOpportunity.get(String(optyNumber)) || []; const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); badge.className = "opportunities-extension-crm-sr-badge"; @@ -2123,7 +2152,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir } // Source: src/features/opportunities/workload-actions.js -function createOpportunityWorkloadActions({ authSessionState, requestConsumerTokenServiceOnce, sendRuntimeMessage, +function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) { let session = newSession(); function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; } @@ -2137,7 +2166,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } function loadProducts(products) { - if (!session.enabled) return; + if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { if (session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; @@ -2161,9 +2190,9 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions }); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions."); record.actions = result.actions; @@ -2178,7 +2207,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok } } function createBadge(optyNumber) { - if (!session.enabled || !optyNumber) return null; + if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null; const products = productsContentState.opptyProductsByNumber.get(String(optyNumber)); if (!products) return null; const ids = idsFor(products); @@ -6463,7 +6492,7 @@ function createDebugContentView({ let returnFocus = null; function getRequestCategory(entry) { - if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; + if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (entry.name === "requestOpportunities") return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestWlActions") return "actions"; @@ -6826,6 +6855,11 @@ function createContentApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const currentUser = createCurrentUser({ + messageTypes, + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) + }); const contracts = createContentContracts(); const authSessionState = createAuthSessionState(contracts); const authSalesCommonState = createAuthSalesCommonState(); @@ -6857,6 +6891,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, @@ -6975,6 +7010,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunitiesController = createOpportunitiesController({ + ensureCurrentUser: () => currentUser.ensure(), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), STAGE_OPTIONS: contracts.STAGE_OPTIONS, STATUS_OPTIONS: contracts.STATUS_OPTIONS, @@ -7023,6 +7059,7 @@ function createContentApplication(overrides = {}) { createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment }); const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), bindPopover: (...args) => crmSrPopover.bind(...args), closePopover: (...args) => crmSrPopover.close(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), @@ -7143,6 +7180,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunityWorkloadActions = createOpportunityWorkloadActions({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), authSessionState, requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), @@ -7335,6 +7373,7 @@ function createContentApplication(overrides = {}) { contentIcons, contentStyles, authContentSession, + currentUser, authSalesCommon, authTokenRelay, authXsrfContent, diff --git a/dist/firefox/background.js b/dist/firefox/background.js index 9c28c7e..98bef3a 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -14,6 +14,7 @@ function createMessageTypes() { requestInstallBase: "opportunitiesExtension.requestInstallBase", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestWlActions: "opportunitiesExtension.requestWlActions", + requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" @@ -643,6 +644,35 @@ function createSpaWorkloadActions({ fetchJson }) { return { requestWlActions }; } +// Source: src/services/spa/current-user.js +function createSpaCurrentUser({ fetchJson }) { + async function requestCurrentUser() { + const debugRequests = []; + let user = null; + try { + const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { + throw new Error("requestCurrentUser returned invalid user roles."); + } + user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } + error.debugRequests = debugRequests; + throw error; + } finally { + // The identity token is not needed by content scripts or debug, including on failures. + for (const entry of debugRequests) { + entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2); + entry.responseBodyTruncated = false; + } + } + } + return { requestCurrentUser }; +} + // Source: src/services/spa/consumption.js function createSpaConsumption({ CONSUMPTION_TREND_REQUESTS, @@ -1696,6 +1726,7 @@ function registerBackgroundMessages({ requestInstallBase, requestCustomerServiceRequest, requestWlActions, + requestCurrentUser, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, @@ -1777,6 +1808,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.type === messageTypes.requestCurrentUser) { + requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + ok: false, error: error.message || "Unable to load current user.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + })); + return true; + } + if (message.type === messageTypes.requestWlActions) { requestWlActions(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load workload actions.", @@ -1859,6 +1898,8 @@ function createBackgroundApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ @@ -1973,7 +2014,7 @@ function createBackgroundApplication(overrides = {}) { const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; function start() { - startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 668db49..4dc40cb 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -15,6 +15,7 @@ function createMessageTypes() { requestInstallBase: "opportunitiesExtension.requestInstallBase", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestWlActions: "opportunitiesExtension.requestWlActions", + requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" @@ -470,6 +471,7 @@ function createContentModal({ resetWorkloadActions, reopenWorkloadActions, requestTokenRelayOnce, + ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, resetOpportunitiesTable, restoreFilterPreferences, @@ -496,11 +498,16 @@ function createContentModal({ if (existingModal) { existingModal.hidden = false; - reopenWorkloadActions(); existingModal.querySelector("select").focus(); document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); - requestTokenRelayOnce().then(token => loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion)) + ensureCurrentUser().then(() => { + if (existingModal.hidden) return; + reopenWorkloadActions(); + return requestTokenRelayOnce().then(token => { + if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); + }); + }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; } @@ -1186,6 +1193,25 @@ function createAuthContentSession({ return { requestTokenRelayOnce, requestConsumerTokenServiceOnce }; } +// Source: src/auth/current-user.js +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { + let pending = null; + let allowed = false; + function ensure() { + if (!pending) { + pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { + appendBackgroundRequestLog(response?.debugRequests); + const roles = response?.user?.roles; + allowed = response?.ok === true && Array.isArray(roles) + && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); + return allowed; + }).catch(() => { allowed = false; return false; }); + } + return pending; + } + return { ensure, allowsCrmActivities: () => allowed }; +} + // Source: src/auth/sales-common.js function createAuthSalesCommon({ TOKEN_RELAY_URL, @@ -1568,11 +1594,14 @@ function createOpportunitiesController({ resetWorkloadActions, requestOpptyProductsInBackground, requestTokenRelayOnce, + ensureCurrentUser, setOpportunitiesTableState }) { async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + await ensureCurrentUser(); + if (!isCurrentPeriodRequest(requestVersion)) return; if (preferencesState.selectedOpportunityTypeValues.size === 0) { setOpportunitiesTableState({ @@ -1933,14 +1962,14 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest, } // Source: src/features/opportunities/crm-service-requests.js -function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) { +function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) { let status = "idle"; let byOpportunity = new Map(); let pending = null; let generation = 0; function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); } function load(token, version) { - if (!isCurrentPeriodRequest(version)) return; + if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return; if (!pending) pending = run(token, version, generation); return pending; } @@ -1965,7 +1994,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre } } function createBadge(optyNumber) { - if (!optyNumber || status === "idle" || status === "loading") return null; + if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const items = byOpportunity.get(String(optyNumber)) || []; const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); badge.className = "opportunities-extension-crm-sr-badge"; @@ -2123,7 +2152,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir } // Source: src/features/opportunities/workload-actions.js -function createOpportunityWorkloadActions({ authSessionState, requestConsumerTokenServiceOnce, sendRuntimeMessage, +function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) { let session = newSession(); function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; } @@ -2137,7 +2166,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } function loadProducts(products) { - if (!session.enabled) return; + if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { if (session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; @@ -2161,9 +2190,9 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions }); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions."); record.actions = result.actions; @@ -2178,7 +2207,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok } } function createBadge(optyNumber) { - if (!session.enabled || !optyNumber) return null; + if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null; const products = productsContentState.opptyProductsByNumber.get(String(optyNumber)); if (!products) return null; const ids = idsFor(products); @@ -6463,7 +6492,7 @@ function createDebugContentView({ let returnFocus = null; function getRequestCategory(entry) { - if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; + if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (entry.name === "requestOpportunities") return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestWlActions") return "actions"; @@ -6826,6 +6855,11 @@ function createContentApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const currentUser = createCurrentUser({ + messageTypes, + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) + }); const contracts = createContentContracts(); const authSessionState = createAuthSessionState(contracts); const authSalesCommonState = createAuthSalesCommonState(); @@ -6857,6 +6891,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, @@ -6975,6 +7010,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunitiesController = createOpportunitiesController({ + ensureCurrentUser: () => currentUser.ensure(), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), STAGE_OPTIONS: contracts.STAGE_OPTIONS, STATUS_OPTIONS: contracts.STATUS_OPTIONS, @@ -7023,6 +7059,7 @@ function createContentApplication(overrides = {}) { createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment }); const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), bindPopover: (...args) => crmSrPopover.bind(...args), closePopover: (...args) => crmSrPopover.close(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), @@ -7143,6 +7180,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunityWorkloadActions = createOpportunityWorkloadActions({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), authSessionState, requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), @@ -7335,6 +7373,7 @@ function createContentApplication(overrides = {}) { contentIcons, contentStyles, authContentSession, + currentUser, authSalesCommon, authTokenRelay, authXsrfContent, diff --git a/scripts/sources.cjs b/scripts/sources.cjs index cd2fe4f..417fc3e 100644 --- a/scripts/sources.cjs +++ b/scripts/sources.cjs @@ -23,6 +23,7 @@ module.exports = { "src/content/svg.js", "src/content/styles.js", "src/auth/content-session.js", + "src/auth/current-user.js", "src/auth/sales-common.js", "src/auth/token-relay.js", "src/auth/xsrf-content.js", @@ -76,6 +77,7 @@ module.exports = { "src/services/spa/install-base.js", "src/services/spa/customer-service-requests.js", "src/services/spa/workload-actions.js", + "src/services/spa/current-user.js", "src/services/spa/consumption.js", "src/services/spa/response-shapes.js", "src/services/http/background-json.js", diff --git a/src/auth/current-user.js b/src/auth/current-user.js new file mode 100644 index 0000000..cd09b6c --- /dev/null +++ b/src/auth/current-user.js @@ -0,0 +1,17 @@ +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { + let pending = null; + let allowed = false; + function ensure() { + if (!pending) { + pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { + appendBackgroundRequestLog(response?.debugRequests); + const roles = response?.user?.roles; + allowed = response?.ok === true && Array.isArray(roles) + && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); + return allowed; + }).catch(() => { allowed = false; return false; }); + } + return pending; + } + return { ensure, allowsCrmActivities: () => allowed }; +} diff --git a/src/background/application.js b/src/background/application.js index 756d3e0..e67deb0 100644 --- a/src/background/application.js +++ b/src/background/application.js @@ -7,6 +7,8 @@ function createBackgroundApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ @@ -121,7 +123,7 @@ function createBackgroundApplication(overrides = {}) { const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; function start() { - startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/src/background/messages.js b/src/background/messages.js index 7159bf3..0ba14b8 100644 --- a/src/background/messages.js +++ b/src/background/messages.js @@ -7,6 +7,7 @@ function registerBackgroundMessages({ requestInstallBase, requestCustomerServiceRequest, requestWlActions, + requestCurrentUser, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, @@ -88,6 +89,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.type === messageTypes.requestCurrentUser) { + requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + ok: false, error: error.message || "Unable to load current user.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + })); + return true; + } + if (message.type === messageTypes.requestWlActions) { requestWlActions(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load workload actions.", diff --git a/src/content/application.js b/src/content/application.js index 29f0165..d24f891 100644 --- a/src/content/application.js +++ b/src/content/application.js @@ -18,6 +18,11 @@ function createContentApplication(overrides = {}) { ...overrides }; const messageTypes = createMessageTypes(); + const currentUser = createCurrentUser({ + messageTypes, + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) + }); const contracts = createContentContracts(); const authSessionState = createAuthSessionState(contracts); const authSalesCommonState = createAuthSalesCommonState(); @@ -49,6 +54,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, @@ -167,6 +173,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunitiesController = createOpportunitiesController({ + ensureCurrentUser: () => currentUser.ensure(), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), STAGE_OPTIONS: contracts.STAGE_OPTIONS, STATUS_OPTIONS: contracts.STATUS_OPTIONS, @@ -215,6 +222,7 @@ function createContentApplication(overrides = {}) { createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment }); const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), bindPopover: (...args) => crmSrPopover.bind(...args), closePopover: (...args) => crmSrPopover.close(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), @@ -335,6 +343,7 @@ function createContentApplication(overrides = {}) { environment }); const opportunityWorkloadActions = createOpportunityWorkloadActions({ + allowsCrmActivities: () => currentUser.allowsCrmActivities(), authSessionState, requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), @@ -527,6 +536,7 @@ function createContentApplication(overrides = {}) { contentIcons, contentStyles, authContentSession, + currentUser, authSalesCommon, authTokenRelay, authXsrfContent, diff --git a/src/content/modal.js b/src/content/modal.js index 516e132..2836013 100644 --- a/src/content/modal.js +++ b/src/content/modal.js @@ -56,6 +56,7 @@ function createContentModal({ resetWorkloadActions, reopenWorkloadActions, requestTokenRelayOnce, + ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, resetOpportunitiesTable, restoreFilterPreferences, @@ -82,11 +83,16 @@ function createContentModal({ if (existingModal) { existingModal.hidden = false; - reopenWorkloadActions(); existingModal.querySelector("select").focus(); document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); - requestTokenRelayOnce().then(token => loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion)) + ensureCurrentUser().then(() => { + if (existingModal.hidden) return; + reopenWorkloadActions(); + return requestTokenRelayOnce().then(token => { + if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); + }); + }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; } diff --git a/src/features/debug/content-view.js b/src/features/debug/content-view.js index df4af9c..39565b7 100644 --- a/src/features/debug/content-view.js +++ b/src/features/debug/content-view.js @@ -20,7 +20,7 @@ function createDebugContentView({ let returnFocus = null; function getRequestCategory(entry) { - if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; + if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (entry.name === "requestOpportunities") return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestWlActions") return "actions"; diff --git a/src/features/opportunities/controller.js b/src/features/opportunities/controller.js index bcf329e..2ad1440 100644 --- a/src/features/opportunities/controller.js +++ b/src/features/opportunities/controller.js @@ -18,11 +18,14 @@ function createOpportunitiesController({ resetWorkloadActions, requestOpptyProductsInBackground, requestTokenRelayOnce, + ensureCurrentUser, setOpportunitiesTableState }) { async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + await ensureCurrentUser(); + if (!isCurrentPeriodRequest(requestVersion)) return; if (preferencesState.selectedOpportunityTypeValues.size === 0) { setOpportunitiesTableState({ diff --git a/src/features/opportunities/crm-service-requests.js b/src/features/opportunities/crm-service-requests.js index 7975ca0..988ffd1 100644 --- a/src/features/opportunities/crm-service-requests.js +++ b/src/features/opportunities/crm-service-requests.js @@ -1,11 +1,11 @@ -function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) { +function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) { let status = "idle"; let byOpportunity = new Map(); let pending = null; let generation = 0; function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); } function load(token, version) { - if (!isCurrentPeriodRequest(version)) return; + if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return; if (!pending) pending = run(token, version, generation); return pending; } @@ -30,7 +30,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre } } function createBadge(optyNumber) { - if (!optyNumber || status === "idle" || status === "loading") return null; + if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const items = byOpportunity.get(String(optyNumber)) || []; const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); badge.className = "opportunities-extension-crm-sr-badge"; diff --git a/src/features/opportunities/workload-actions.js b/src/features/opportunities/workload-actions.js index ce7d38c..b2e6e2a 100644 --- a/src/features/opportunities/workload-actions.js +++ b/src/features/opportunities/workload-actions.js @@ -1,4 +1,4 @@ -function createOpportunityWorkloadActions({ authSessionState, requestConsumerTokenServiceOnce, sendRuntimeMessage, +function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) { let session = newSession(); function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; } @@ -12,7 +12,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } function loadProducts(products) { - if (!session.enabled) return; + if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { if (session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; @@ -36,9 +36,9 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions }); - if (!current.enabled || current !== session) return; + if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions."); record.actions = result.actions; @@ -53,7 +53,7 @@ function createOpportunityWorkloadActions({ authSessionState, requestConsumerTok } } function createBadge(optyNumber) { - if (!session.enabled || !optyNumber) return null; + if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null; const products = productsContentState.opptyProductsByNumber.get(String(optyNumber)); if (!products) return null; const ids = idsFor(products); diff --git a/src/services/spa/current-user.js b/src/services/spa/current-user.js new file mode 100644 index 0000000..7aff308 --- /dev/null +++ b/src/services/spa/current-user.js @@ -0,0 +1,27 @@ +function createSpaCurrentUser({ fetchJson }) { + async function requestCurrentUser() { + const debugRequests = []; + let user = null; + try { + const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { + throw new Error("requestCurrentUser returned invalid user roles."); + } + user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } + error.debugRequests = debugRequests; + throw error; + } finally { + // The identity token is not needed by content scripts or debug, including on failures. + for (const entry of debugRequests) { + entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2); + entry.responseBodyTruncated = false; + } + } + } + return { requestCurrentUser }; +} diff --git a/src/shared/messages.js b/src/shared/messages.js index 918d46b..1313422 100644 --- a/src/shared/messages.js +++ b/src/shared/messages.js @@ -11,6 +11,7 @@ function createMessageTypes() { requestInstallBase: "opportunitiesExtension.requestInstallBase", requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest", requestWlActions: "opportunitiesExtension.requestWlActions", + requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail", requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan" diff --git a/tests/crmServiceRequests.test.cjs b/tests/crmServiceRequests.test.cjs index cbef906..86d458b 100644 --- a/tests/crmServiceRequests.test.cjs +++ b/tests/crmServiceRequests.test.cjs @@ -5,6 +5,7 @@ const response = data => new Response(JSON.stringify(data), { status: 200 }); function setup(fetch) { const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) }; const app = loadApplication("content", { fetch, document, Intl, Date }); + app.currentUser.allowsCrmActivities = () => true; app.debugContentView.renderDebugPanel = () => {}; app.opportunitiesQueryState.periodRequestVersion = 1; return app; diff --git a/tests/crmSrPopover.test.cjs b/tests/crmSrPopover.test.cjs index b25181d..bbc9337 100644 --- a/tests/crmSrPopover.test.cjs +++ b/tests/crmSrPopover.test.cjs @@ -32,6 +32,7 @@ function setup() { const copied = []; const app = loadApplication("content", { document, window, setTimeout, clearTimeout, navigator: { clipboard: { writeText: async value => copied.push(value) } } }); + app.currentUser.allowsCrmActivities = () => true; // Keep the copy test independent of the toast's own DOM/timer implementation. modal.querySelector = () => ({ classList: { toggle() {} } }); window.setTimeout = () => 0; diff --git a/tests/currentUser.test.cjs b/tests/currentUser.test.cjs new file mode 100644 index 0000000..52e3410 --- /dev/null +++ b/tests/currentUser.test.cjs @@ -0,0 +1,105 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { loadBackground, response } = require("./support/characterization.cjs"); +const { loadApplication } = require("./support/application.cjs"); +const type = "opportunitiesExtension.requestCurrentUser"; + +for (const family of ["chrome", "firefox"]) { + test(`${family}: current user GET uses SPA cookies and discards identity token before messaging/debug`, async () => { + const calls = []; + const app = loadBackground({ family, fetch: async (url, options) => { + calls.push({ url, options }); + return response({ user: { id: "synthetic@example.invalid", roles: ["SALES_REP", "SIMPLE_USER"] }, token: "synthetic-secret" }); + } }); + const result = await app.dispatch({ type }); + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.credentials, "include"); + assert.equal(calls[0].options.cache, "no-store"); + assert.equal(result.debugRequests[0].name, "requestCurrentUser"); + assert.equal(result.debugRequests[0].httpStatus, 200); + assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/); + assert.equal(result.token, undefined); + }); +} + +test("identity failures and malformed roles remain explicit and never expose response tokens", async () => { + for (const fetch of [ + async () => new Response("synthetic-secretfallback"), + async () => response({ token: "synthetic-secret", user: {} }), + async () => response({ token: "synthetic-secret", user: { roles: [null] } }), + async () => response({ token: "synthetic-secret" }, 401), + async () => { throw new Error("Synthetic network failure"); } + ]) { + const app = loadBackground({ fetch }); + const result = await app.dispatch({ type }); + assert.equal(result.ok, false); + assert.equal(result.debugRequests[0].status, "failed"); + assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/); + } +}); + +function content(identity) { + const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } }); + app.debugContentView.renderDebugPanel = () => {}; + const calls = []; + app.platformContentMessages.sendRuntimeMessage = async message => { + calls.push(message.type); + if (message.type === type) return identity(); + return { ok: true, actions: [], hasNext: false }; + }; + app.authContentSession.requestTokenRelayOnce = async () => { calls.push("tokenrelay"); return "synthetic"; }; + app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } }); + app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { calls.push("crm"); return []; }; + app.fusionOpportunities.requestOpportunitiesPage = async () => { calls.push("opportunities"); return []; }; + app.productsContentJobs.renderProductsProgress = () => {}; + app.productsContentJobs.requestOpptyProductsInBackground = () => {}; + app.productsContentJobs.queueProductsTableRender = () => {}; + app.opportunitiesTable.setOpportunitiesTableState = () => {}; + app.preferencesState.selectedOpportunityTypeValues = new Set(["ORA_ALLOPTIES"]); + return { app, calls }; +} + +for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOLUTION_ENG_NOTERR"], true ], [ [], true ]]) { + test(`identity runs first, once; CRM/Actions enabled=${allowed} for ${JSON.stringify(roles)}`, async () => { + let finish; + const { app, calls } = content(() => new Promise(resolve => { finish = resolve; })); + const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter"); + assert.deepEqual(calls, [type]); + assert.equal(app.currentUser.allowsCrmActivities(), false); + const same = app.currentUser.ensure(); + assert.equal(same, app.currentUser.ensure()); + finish({ ok: true, user: { roles } }); + await first; + assert.deepEqual(calls, allowed ? [type, "tokenrelay", "crm", "opportunities"] : [type, "tokenrelay", "opportunities"]); + const products = { items: [{ RevnId: "123" }] }; + app.productsContentState.opptyProductsByNumber.set("OP", products); + app.opportunityWorkloadActions.loadProducts(products); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed); + if (!allowed) { + assert.equal(app.opportunityCrmServiceRequests.createBadge("OP"), null); + assert.equal(app.opportunityWorkloadActions.createBadge("OP"), null); + } + await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter"); + app.opportunityCrmServiceRequests.reset(); + app.opportunityWorkloadActions.reopen(); + await app.currentUser.ensure(); + assert.equal(calls.filter(value => value === type).length, 1); + }); +} + +test("identity failure disables only CRM/Actions and does not retry on Reference changes", async () => { + for (const identity of [async () => ({ ok: false }), async () => ({ ok: true, user: {} }), async () => { throw new Error("Offline"); }]) { + const { app, calls } = content(identity); + await app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter"); + await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter"); + assert.equal(calls.filter(value => value === type).length, 1); + assert.equal(calls.filter(value => value === "opportunities").length, 2); + assert.equal(calls.includes("crm"), false); + app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "123" }] }); + assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), false); + } +}); diff --git a/tests/opportunityTable.test.cjs b/tests/opportunityTable.test.cjs index 06441ca..521880f 100644 --- a/tests/opportunityTable.test.cjs +++ b/tests/opportunityTable.test.cjs @@ -36,6 +36,7 @@ function setup(items = [synthetic()]) { const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null }; const app = loadApplication("content", { document, Intl, Date }); + app.currentUser.allowsCrmActivities = () => true; const summaries = []; app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number); app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items }); diff --git a/tests/workloadActions.test.cjs b/tests/workloadActions.test.cjs index 8285431..eaf3cf6 100644 --- a/tests/workloadActions.test.cjs +++ b/tests/workloadActions.test.cjs @@ -53,6 +53,7 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO function contentFixture(send) { const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } }); + app.currentUser.allowsCrmActivities = () => true; app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } }); app.authSessionState.principal = "test@example.invalid"; app.platformContentMessages.sendRuntimeMessage = send; @@ -165,6 +166,7 @@ test("product progress triggers Actions for cached products and ignores stale pr window: { requestAnimationFrame() {} }, chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } } }); + app.currentUser.allowsCrmActivities = () => true; app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } }); app.platformContentMessages.sendRuntimeMessage = async message => { calls.push(message);