Fixed the SPA authentication flow

This commit is contained in:
2026-10-21 15:55:29 -03:00
parent 5ef4c22c59
commit ba61f91c53
33 changed files with 1924 additions and 117 deletions

View File

@@ -154,7 +154,7 @@ for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SAL
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } });
finish({ ok: true, user: { id: "synthetic@example.invalid", name: "Synthetic User", roles } });
await identity;
app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed);

View File

@@ -45,6 +45,8 @@ test("distribution contracts: manifests, execution boundaries and deterministic
content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["content.js"], all_frames: true, run_at: "document_idle"
}, {
matches: ["https://spa.oracle.com/*"], js: ["spa-page.js"], all_frames: false, run_at: "document_idle"
}]
};
for (const target of ["chromium", "firefox"]) {
@@ -57,6 +59,9 @@ test("distribution contracts: manifests, execution boundaries and deterministic
assert.equal(output, composeScript(file.replace(".js", "")));
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` }));
}
const pageScript = fs.readFileSync(path.join(root, "dist", target, "spa-page.js"), "utf8");
assert.equal(pageScript, composeScript("spa-page"));
assert.doesNotThrow(() => new vm.Script(pageScript));
}
});

View File

@@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html");
assert.equal(h.tabs[0].url, "https://spa.oracle.com/");
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[0].httpStatus, status);
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);

View File

@@ -109,7 +109,7 @@ async function run() {
assert.equal(createdTabs[0].active, false);
assert.equal(
createdTabs[0].url,
"https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"
"https://spa.oracle.com/"
);
assert.deepEqual(removedTabs, [27]);
assert.equal(response.debugRequests.length, 2);

View File

@@ -3,13 +3,18 @@ const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser";
const validIdentity = {
user: { id: "synthetic@example.invalid", name: "Synthetic User", onWhiteList: false,
provisioned: true, administrator: false, roles: ["SALES_REP", "SIMPLE_USER"] },
token: "synthetic-secret"
};
for (const family of ["chrome", "firefox"]) {
test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, 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" });
return response(validIdentity);
} });
const result = await app.dispatch({ type });
assert.equal(result.ok, true);
@@ -26,6 +31,8 @@ for (const family of ["chrome", "firefox"]) {
content.debugState.debugSecretsVisible = true;
assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
assert.equal(result.token, undefined);
assert.equal(app.tabs.length, 0, "a valid existing session must not open a tab");
assert.equal(app.removedTabs.length, 0);
});
}
@@ -46,8 +53,116 @@ test("identity failures retain diagnostics but hide response tokens by default",
}
});
function content(identity) {
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } });
test("retry timer is called without the environment object as its receiver", async () => {
let attempts = 0;
let timers = 0;
const { app } = content(() => ++attempts === 1 ? { ok: false } : { ok: true, user: validIdentity.user }, {
setTimeout: function(callback) {
"use strict";
assert.equal(this, undefined, "Window timers reject an arbitrary receiver in Edge");
timers++;
setImmediate(callback);
}
});
await app.currentUser.ensure();
assert.equal(attempts, 2);
assert.equal(timers, 1);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: current-user SSO recovery shares one verified SPA tab and closes on valid JSON`, async () => {
let attempts = 0;
const app = loadBackground({ family, fetch: async (url, options) => {
assert.equal(url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current");
assert.equal(options.credentials, "include");
attempts++;
if (attempts <= 2) return new Response('<html><body onload="document.forms[0].submit()"><form method="POST"></form></body></html>');
assert.equal(app.tabs.length, 1, "SSO must be started before retry can succeed");
assert.equal(app.removedTabs.length, 0, "do not close before a valid identity response");
return response(validIdentity);
} });
const [first, second] = await Promise.all([app.dispatch({ type }), app.dispatch({ type })]);
assert.equal(first.ok, true);
assert.deepEqual(first, second);
assert.equal(attempts, 3);
assert.deepEqual(app.tabs, [{
url: "https://spa.oracle.com/",
active: false, windowId: 3, ...(family === "firefox" ? { cookieStoreId: "test-store" } : {})
}]);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(first.debugRequests[0].metadata.sessionBootstrapState, "opened-inactive-tab");
assert.equal(first.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
assert.equal(first.debugRequests[2].metadata.sessionBootstrapState, "closed-after-valid-json");
});
}
test("expired SPA session recovery handles 401, 403 and network errors, then releases its in-flight request", async () => {
let attempts = 0;
const app = loadBackground({ fetch: async () => {
attempts++;
if (attempts === 1) return response({}, 401);
if (attempts === 2) return response({}, 403);
if (attempts === 3) throw new TypeError("Failed to fetch");
return response(validIdentity);
} });
const first = await app.dispatch({ type });
assert.equal(first.ok, true);
assert.equal(app.tabs.length, 1);
assert.deepEqual(first.debugRequests.map(entry => entry.httpStatus), [401, 403, null, 200]);
assert.deepEqual(app.removedTabs, [91]);
assert.equal((await app.dispatch({ type })).ok, true);
assert.equal(attempts, 5, "background must not persist identity after the shared request settles");
assert.equal(app.tabs.length, 1);
});
test("failed recovery closes its own tab and allows another cycle; server errors do not open SPA", async () => {
let ready = false;
const app = loadBackground({ fetch: async () => ready ? response(validIdentity) : new Response("<html>Sign in</html>") });
const failure = await app.dispatch({ type });
assert.equal(failure.ok, false);
assert.equal(failure.debugRequests.length, 6);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(failure.debugRequests.at(-1).metadata.retryLimitReached, "true");
ready = true;
assert.equal((await app.dispatch({ type })).ok, true);
const unavailable = loadBackground({ fetch: async () => response({}, 503) });
assert.equal((await unavailable.dispatch({ type })).ok, false);
assert.equal(unavailable.tabs.length, 0);
});
test("SPA tab opening failures stop API probes; a later cycle and cleanup failure preserve valid identity", async () => {
let listener;
let attempts = 0;
let opens = 0;
const app = loadApplication("background", {
chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } } }, URL,
fetch: async () => { attempts++; return new Response("<html>SSO</html>"); }
});
app.platformBackgroundApi.tabsCreate = async details => {
assert.equal(details.active, false);
if (++opens === 1) throw new Error("Temporary tab error");
return { id: 92 };
};
app.platformBackgroundApi.wait = async () => {};
app.platformBackgroundApi.tabsSendMessage = async (id, message) => message.type === "opportunitiesExtension.inspectSpaPage"
? { status: "ready", documentId: "test-document" }
: { status: 200, contentType: "application/json", body: JSON.stringify(validIdentity) };
app.platformBackgroundApi.tabsRemove = async id => { assert.equal(id, 92); throw new Error("Tab already closed"); };
app.start();
const dispatch = () => new Promise(resolve => listener({ type }, { tab: { windowId: 3 } }, resolve));
const failure = await dispatch();
assert.equal(failure.ok, false);
assert.equal(attempts, 1);
assert.match(failure.debugRequests[0].metadata.sessionBootstrapState, /tab-open-failed/);
const result = await dispatch();
assert.equal(result.ok, true);
assert.equal(opens, 2);
assert.equal(attempts, 2);
assert.match(result.debugRequests[1].metadata.sessionBootstrapState, /tab-close-failed/);
});
function content(identity, overrides = {}) {
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) }, ...overrides });
app.debugContentView.renderDebugPanel = () => {};
const calls = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
@@ -76,7 +191,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
assert.equal(app.currentUser.allowsCrmActivities(), false);
const same = app.currentUser.ensure();
assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { roles } });
finish({ ok: true, user: { ...validIdentity.user, roles } });
await first;
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
app.productsContentState.currentProductsRequestId = "products-cycle";
@@ -98,15 +213,63 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
});
}
test("identity failure disables only CRM/Actions and does not retry on Reference changes", async () => {
test("identity failures retry with shared backoff instead of being cached as a final result", 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);
const timers = [];
const delays = [];
let attempts = 0;
const { app, calls } = content(() => ++attempts <= 6 ? identity() : { ok: true, user: validIdentity.user }, {
setTimeout: (callback, delay) => { timers.push(callback); delays.push(delay); }
});
const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
const second = app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter");
assert.equal(app.currentUser.ensure(), app.currentUser.ensure());
for (let i = 0; i < 6; i++) {
await new Promise(resolve => setImmediate(resolve));
assert.ok(calls.every(value => value === type));
assert.equal(timers.length, 1);
assert.equal(app.currentUser.getUserId(), "");
timers.shift()();
}
await Promise.all([first, second]);
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 10000, 10000]);
assert.equal(attempts, 7);
assert.deepEqual(calls.slice(-2), ["tokenrelay", "opportunities"]);
await app.currentUser.ensure();
assert.equal(attempts, 7);
assert.equal(app.currentUser.getUserId(), validIdentity.user.id);
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: HTML, malformed JSON and invalid identity objects retry until valid user/token JSON`, async () => {
const payloads = [
() => new Response("<html>Sign in</html>"),
() => new Response('{"user":'),
() => response(null),
() => response([]),
() => response({}),
() => response({ ...validIdentity, token: "" }),
() => response({ ...validIdentity, user: { ...validIdentity.user, id: "" } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, name: null } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, roles: [null] } }),
() => response({ ...validIdentity, user: { ...validIdentity.user, provisioned: "true" } }),
() => response(validIdentity)
];
let attempts = 0;
const background = loadBackground({ family, fetch: async () => payloads[attempts++]() });
const { app } = content(() => background.dispatch({ type }), { setTimeout: callback => setImmediate(callback) });
const logs = [];
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(...entries);
const pending = app.currentUser.ensure();
assert.equal(pending, app.currentUser.ensure());
await pending;
assert.equal(attempts, payloads.length);
assert.equal(app.currentUser.getUserId(), validIdentity.user.id);
assert.equal(app.currentUser.allowsCrmActivities(), false, "valid SALES_REP identity must not retry");
assert.equal(logs.filter(entry => entry.status === "failed").length, payloads.length - 1);
assert.equal(logs.at(-1).status, "success");
await app.currentUser.ensure();
assert.equal(attempts, payloads.length);
});
}

119
tests/spaPage.test.cjs Normal file
View File

@@ -0,0 +1,119 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const vm = require("node:vm");
const { composeScript } = require("../scripts/compose.cjs");
const script = new vm.Script(composeScript("spa-page"));
const inspect = "opportunitiesExtension.inspectSpaPage";
const read = "opportunitiesExtension.readSpaIdentity";
const validIdentity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
function harness({ family = "chrome", origin = "https://spa.oracle.com", subframe = false, fetchResponse } = {}) {
let listener;
const requests = [];
const timers = new Map();
const dom = { pathname: "/oalcrm/", title: "SPA", body: "Sales Planning and Analytics", readyState: "complete", controls: true, loginSelector: "" };
const window = {};
window.top = subframe ? {} : window;
const document = {
get title() { return dom.title; },
get body() { return { innerText: dom.body }; },
get readyState() { return dom.readyState; },
querySelector: selector => selector.startsWith("a[href]") ? dom.controls : Boolean(dom.loginSelector && selector.includes(dom.loginSelector))
};
script.runInNewContext({
window, document, location: { origin, get pathname() { return dom.pathname; } }, performance: { timeOrigin: 123456 },
[family]: { runtime: { id: "test-extension", onMessage: { addListener: callback => { listener = callback; } } } },
AbortController,
setTimeout: callback => { const id = {}; timers.set(id, callback); return id; },
clearTimeout: id => timers.delete(id),
fetch: async (url, options) => {
requests.push({ url, options });
return fetchResponse ? fetchResponse(options) : new Response(JSON.stringify(validIdentity), { headers: { "Content-Type": "application/json", "X-Request-Id": "synthetic" } });
}
});
const send = (type, documentId = "123456:/oalcrm/") => new Promise(resolve => listener({ type, documentId }, { id: "test-extension" }, resolve));
return { dom, send, requests, timers, get listener() { return listener; } };
}
test("SPA inspector is restricted to the top-level SPA origin and trusted extension messages", () => {
assert.equal(harness({ origin: "https://other.example.invalid" }).listener, undefined);
assert.equal(harness({ subframe: true }).listener, undefined);
const h = harness();
assert.equal(h.listener({ type: inspect }, { id: "other-extension" }, () => assert.fail("Must not reply")), false);
assert.equal(h.listener({ type: "unknown" }, { id: "test-extension" }, () => assert.fail("Must not reply")), false);
assert.equal(h.requests.length, 0, "installing the inspector never starts an API request");
});
test("error, SAML/login, blank and incomplete pages never authorize a fetch", async () => {
const cases = [
[{ body: "401 Authorization Required" }, "error"],
[{ title: "403 Forbidden" }, "error"],
[{ body: "Internal Server Error" }, "error"],
[{ pathname: "/cloudgate/v1/oauth2/callback" }, "login"],
[{ title: "Oracle Sign In" }, "login"],
[{ loginSelector: 'input[name="SAMLResponse"]' }, "login"],
[{ loginSelector: 'input[name="SAMLRequest"]' }, "login"],
[{ loginSelector: 'input[type="password"]' }, "login"],
[{ loginSelector: 'meta[http-equiv="refresh" i]' }, "login"],
[{ readyState: "interactive" }, "loading"],
[{ readyState: "loading" }, "loading"],
[{ title: "", body: "" }, "loading"],
[{ controls: false }, "loading"]
];
for (const [dom, expected] of cases) {
const h = harness();
Object.assign(h.dom, dom);
assert.equal((await h.send(inspect)).status, expected, JSON.stringify(dom));
assert.match((await h.send(read)).error, /not ready/);
assert.equal(h.requests.length, 0);
}
});
for (const family of ["chrome", "browser"]) {
test(`${family}: a loaded application reads identity once per document in its own authenticated context`, async () => {
const h = harness({ family });
const page = await h.send(inspect);
assert.equal(page.status, "ready");
const [first, second] = await Promise.all([h.send(read, page.documentId), h.send(read, page.documentId)]);
assert.deepEqual(first, second);
assert.deepEqual(JSON.parse(first.body), validIdentity);
assert.equal(first.headers["x-request-id"], "synthetic");
assert.equal((await h.send(read)).body, first.body);
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current");
assert.equal(h.requests[0].options.method, "GET");
assert.equal(h.requests[0].options.credentials, "include");
assert.equal(h.requests[0].options.cache, "no-store");
assert.equal(h.timers.size, 0);
assert.match((await h.send(read, "old-document")).error, /navigated/);
h.dom.pathname = "/login";
assert.match((await h.send(read)).error, /not ready/);
assert.equal(h.requests.length, 1);
});
}
test("network or HTML failures are not retried in the same SPA document", async () => {
for (const fetchResponse of [
() => { throw new Error("Network failure"); },
() => new Response('<html><form><input name="SAMLResponse"></form></html>', { headers: { "Content-Type": "text/html" } })
]) {
const h = harness({ fetchResponse });
const first = await h.send(read);
for (let i = 0; i < 20; i++) assert.deepEqual(await h.send(read), first);
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0);
}
});
test("a stalled identity fetch is aborted and never leaves a retry timer", async () => {
const h = harness({ fetchResponse: options => new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => reject(new Error("Timed out")));
}) });
const pending = h.send(read);
assert.equal(h.timers.size, 1);
[...h.timers.values()][0]();
assert.equal((await pending).error, "Timed out");
assert.equal(h.timers.size, 0);
await h.send(read);
assert.equal(h.requests.length, 1);
});

View File

@@ -0,0 +1,87 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const currentUser = "opportunitiesExtension.requestCurrentUser";
const consumer = "opportunitiesExtension.requestConsumerTokenService";
const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
];
const html = () => new Response("<html>Sign in</html>");
function navigation(app, overrides = {}) {
const observer = app.responseHeaderListeners.find(item => item.filter.types.includes("main_frame"));
assert.ok(observer);
observer.listener({ tabId: 91, type: "main_frame", statusCode: 401,
url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=do-not-replay", ...overrides });
}
for (const family of ["chrome", "firefox"]) {
for (const type of [currentUser, consumer]) {
test(`${family}: ${type} restarts a rejected callback at SPA home in the same inactive tab`, async () => {
let calls = 0;
const app = loadBackground({ family, fetch: async () => {
calls++;
if (calls === 1) return html();
if (calls === 2) { navigation(app); return html(); }
assert.deepEqual(app.updatedTabs, [{ id: 91, details: { url: "https://spa.oracle.com/", active: false } }]);
return response(type === currentUser ? identity : tokens);
} });
const result = await app.dispatch({ type });
assert.equal(result.ok, true);
assert.equal(app.tabs.length, 1);
assert.deepEqual(app.removedTabs, [91]);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadState, "restarted-at-spa-home");
assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadReason, /401/);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /do-not-replay/);
assert.equal(app.responseHeaderListeners.filter(item => item.filter.types.includes("main_frame")).length, 0);
});
}
test(`${family}: failed navigation is recorded and retried after the cooldown without opening another tab`, async () => {
let calls = 0;
const app = loadBackground({ family, updateTabError: true, fetch: async () => {
calls++;
if (calls === 2) navigation(app, { statusCode: 403 });
return calls < 5 ? html() : response(identity);
} });
const result = await app.dispatch({ type: currentUser });
assert.equal(result.ok, false);
assert.equal(calls, 2, "failed navigation must never release another identity probe");
assert.equal(app.updatedTabs.length, 3, "at most three restart attempts per session wait");
assert.equal(app.tabs.length, 1);
assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadState, /restart-failed/);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadCount, "3");
assert.deepEqual(app.removedTabs, [91]);
});
}
test("only errors in the owned SPA document trigger navigation, never other tabs, frames or hosts", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => {
if (++calls === 1) return html();
if (calls === 2) {
navigation(app, { tabId: 7 });
navigation(app, { type: "sub_frame" });
navigation(app, { type: "xmlhttprequest" });
navigation(app, { url: "https://other.example.invalid/callback" });
navigation(app, { statusCode: 302 });
assert.equal(app.updatedTabs.length, 0);
}
return response(identity);
} });
assert.equal((await app.dispatch({ type: currentUser })).ok, true);
});
test("a successful SPA navigation clears an earlier failure before it can be restarted", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => {
if (++calls === 1) return html();
if (calls === 2) {
navigation(app);
navigation(app, { url: "https://spa.oracle.com/", statusCode: 200 });
assert.equal(app.updatedTabs.length, 0);
}
return response(identity);
} });
assert.equal((await app.dispatch({ type: currentUser })).ok, true);
});

View File

@@ -0,0 +1,181 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser";
const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" };
const flush = () => new Promise(resolve => setImmediate(resolve));
function harness({ validAfter = 0 } = {}) {
let now = Date.UTC(2026, 9, 21);
let sequence = 0;
let backgroundCalls = 0;
let tabCalls = 0;
let listener;
let page = { status: "loading", documentId: "first-document" };
const timers = new Map();
const tabs = [];
const updates = [];
const removed = [];
function event() {
const listeners = new Set();
return { listeners, addListener: callback => listeners.add(callback), removeListener: callback => listeners.delete(callback),
emit: value => [...listeners].forEach(callback => callback(value)) };
}
const headers = event();
const cookies = event();
class ClockDate extends Date {
constructor(...args) { super(...(args.length ? args : [now])); }
static now() { return now; }
}
const app = loadApplication("background", {
chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } },
webRequest: { onHeadersReceived: headers }, cookies: { onChanged: cookies } }, URL,
Date: ClockDate,
setTimeout: function(callback, delay) {
"use strict";
assert.equal(this, undefined);
const id = ++sequence;
timers.set(id, { callback, at: now + delay });
return id;
},
clearTimeout: id => timers.delete(id),
fetch: async () => { backgroundCalls++; return new Response("<html>SSO</html>"); }
});
app.platformBackgroundApi.tabsCreate = async details => { tabs.push(details); return { id: 91 }; };
app.platformBackgroundApi.tabsUpdate = async (id, details) => { updates.push({ id, details }); return { id }; };
app.platformBackgroundApi.tabsRemove = async id => { removed.push(id); };
app.platformBackgroundApi.tabsSendMessage = async (id, message, options) => {
assert.equal(id, 91);
assert.equal(options.frameId, 0);
if (message.type === "opportunitiesExtension.inspectSpaPage") return page;
assert.equal(message.type, "opportunitiesExtension.readSpaIdentity");
assert.equal(message.documentId, page.documentId);
assert.equal(page.status, "ready");
const valid = ++tabCalls > validAfter;
return { status: 200, contentType: valid ? "application/json" : "text/html", body: valid ? JSON.stringify(identity) : "<html>SSO</html>" };
};
app.start();
function dispatch() {
return new Promise(resolve => listener({ type }, { tab: { id: 7, windowId: 3, cookieStoreId: "store-a" } }, resolve));
}
async function advance(ms) {
const until = now + ms;
for (;;) {
const next = [...timers].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
now = next[1].at;
timers.delete(next[0]);
next[1].callback();
await flush();
}
now = until;
await flush();
}
function navigation(overrides = {}) {
headers.emit({ tabId: 91, type: "main_frame", statusCode: 200, url: "https://spa.oracle.com/", ...overrides });
}
function cookie() {
cookies.emit({ removed: false, cookie: { name: "synthetic-session", value: "never-log-this-cookie", domain: "spa.oracle.com",
storeId: "store-a", secure: true, httpOnly: true } });
}
return { dispatch, advance, navigation, cookie, tabs, updates, removed, timers, cookies, headers,
setPage: (status, documentId = "first-document") => { page = { status, documentId }; },
get backgroundCalls() { return backgroundCalls; }, get tabCalls() { return tabCalls; } };
}
test("HTTP 200, cookies and sixty seconds do not release identity GETs on a login page", async () => {
const h = harness();
const first = h.dispatch();
const second = h.dispatch();
await flush();
h.setPage("login");
h.navigation();
h.cookie();
await h.advance(60000);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 0);
h.setPage("ready");
await h.advance(1500);
const [result, shared] = await Promise.all([first, second]);
assert.equal(result.ok, true);
assert.deepEqual(result, shared);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 1, "identity is read in the verified SPA tab, not in the background");
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removed, [91]);
assert.equal(h.timers.size, 0);
assert.equal(h.cookies.listeners.size, 0);
assert.equal(h.headers.listeners.size, 1, "only the unrelated SR observer remains");
assert.equal(result.debugRequests[0].metadata.sessionWaitReason, "spa-document-verified");
assert.equal(result.debugRequests[1].metadata.executionContext, "spa-tab");
assert.doesNotMatch(JSON.stringify(result), /never-log-this-cookie/);
});
test("callback 401 restarts only the owned SPA home; another HTTP 200 error page still blocks GETs", async () => {
const h = harness();
const pending = h.dispatch();
await flush();
h.setPage("error");
h.navigation({ statusCode: 401, url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=never-replay" });
await h.advance(1000);
assert.equal(h.updates.length, 1);
assert.equal(h.updates[0].details.url, "https://spa.oracle.com/");
assert.equal(h.updates[0].details.active, false);
h.navigation();
await h.advance(10000);
assert.equal(h.tabCalls, 0);
assert.equal(h.updates.length, 1, "do not repeatedly restart a document already being navigated away from");
h.setPage("ready", "new-document");
await h.advance(1500);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 1);
});
test("timeout fails closed with no fallback API request", async () => {
const h = harness();
const pending = h.dispatch();
await flush();
h.navigation();
h.cookie();
await h.advance(120000);
const result = await pending;
assert.equal(result.ok, false);
assert.match(result.error, /did not finish authentication/);
assert.equal(result.debugRequests.length, 1);
assert.equal(h.backgroundCalls, 1);
assert.equal(h.tabCalls, 0);
assert.deepEqual(h.removed, [91]);
assert.equal(h.timers.size, 0);
});
test("invalid identity requires a new verified document; the old ready document is never probed twice", async () => {
const h = harness({ validAfter: 1 });
h.setPage("ready");
const pending = h.dispatch();
await flush();
await h.advance(500);
assert.equal(h.tabCalls, 1);
assert.equal(h.updates.length, 1);
await h.advance(30000);
assert.equal(h.tabCalls, 1);
assert.equal(h.updates.length, 1);
h.setPage("ready", "reloaded-document");
await h.advance(1500);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 2);
assert.equal(h.backgroundCalls, 1);
});
test("a redirect during stable-document confirmation does not trigger a GET", async () => {
const h = harness();
h.setPage("ready");
const pending = h.dispatch();
await flush();
h.setPage("login", "redirect-document");
await h.advance(2000);
assert.equal(h.tabCalls, 0);
h.setPage("ready", "final-document");
await h.advance(2000);
assert.equal((await pending).ok, true);
assert.equal(h.tabCalls, 1);
});

View File

@@ -57,30 +57,53 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null
};
}
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, messageTimeout = 2000 } = {}) {
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, updateTabError = false, messageTimeout = 2000 } = {}) {
const time = clock();
const listeners = [];
const progress = [];
const requestDebug = [];
const tabs = [];
const removedTabs = [];
const updatedTabs = [];
const cookieQueries = [];
const headerListeners = [];
const responseHeaderListeners = [];
const headerRules = [];
let spaDocument = 0;
let identityRequest;
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) },
getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` },
webRequest: {
onBeforeSendHeaders: { addListener: (listener, filter, options) => headerListeners.push({ listener, filter: plain(filter), options: plain(options) }) },
onHeadersReceived: { addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }) }
onHeadersReceived: {
addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }),
removeListener: listener => {
const index = responseHeaderListeners.findIndex(item => item.listener === listener);
if (index >= 0) responseHeaderListeners.splice(index, 1);
}
}
},
declarativeNetRequest: { updateSessionRules: async rules => { if (ruleError) throw new Error("Rule permission denied"); headerRules.push(plain(rules)); } }
};
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value));
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
const newSpaDocument = () => { spaDocument++; identityRequest = null; };
const openTab = details => { tabs.push(plain(details)); newSpaDocument(); return { id: 91 }; };
const send = (tabId, message, options) => {
if (message.type === "opportunitiesExtension.inspectSpaPage") return { status: "ready", documentId: String(spaDocument) };
if (message.type === "opportunitiesExtension.readSpaIdentity") {
assert.equal(message.documentId, String(spaDocument));
if (!identityRequest) identityRequest = (async () => {
try {
const result = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
});
return { status: result.status, statusText: result.statusText, contentType: result.headers.get("content-type"), body: await result.text() };
} catch (error) { return { error: error.message }; }
})();
return identityRequest;
}
const events = message.type === "opportunitiesExtension.opptyProductsRequestDebug" ? requestDebug : progress;
events.push({ tabId, message: plain(message), options: plain(options || {}) });
};
@@ -91,6 +114,12 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
set: async value => { if (storageError) throw new Error("Storage unavailable"); store(value); }
} };
api.tabs = { create: async details => openTab(details), remove: async id => removedTabs.push(id),
update: async (id, details) => {
updatedTabs.push({ id, details: plain(details) });
if (updateTabError) throw new Error("Synthetic navigation failure");
newSpaDocument();
return { id };
},
sendMessage: async (tabId, message, options) => send(tabId, message, options) };
api.cookies = {
get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null,
@@ -107,11 +136,17 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); }
} };
api.tabs = {
update(id, details, callback) {
updatedTabs.push({ id, details: plain(details) });
api.runtime.lastError = updateTabError ? { message: "Synthetic navigation failure" } : undefined;
if (!updateTabError) newSpaDocument();
try { callback({ id }); } finally { delete api.runtime.lastError; }
},
create(details, callback) { callback(openTab(details)); },
remove(id, callback) { removedTabs.push(id); callback(); },
sendMessage(tabId, message, options, callback) {
if (typeof options === "function") { callback = options; options = undefined; }
send(tabId, message, options); callback();
Promise.resolve(send(tabId, message, options)).then(callback);
}
};
api.cookies = {
@@ -126,7 +161,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
};
const context = {
[family === "firefox" ? "browser" : "chrome"]: api,
fetch, URL, Date: time.Date,
fetch, URL, Date: time.Date, clearTimeout,
setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); }
};
new vm.Script(composeScript("background"), { filename: "background.js" })
@@ -146,7 +181,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
});
}
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance };
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, updatedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance };
}
module.exports = { root, plain, response, loadContent, loadBackground };

View File

@@ -342,7 +342,7 @@ test("both options requests must succeed before fields enable; email and lookup
test("Action defaults use current user and lookup labels; only Notes is optional", async () => {
const h = optionsSetup();
const identity = h.app.currentUser.ensure();
h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", roles: ["SIMPLE_USER"] } });
h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", name: "Synthetic Owner", roles: ["SIMPLE_USER"] } });
await identity;
assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid");
h.calls.length = 0;