diff --git a/dist/chromium/background.js b/dist/chromium/background.js index fa92db7..50033b2 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -752,10 +752,14 @@ function createFusionSrDetails({ fetchJson }) { const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId"; const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests" + `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`; - const result = await fetchJson(url, { method: "GET", credentials: "include", + const query = async () => { + const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } - }, "requestInternalServiceRequest", debugRequests); - if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + }, "requestInternalServiceRequest", debugRequests); + if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + return result; + }; + const result = await query(); const items = result.items.flatMap(item => { const children = item.ServiceRequest_SRToInternalSR_Tgt; if (children == null) return []; @@ -1966,7 +1970,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) { const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup; - request(message).then(sendResponse).catch(error => sendResponse({ + request(message, sender).then(sendResponse).catch(error => sendResponse({ ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); @@ -2173,9 +2177,11 @@ function createBackgroundApplication(overrides = {}) { const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; - const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const fusionSrDetails = createFusionSrDetails({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) + }); function start() { - startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 8aefe43..8e8a0dd 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -2620,6 +2620,62 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque return { bind, close }; } +// Source: src/features/opportunities/internal-sr-session.js +function createInternalSrSession({ query, environment }) { + const { document, setTimeout } = environment; + let recovery = null; + const wait = duration => new Promise(resolve => setTimeout(resolve, duration)); + const now = () => environment.performance?.now() ?? Date.now(); + async function warmSession(id) { + const frame = document.createElement("iframe"); + frame.src = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr"; + frame.title = "Internal SR session initialization"; + frame.tabIndex = -1; + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;"; + let blocked = false; + let loaded = false; + frame.addEventListener("error", () => { blocked = true; }); + frame.addEventListener("load", () => { loaded = true; }); + try { + if (!document.body) throw new Error("The current page cannot initialize the Internal SR session."); + document.body.append(frame); + const deadline = now() + 60000; + for (let attempt = 0; now() < deadline; attempt += 1) { + // Check immediately, then briefly poll faster before backing off. + if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now())); + if (now() >= deadline) break; + if (blocked) throw new Error("The Internal SR session iframe could not be loaded. Sign in manually and try again."); + const result = await query(id); + if (result?.ok) return { id, result }; + if (result?.httpStatus !== 401) throw new Error(result?.error || "Unable to load Internal SR details."); + } + throw new Error(`Internal SR session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`); + } finally { + // Stop remaining page resources without stopping the caller's page. + try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ } + frame.src = "about:blank"; + frame.remove(); + } + } + async function request(id) { + let result = await query(id); + if (!result?.ok && result?.httpStatus === 401) { + if (!recovery) { + recovery = warmSession(id); + const pending = recovery; + pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {}); + } + const recovered = await recovery; + result = recovered.id === id ? recovered.result : await query(id); + } + if (!result?.ok) throw new Error(result?.error || "Unable to load Internal SR details."); + return result.items; + } + return { request }; +} + // Source: src/features/opportunities/workload-actions.js function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) { @@ -8061,13 +8117,16 @@ function createContentApplication(overrides = {}) { readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), maskToken: (...args) => debugContentLog.maskToken(...args), environment }); - const crmSrPopover = createCrmSrPopover({ - requestDetails: async srId => { + const internalSrSession = createInternalSrSession({ + environment, + query: async srId => { const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); - if (!result?.ok) throw new Error(result?.error || "Unable to load SR details."); - return result.items; - }, + return result; + } + }); + const crmSrPopover = createCrmSrPopover({ + requestDetails: srId => internalSrSession.request(srId), MODAL_ID: contracts.MODAL_ID, createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment diff --git a/dist/firefox/background.js b/dist/firefox/background.js index fa92db7..50033b2 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -752,10 +752,14 @@ function createFusionSrDetails({ fetchJson }) { const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId"; const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests" + `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`; - const result = await fetchJson(url, { method: "GET", credentials: "include", + const query = async () => { + const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } - }, "requestInternalServiceRequest", debugRequests); - if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + }, "requestInternalServiceRequest", debugRequests); + if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + return result; + }; + const result = await query(); const items = result.items.flatMap(item => { const children = item.ServiceRequest_SRToInternalSR_Tgt; if (children == null) return []; @@ -1966,7 +1970,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) { const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup; - request(message).then(sendResponse).catch(error => sendResponse({ + request(message, sender).then(sendResponse).catch(error => sendResponse({ ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); @@ -2173,9 +2177,11 @@ function createBackgroundApplication(overrides = {}) { const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; - const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const fusionSrDetails = createFusionSrDetails({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) + }); function start() { - startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 8aefe43..8e8a0dd 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -2620,6 +2620,62 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque return { bind, close }; } +// Source: src/features/opportunities/internal-sr-session.js +function createInternalSrSession({ query, environment }) { + const { document, setTimeout } = environment; + let recovery = null; + const wait = duration => new Promise(resolve => setTimeout(resolve, duration)); + const now = () => environment.performance?.now() ?? Date.now(); + async function warmSession(id) { + const frame = document.createElement("iframe"); + frame.src = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr"; + frame.title = "Internal SR session initialization"; + frame.tabIndex = -1; + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;"; + let blocked = false; + let loaded = false; + frame.addEventListener("error", () => { blocked = true; }); + frame.addEventListener("load", () => { loaded = true; }); + try { + if (!document.body) throw new Error("The current page cannot initialize the Internal SR session."); + document.body.append(frame); + const deadline = now() + 60000; + for (let attempt = 0; now() < deadline; attempt += 1) { + // Check immediately, then briefly poll faster before backing off. + if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now())); + if (now() >= deadline) break; + if (blocked) throw new Error("The Internal SR session iframe could not be loaded. Sign in manually and try again."); + const result = await query(id); + if (result?.ok) return { id, result }; + if (result?.httpStatus !== 401) throw new Error(result?.error || "Unable to load Internal SR details."); + } + throw new Error(`Internal SR session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`); + } finally { + // Stop remaining page resources without stopping the caller's page. + try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ } + frame.src = "about:blank"; + frame.remove(); + } + } + async function request(id) { + let result = await query(id); + if (!result?.ok && result?.httpStatus === 401) { + if (!recovery) { + recovery = warmSession(id); + const pending = recovery; + pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {}); + } + const recovered = await recovery; + result = recovered.id === id ? recovered.result : await query(id); + } + if (!result?.ok) throw new Error(result?.error || "Unable to load Internal SR details."); + return result.items; + } + return { request }; +} + // Source: src/features/opportunities/workload-actions.js function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) { @@ -8061,13 +8117,16 @@ function createContentApplication(overrides = {}) { readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), maskToken: (...args) => debugContentLog.maskToken(...args), environment }); - const crmSrPopover = createCrmSrPopover({ - requestDetails: async srId => { + const internalSrSession = createInternalSrSession({ + environment, + query: async srId => { const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); - if (!result?.ok) throw new Error(result?.error || "Unable to load SR details."); - return result.items; - }, + return result; + } + }); + const crmSrPopover = createCrmSrPopover({ + requestDetails: srId => internalSrSession.request(srId), MODAL_ID: contracts.MODAL_ID, createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment diff --git a/scripts/sources.cjs b/scripts/sources.cjs index 8f4ab17..fbc509b 100644 --- a/scripts/sources.cjs +++ b/scripts/sources.cjs @@ -39,6 +39,7 @@ module.exports = { "src/features/opportunities/activity-progress.js", "src/features/opportunities/activity-filters.js", "src/features/opportunities/crm-sr-popover.js", + "src/features/opportunities/internal-sr-session.js", "src/features/opportunities/workload-actions.js", "src/features/opportunities/workload-action-form.js", "src/features/opportunities/workload-action-options.js", diff --git a/src/background/application.js b/src/background/application.js index 9a8192c..fc00bda 100644 --- a/src/background/application.js +++ b/src/background/application.js @@ -125,9 +125,11 @@ function createBackgroundApplication(overrides = {}) { const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const runtimeApi = environment.browser || environment.chrome; - const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); + const fusionSrDetails = createFusionSrDetails({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) + }); function start() { - startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); + startBackgroundApplication({ messageTypes, requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); } return { start, contracts, diff --git a/src/background/messages.js b/src/background/messages.js index abb9176..6722983 100644 --- a/src/background/messages.js +++ b/src/background/messages.js @@ -108,7 +108,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) { const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup; - request(message).then(sendResponse).catch(error => sendResponse({ + request(message, sender).then(sendResponse).catch(error => sendResponse({ ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); diff --git a/src/content/application.js b/src/content/application.js index bfbd2e2..d0d0df5 100644 --- a/src/content/application.js +++ b/src/content/application.js @@ -216,13 +216,16 @@ function createContentApplication(overrides = {}) { readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), maskToken: (...args) => debugContentLog.maskToken(...args), environment }); - const crmSrPopover = createCrmSrPopover({ - requestDetails: async srId => { + const internalSrSession = createInternalSrSession({ + environment, + query: async srId => { const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); - if (!result?.ok) throw new Error(result?.error || "Unable to load SR details."); - return result.items; - }, + return result; + } + }); + const crmSrPopover = createCrmSrPopover({ + requestDetails: srId => internalSrSession.request(srId), MODAL_ID: contracts.MODAL_ID, createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment diff --git a/src/features/opportunities/internal-sr-session.js b/src/features/opportunities/internal-sr-session.js new file mode 100644 index 0000000..0a1a617 --- /dev/null +++ b/src/features/opportunities/internal-sr-session.js @@ -0,0 +1,54 @@ +function createInternalSrSession({ query, environment }) { + const { document, setTimeout } = environment; + let recovery = null; + const wait = duration => new Promise(resolve => setTimeout(resolve, duration)); + const now = () => environment.performance?.now() ?? Date.now(); + async function warmSession(id) { + const frame = document.createElement("iframe"); + frame.src = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr"; + frame.title = "Internal SR session initialization"; + frame.tabIndex = -1; + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;"; + let blocked = false; + let loaded = false; + frame.addEventListener("error", () => { blocked = true; }); + frame.addEventListener("load", () => { loaded = true; }); + try { + if (!document.body) throw new Error("The current page cannot initialize the Internal SR session."); + document.body.append(frame); + const deadline = now() + 60000; + for (let attempt = 0; now() < deadline; attempt += 1) { + // Check immediately, then briefly poll faster before backing off. + if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now())); + if (now() >= deadline) break; + if (blocked) throw new Error("The Internal SR session iframe could not be loaded. Sign in manually and try again."); + const result = await query(id); + if (result?.ok) return { id, result }; + if (result?.httpStatus !== 401) throw new Error(result?.error || "Unable to load Internal SR details."); + } + throw new Error(`Internal SR session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`); + } finally { + // Stop remaining page resources without stopping the caller's page. + try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ } + frame.src = "about:blank"; + frame.remove(); + } + } + async function request(id) { + let result = await query(id); + if (!result?.ok && result?.httpStatus === 401) { + if (!recovery) { + recovery = warmSession(id); + const pending = recovery; + pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {}); + } + const recovered = await recovery; + result = recovered.id === id ? recovered.result : await query(id); + } + if (!result?.ok) throw new Error(result?.error || "Unable to load Internal SR details."); + return result.items; + } + return { request }; +} diff --git a/src/services/fusion/sr-details.js b/src/services/fusion/sr-details.js index 34084f3..881e054 100644 --- a/src/services/fusion/sr-details.js +++ b/src/services/fusion/sr-details.js @@ -7,10 +7,14 @@ function createFusionSrDetails({ fetchJson }) { const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId"; const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests" + `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`; - const result = await fetchJson(url, { method: "GET", credentials: "include", + const query = async () => { + const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } - }, "requestInternalServiceRequest", debugRequests); - if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + }, "requestInternalServiceRequest", debugRequests); + if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response."); + return result; + }; + const result = await query(); const items = result.items.flatMap(item => { const children = item.ServiceRequest_SRToInternalSR_Tgt; if (children == null) return []; diff --git a/tests/crmSrDetails.test.cjs b/tests/crmSrDetails.test.cjs index 0808362..a4ab562 100644 --- a/tests/crmSrDetails.test.cjs +++ b/tests/crmSrDetails.test.cjs @@ -2,6 +2,44 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); const { loadBackground, response } = require("./support/characterization.cjs"); const type = "opportunitiesExtension.requestInternalServiceRequest"; +for (const family of ["chrome", "firefox"]) { + test(`${family}: 401 is returned to the caller without opening tabs`, async () => { + let count = 0; + const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) }); + const result = await h.dispatch({ type, srId: "123" }); + assert.equal(result.ok, false); + assert.equal(result.httpStatus, 401); + assert.equal(count, 1); + assert.equal(h.tabs.length, 0); + assert.deepEqual(h.removedTabs, []); + assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]); + }); +} +test("concurrent 401 responses do not cause background tab recovery", async () => { + let calls = 0; + const h = loadBackground({ fetch: async url => { + if (++calls <= 2) return response({}, 401); + const id = new URL(url).searchParams.get("q"); + return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] }); + } }); + const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]); + assert.equal(h.tabs.length, 0); + assert.deepEqual(h.removedTabs, []); + assert.ok(results.every(result => result.httpStatus === 401)); +}); +test("background never creates recovery tabs after an error", async () => { + for (const status of [401, 500]) { + let count = 0; + const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) }); + const result = await h.dispatch({ type, srId: "1" }); + assert.equal(result.ok, false); + assert.equal(count, 1); + assert.deepEqual(h.removedTabs, []); + } + const h = loadBackground({ fetch: async () => response({}, 403) }); + assert.equal((await h.dispatch({ type, srId: "1" })).ok, false); + assert.equal(h.tabs.length, 0); +}); for (const family of ["chrome", "firefox"]) { test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => { const calls = []; diff --git a/tests/crmSrPopover.test.cjs b/tests/crmSrPopover.test.cjs index 542758f..f219e48 100644 --- a/tests/crmSrPopover.test.cjs +++ b/tests/crmSrPopover.test.cjs @@ -3,7 +3,7 @@ const { test } = require("node:test"); const { loadApplication } = require("./support/application.cjs"); const flush = () => new Promise(resolve => setImmediate(resolve)); -test("internal SR uses Session without TokenRelay or retries on 401", async () => { +test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => { const h = setup(); let renewals = 0; const sent = []; @@ -24,7 +24,7 @@ test("internal SR uses Session without TokenRelay or retries on 401", async () = assert.equal(sent.length, 1); assert.equal(sent[0].token, undefined); assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest")); - assert.ok(h.walk(h.panel()).some(node => node.textContent === "Unauthorized")); + assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert")); }); function setup() { diff --git a/tests/internalSrSession.test.cjs b/tests/internalSrSession.test.cjs new file mode 100644 index 0000000..6f4dbe4 --- /dev/null +++ b/tests/internalSrSession.test.cjs @@ -0,0 +1,99 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); +const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`); +function setup(query) { + let elapsed = 0; + const frames = [], removed = [], delays = [], stopped = []; + const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } }; + const document = { body, createElement(tag) { + assert.equal(tag, "iframe"); + return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; }, + addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } }; + } }; + const api = factory({ query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } }); + return { api, frames, removed, delays, stopped }; +} +test("401 initializes a hidden iframe in the calling document and removes it on success", async () => { + let calls = 0; + const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] }); + const items = await h.api.request("1"); + assert.equal(items[0].SrId, 1); + assert.equal(h.frames.length, 1); + const frame = h.frames[0]; + assert.equal(frame.tabIndex, -1); + assert.equal(frame.attributes["aria-hidden"], "true"); + assert.match(frame.style.cssText, /pointer-events:none/); + assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation")); + assert.ok(frame.initialSrc.endsWith("&view=showisr")); + assert.equal(frame.src, "about:blank"); + assert.equal(h.stopped.length, 1); + assert.deepEqual(h.delays, [250]); + assert.equal(h.removed[0], frame); +}); + +test("an immediately available session stops the iframe without waiting for load or a timer", async () => { + let calls = 0; + const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] }); + await h.api.request("1"); + assert.equal(calls, 2); + assert.equal(h.delays.length, 0); + assert.equal(h.stopped.length, 1); + assert.equal(h.removed.length, 1); +}); + +test("cross-origin redirects still allow iframe removal when stop is blocked", async () => { + let calls = 0; + const h = setup(async () => { + if (++calls === 1) return { ok: false, httpStatus: 401 }; + h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); }; + return { ok: true, items: [] }; + }); + await h.api.request("1"); + assert.equal(h.frames[0].src, "about:blank"); + assert.equal(h.removed.length, 1); +}); +test("concurrent queries share one iframe without mixing SR results", async () => { + let calls = 0; + const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] }); + const results = await Promise.all([h.api.request("1"), h.api.request("2")]); + assert.deepEqual(results, [["1"], ["2"]]); + assert.equal(h.frames.length, 1); + assert.equal(h.removed.length, 1); +}); +test("normal success does not mount an iframe; errors and timeout always clean it up", async () => { + const success = setup(async () => ({ ok: true, items: [] })); + await success.api.request("1"); + assert.equal(success.frames.length, 0); + for (const status of [401, 500]) { + let calls = 0; + const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status })); + await assert.rejects(h.api.request("1")); + if (status === 401) { + assert.ok(calls > 31); + assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000); + } else assert.equal(calls, 2); + assert.equal(h.removed.length, 1); + } +}); + +test("slow session initialization can succeed after the old thirty-attempt limit", async () => { + let calls = 0; + const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] }); + assert.deepEqual(await h.api.request("1"), ["ready"]); + assert.equal(calls, 45); + assert.equal(h.removed.length, 1); + assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000); +}); + +test("iframe load alone never confirms authentication", async () => { + let calls = 0; + const h = setup(async () => { + if (++calls > 1) h.frames[0].listeners.load(); + return { ok: false, httpStatus: 401 }; + }); + await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/); + assert.equal(h.removed.length, 1); +});