Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a3eeedb

Browse files
committedApr 23, 2024
PB-22623 As a user I should be able to use passbolt serving an invalid certificate if previously allowed it
1 parent 315b617 commit a3eeedb

19 files changed

+1147
-7
lines changed
 

‎Gruntfile.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ module.exports = function (grunt) {
118118
},
119119
service_worker: {
120120
files: [
121-
{ expand: true, cwd: path.src_chrome_mv3, src: 'serviceWorker.js', dest: path.build + 'serviceWorker' }
121+
{ expand: true, cwd: path.src_chrome_mv3, src: 'serviceWorker.js', dest: path.build + 'serviceWorker' },
122+
{ expand: true, cwd: `${path.src_chrome_mv3}/offscreens`, src: 'fetch.html', dest: `${path.build}/offscreens` }
122123
]
123124
},
124125
web_accessible_resources: {
@@ -309,14 +310,15 @@ module.exports = function (grunt) {
309310
*/
310311
build_service_worker_prod: {
311312
command: [
312-
'npm run build:service-worker'
313+
'npm run build:service-worker',
313314
].join(' && ')
314315
},
315316
build_service_worker_debug: {
316317
command: [
317-
'npm run dev:build:service-worker'
318+
'npm run dev:build:service-worker',
318319
].join(' && ')
319320
},
321+
320322
/**
321323
* Build content script
322324
*/

‎package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"scripts": {
7878
"build": "npx grunt build",
7979
"build:background-page": "webpack --config webpack.background-page.config.js",
80-
"build:service-worker": "webpack --config webpack.service-worker.config.js",
80+
"build:service-worker": "webpack --config webpack.service-worker.config.js; webpack --config webpack-offscreens.fetch.config.js",
8181
"build:content-scripts": "npm run build:content-scripts:app; npm run build:content-scripts:browser-integration; npm run build:content-scripts:public-website",
8282
"build:content-scripts:app": "webpack --config webpack-content-scripts.config.js",
8383
"build:content-scripts:browser-integration": "webpack --config webpack-content-scripts.browser-integration.config.js",
@@ -86,7 +86,7 @@
8686
"build:web-accessible-resources:app": "webpack --config webpack-data.config.js; webpack --config webpack-data.download.config.js",
8787
"build:web-accessible-resources:browser-integration": "webpack --config webpack-data.in-form-call-to-action.config.js; webpack --config webpack-data.in-form-menu.config.js",
8888
"dev:build:background-page": "webpack --env debug=true --config webpack.background-page.config.js",
89-
"dev:build:service-worker": "webpack --env debug=true --config webpack.service-worker.config.js",
89+
"dev:build:service-worker": "webpack --env debug=true --config webpack.service-worker.config.js; webpack --env debug=true --config webpack-offscreens.fetch.config.js",
9090
"dev:build:content-scripts": "npm run dev:build:content-scripts:app; npm run dev:build:content-scripts:browser-integration; npm run dev:build:content-scripts:public-website",
9191
"dev:build:content-scripts:app": "webpack --env debug=true --config webpack-content-scripts.config.js",
9292
"dev:build:content-scripts:browser-integration": "webpack --env debug=true --config webpack-content-scripts.browser-integration.config.js",

‎src/chrome-mv3/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import TabService from "../all/background_page/service/tab/tabService";
1919
import OnExtensionUpdateAvailableService
2020
from "../all/background_page/service/extension/onExtensionUpdateAvailableService";
2121
import GlobalAlarmService from "../all/background_page/service/alarm/globalAlarmService";
22+
import ResponseFetchOffscreenService from "./serviceWorker/service/network/responseFetchOffscreenService";
2223

2324
/**
2425
* Load all system requirement
@@ -64,3 +65,8 @@ browser.alarms.onAlarm.removeListener(GlobalAlarmService.exec);
6465
* Add a top-level alarm handler.
6566
*/
6667
browser.alarms.onAlarm.addListener(GlobalAlarmService.exec);
68+
69+
/**
70+
* Handle offscreen fetch responses.
71+
*/
72+
chrome.runtime.onMessage.addListener(ResponseFetchOffscreenService.handleFetchResponse);

‎src/chrome-mv3/manifest.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"downloads",
4646
"cookies",
4747
"clipboardWrite",
48-
"background"
48+
"background",
49+
"offscreen"
4950
],
5051
"host_permissions": [
5152
"*://*/*"

‎src/chrome-mv3/offscreens/fetch.html

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8"/>
5+
<script src="vendors.js"></script>
6+
<script src="fetch.js"></script>
7+
</head>
8+
<body spellcheck="false">
9+
</body>
10+
</html>

‎src/chrome-mv3/offscreens/fetch.js

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
16+
import FetchOffscreenService from "./service/network/fetchOffscreenService";
17+
18+
chrome.runtime.onMessage.addListener(FetchOffscreenService.handleFetchRequest);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
import Validator from "validator";
16+
17+
export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN = "fetch-offscreen";
18+
export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER = "service-worker-fetch-offscreen-response-handler";
19+
export const FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS = "success";
20+
export const FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR = "error";
21+
22+
export default class FetchOffscreenService {
23+
/**
24+
* Handle fetch request.
25+
* @param {object} message Browser runtime.onMessage listener message.
26+
* @returns {Promise<void>}
27+
*/
28+
static async handleFetchRequest(message) {
29+
// Return early if this message isn't meant for the offscreen document.
30+
if (message.target !== SEND_MESSAGE_TARGET_FETCH_OFFSCREEN) {
31+
console.debug("FetchOffscreenService received message not specific to offscreen.");
32+
return;
33+
}
34+
35+
if (!(await FetchOffscreenService.validateMessageData(message.data))) {
36+
return;
37+
}
38+
const {id, resource, options} = message?.data || {};
39+
40+
try {
41+
const response = await fetch(resource, options);
42+
await FetchOffscreenService.handleSuccessResponse(id, response);
43+
} catch (error) {
44+
await FetchOffscreenService.handleErrorResponse(id, error);
45+
}
46+
}
47+
48+
/**
49+
* Validate message data.
50+
* @param {object} messageData The message data
51+
* @returns {Promise<boolean>}
52+
*/
53+
static async validateMessageData(messageData = {}) {
54+
let error;
55+
56+
if (!messageData.id || !Validator.isUUID(messageData.id)) {
57+
error = new Error("FetchOffscreenService: message.id should be a valid uuid.");
58+
} else if (typeof messageData.resource !== "string") {
59+
error = new Error("FetchOffscreenService: message.resource should be a valid valid.");
60+
} else if (typeof messageData.options !== "undefined" && !(messageData.options instanceof Object)) {
61+
error = new Error("FetchOffscreenService: message.options should be an object.");
62+
}
63+
64+
if (error) {
65+
await FetchOffscreenService.handleErrorResponse(messageData.id, error);
66+
return false;
67+
}
68+
69+
return true;
70+
}
71+
72+
/**
73+
* Handle fetch success, and send response to the service worker.
74+
* @param {string} id The fetch offscreen request id
75+
* @param {Response} response The fetch response
76+
* @returns {Promise<void>}
77+
*/
78+
static async handleSuccessResponse(id, response) {
79+
await chrome.runtime.sendMessage({
80+
target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER,
81+
id: id,
82+
type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS,
83+
data: await FetchOffscreenService.serializeResponse(response)
84+
});
85+
}
86+
87+
/**
88+
* Handle fetch error, and communicate it the service worker.
89+
* @param {string} id The fetch offscreen request id
90+
* @param {Error} error The fetch error
91+
* @returns {Promise<void>}
92+
*/
93+
static async handleErrorResponse(id, error) {
94+
console.error(error);
95+
await chrome.runtime.sendMessage({
96+
target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER,
97+
id: id,
98+
type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR,
99+
data: {
100+
name: error?.name,
101+
message: error?.message || "FetchOffscreenService: an unexpected error occurred"
102+
}
103+
});
104+
}
105+
106+
/**
107+
* Serialize the fetch response to return to the service worker.
108+
* @param {Response} response The response to serialize
109+
* @returns {Promise<object>}
110+
*/
111+
static async serializeResponse(response) {
112+
return {
113+
status: response.status,
114+
statusText: response.statusText,
115+
headers: Array.from(response.headers.entries()),
116+
redirected: response.redirected,
117+
url: response.url,
118+
ok: response.ok,
119+
text: await response.text()
120+
};
121+
}
122+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
const {RequestFetchOffscreenService} = require("../serviceWorker/service/network/requestFetchOffscreenService");
16+
17+
module.exports = async(resource, options) => RequestFetchOffscreenService.fetch(resource, options);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
const {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} = require("../../../offscreens/service/network/fetchOffscreenService");
16+
17+
export const IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY = "IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY";
18+
const LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT = "LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT";
19+
const FETCH_OFFSCREEN_DOCUMENT_REASON = "WORKERS";
20+
const OFFSCREEN_URL = "offscreens/fetch.html";
21+
22+
export class RequestFetchOffscreenService {
23+
/**
24+
* Preferred strategy cache.
25+
* @type {boolean|null}
26+
*/
27+
static isFetchOffscreenPreferredCache = null;
28+
29+
/**
30+
* The stack of requests promises callbacks using the request id as reference.
31+
* @type {object}
32+
*/
33+
static offscreenRequestsPromisesCallbacks = {};
34+
35+
/**
36+
* Fetch external service through fetch offscreen document.
37+
* @param {string} resource The fetch url resource, similar to the native fetch resource parameter.
38+
* @param {object} options The fetch options, similar to the native fetch option parameter.
39+
* @returns {Promise<unknown>}
40+
*/
41+
static async fetch(resource, options) {
42+
const fetchStrategy = await RequestFetchOffscreenService.isFetchOffscreenPreferred()
43+
? RequestFetchOffscreenService.fetchOffscreen
44+
: RequestFetchOffscreenService.fetchNative;
45+
46+
return fetchStrategy(resource, options);
47+
}
48+
49+
/**
50+
* Check if the fetch offscreen strategy is preferred.
51+
* @returns {Promise<boolean>}
52+
*/
53+
static async isFetchOffscreenPreferred() {
54+
if (RequestFetchOffscreenService.isFetchOffscreenPreferredCache === null) {
55+
const storageData = await browser.storage.session.get([IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]);
56+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = Boolean(storageData?.[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]);
57+
}
58+
59+
return RequestFetchOffscreenService.isFetchOffscreenPreferredCache;
60+
}
61+
62+
/**
63+
* Perform a fetch using the browser native API. Fallback on the offscreen fetch in case of unexpected error.
64+
* @param {string} resource The fetch url resource, similar to the native fetch resource parameter.
65+
* @param {object} options The fetch options, similar to the native fetch option parameter.
66+
* @returns {Promise<Response>}
67+
*/
68+
static async fetchNative(resource, options) {
69+
try {
70+
return await fetch(resource, options);
71+
} catch (error) {
72+
// Let the fetch happen even if offline in case it requests a local url or a cache, however do not fallback on offscreen strategy in that case.
73+
if (!navigator.onLine) {
74+
throw new Error("RequestFetchOffscreenService::fetchNative: offline error.");
75+
}
76+
console.error("RequestFetchOffscreenService::fetchNative: An error occurred while using the native fetch API, fallback on offscreen strategy until browser restart.", error);
77+
RequestFetchOffscreenService.markFetchOffscreenStrategyAsPreferred();
78+
return await RequestFetchOffscreenService.fetchOffscreen(resource, options);
79+
}
80+
}
81+
82+
/**
83+
* Perform a fetch using the offscreen API.
84+
* @param {string} resource The fetch url resource, similar to the native fetch resource parameter.
85+
* @param {object} options The fetch options, similar to the native fetch option parameter.
86+
* @returns {Promise<Response>}
87+
*/
88+
static async fetchOffscreen(resource, options) {
89+
// Create offscreen document if it does not already exist.
90+
await navigator.locks.request(
91+
LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT,
92+
RequestFetchOffscreenService.createIfNotExistOffscreenDocument);
93+
94+
const offscreenFetchId = crypto.randomUUID();
95+
const offscreenFetchData = RequestFetchOffscreenService.buildOffscreenData(offscreenFetchId, resource, options);
96+
97+
return new Promise((resolve, reject) => {
98+
// Stack the response listener callbacks.
99+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[offscreenFetchId] = {resolve, reject};
100+
return RequestFetchOffscreenService.sendOffscreenMessage(offscreenFetchData)
101+
.catch(reject);
102+
});
103+
}
104+
105+
/**
106+
* Create fetch offscreen document if it does not exist yet.
107+
* @returns {Promise<void>}
108+
*/
109+
static async createIfNotExistOffscreenDocument() {
110+
const existingContexts = await chrome.runtime.getContexts({
111+
contextTypes: ["OFFSCREEN_DOCUMENT"],
112+
documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)]
113+
});
114+
115+
if (existingContexts.length > 0) {
116+
return;
117+
}
118+
119+
await chrome.offscreen.createDocument({
120+
url: OFFSCREEN_URL,
121+
reasons: [FETCH_OFFSCREEN_DOCUMENT_REASON],
122+
justification: "Used to perform fetch to services such as the passbolt API serving invalid certificate.",
123+
});
124+
}
125+
126+
/**
127+
* Build offscreen message data.
128+
* @param {string} id The identifier of the offscreen fetch request.
129+
* @param {string} resource The fetch url resource, similar to the native fetch resource parameter.
130+
* @param {object} fetchOptions The fetch options, similar to the native fetch option parameter.
131+
* @returns {object}
132+
*/
133+
static buildOffscreenData(id, resource, fetchOptions = {}) {
134+
const options = JSON.parse(JSON.stringify(fetchOptions));
135+
136+
// Format FormData fetch options to allow its serialization.
137+
if (fetchOptions?.body instanceof FormData) {
138+
const formDataValues = [];
139+
for (const key of fetchOptions.body.keys()) {
140+
formDataValues.push(`${encodeURIComponent(key)}=${encodeURIComponent(fetchOptions.body.get(key))}`);
141+
}
142+
options.body = formDataValues.join('&');
143+
// Ensure the request content type reflect the content of its body.
144+
options.headers = options.headers ?? {};
145+
options.headers['Content-type'] = 'application/x-www-form-urlencoded';
146+
}
147+
148+
return {id, resource, options};
149+
}
150+
151+
/**
152+
* Send message to the offscreen fetch document.
153+
* @param {object} offscreenData The offscreen message data.
154+
* @param {string} offscreenData.id The identifier of the offscreen fetch request.
155+
* @param {string} offscreenData.resource The fetch url resource, similar to the native fetch resource parameter.
156+
* @param {object} offscreenData.fetchOptions The fetch options, similar to the native fetch option parameter.
157+
* @returns {Promise<*>}
158+
*/
159+
static async sendOffscreenMessage(offscreenData) {
160+
return chrome.runtime.sendMessage({
161+
target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN,
162+
data: offscreenData
163+
});
164+
}
165+
166+
/**
167+
* Mark the fetch offscreen strategy as preferred.
168+
* return {Promise<void>}
169+
*/
170+
static async markFetchOffscreenStrategyAsPreferred() {
171+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = true;
172+
await browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: RequestFetchOffscreenService.isFetchOffscreenPreferredCache});
173+
}
174+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
export const fetchOptionsHeaders = () => ({
16+
"X-CSRF-Token": crypto.randomUUID()
17+
});
18+
19+
export const fetchOptionWithBodyData = () => ({
20+
credentials: "include",
21+
headers: fetchOptionsHeaders(),
22+
body: {
23+
prop1: "value 1",
24+
prop2: "value 2"
25+
}
26+
});
27+
28+
export const fetchOptionsWithBodyFormData = () => {
29+
const formDataBody = (new FormData());
30+
formDataBody.append("prop1", "value 1");
31+
formDataBody.append("prop1", "value 2");
32+
return {
33+
method: "POST",
34+
credentials: "include",
35+
headers: fetchOptionsHeaders(),
36+
body: formDataBody
37+
};
38+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
import each from "jest-each";
16+
import Validator from "validator";
17+
import {enableFetchMocks} from "jest-fetch-mock";
18+
import {
19+
IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY,
20+
RequestFetchOffscreenService
21+
} from "./requestFetchOffscreenService";
22+
import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "../../../offscreens/service/network/fetchOffscreenService";
23+
import {fetchOptionsWithBodyFormData, fetchOptionWithBodyData} from "./requestFetchOffscreenService.test.data";
24+
25+
26+
beforeEach(() => {
27+
enableFetchMocks();
28+
fetch.resetMocks();
29+
jest.clearAllMocks();
30+
// Flush preferred strategy runtime cache.
31+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = null;
32+
});
33+
34+
describe("RequestFetchOffscreenService", () => {
35+
describe("::isFetchOffscreenPreferred", () => {
36+
it("should return false if no value found in runtime or session storage caches", async() => {
37+
expect.assertions(1);
38+
expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toBeFalsy();
39+
});
40+
41+
each([
42+
{label: "true", value: true},
43+
{label: "false", value: false},
44+
]).describe("should return the runtime cached value", scenario => {
45+
it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => {
46+
expect.assertions(1);
47+
// Mock runtime cached value.
48+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = scenario.value;
49+
expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value);
50+
});
51+
});
52+
53+
each([
54+
{label: "true", value: true},
55+
{label: "false", value: false},
56+
]).describe("should return the session storage value if no runtime value is present", scenario => {
57+
it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => {
58+
expect.assertions(1);
59+
// Mock session storage cached value.
60+
browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: scenario.value});
61+
expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value);
62+
});
63+
});
64+
65+
each([
66+
{label: "true", value: true},
67+
{label: "false", value: false},
68+
]).describe("should return the runtime cached value if set and if the session storage value is also set", scenario => {
69+
it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => {
70+
expect.assertions(1);
71+
// Mock runtime cached value.
72+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = scenario.value;
73+
browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: !scenario.value});
74+
expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value);
75+
});
76+
});
77+
});
78+
79+
describe("::createIfNotExistOffscreenDocument", () => {
80+
it("should create the offscreen document if it does not exist yet ", async() => {
81+
expect.assertions(2);
82+
jest.spyOn(chrome.runtime, "getContexts").mockImplementationOnce(() => []);
83+
await RequestFetchOffscreenService.createIfNotExistOffscreenDocument();
84+
85+
const expectedGetContextsData = {
86+
contextTypes: ["OFFSCREEN_DOCUMENT"],
87+
documentUrls: ["chrome-extension://didegimhafipceonhjepacocaffmoppf/offscreens/fetch.html"]
88+
};
89+
const expectedCreateDocumentData = {
90+
url: "offscreens/fetch.html",
91+
reasons: ["WORKERS"],
92+
justification: "Used to perform fetch to services such as the passbolt API serving invalid certificate."
93+
};
94+
expect(chrome.runtime.getContexts).toHaveBeenCalledWith(expectedGetContextsData);
95+
expect(chrome.offscreen.createDocument).toHaveBeenCalledWith(expectedCreateDocumentData);
96+
});
97+
98+
it("should not create the offscreen document if it already exist ", async() => {
99+
expect.assertions(1);
100+
jest.spyOn(chrome.runtime, "getContexts").mockImplementationOnce(() => ["shallow-offscreen-document-mock"]);
101+
await RequestFetchOffscreenService.createIfNotExistOffscreenDocument();
102+
expect(chrome.offscreen.createDocument).not.toHaveBeenCalled();
103+
});
104+
});
105+
106+
describe("::buildOffscreenData", () => {
107+
it("should build data to send to the offscreen document", async() => {
108+
expect.assertions(1);
109+
const id = crypto.randomUUID();
110+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
111+
const options = fetchOptionWithBodyData();
112+
const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options);
113+
// Ensure body remains a form data after serialization.
114+
expect(offscreenData).toEqual({id, resource, options});
115+
});
116+
117+
it("should ensure given fetch options body will not be altered", async() => {
118+
expect.assertions(1);
119+
const id = crypto.randomUUID();
120+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
121+
const fetchOptions = fetchOptionsWithBodyFormData();
122+
RequestFetchOffscreenService.buildOffscreenData(id, resource, fetchOptions);
123+
// Ensure body remains a form data after serialization.
124+
expect(fetchOptions.body).toBeInstanceOf(FormData);
125+
});
126+
127+
it("should transform FormData body into serialized encoded url parameters", async() => {
128+
expect.assertions(1);
129+
const id = crypto.randomUUID();
130+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
131+
const options = fetchOptionsWithBodyFormData();
132+
133+
const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options);
134+
// eslint-disable-next-line object-shorthand
135+
const expectedOffscreenMessageData = {
136+
id,
137+
resource,
138+
options: {
139+
...options,
140+
headers: {
141+
...options.headers,
142+
"Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter.
143+
},
144+
body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter
145+
}
146+
};
147+
expect(offscreenData).toEqual(expectedOffscreenMessageData);
148+
});
149+
});
150+
151+
describe("::sendOffscreenMessage", () => {
152+
it("should send a message to the offscreen document", async() => {
153+
expect.assertions(1);
154+
const data = {prop1: "value1"};
155+
await RequestFetchOffscreenService.sendOffscreenMessage(data);
156+
const target = SEND_MESSAGE_TARGET_FETCH_OFFSCREEN;
157+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({target, data});
158+
});
159+
});
160+
161+
describe("::fetchOffscreen", () => {
162+
it("should send a message to the offscreen document and stack the response callback handlers", async() => {
163+
expect.assertions(4);
164+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
165+
const options = fetchOptionsWithBodyFormData();
166+
jest.spyOn(chrome.runtime, "sendMessage").mockImplementationOnce(message => {
167+
expect(Validator.isUUID(message.data.id)).toBe(true);
168+
const expectedMessageData = {
169+
target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN,
170+
data: {
171+
...message.data,
172+
options: {
173+
...message.data.options,
174+
headers: {
175+
...message.data.options.headers,
176+
"Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter.
177+
},
178+
body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter
179+
}
180+
},
181+
};
182+
expect(message).toEqual(expectedMessageData);
183+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[message.data.id].resolve();
184+
});
185+
const requestPromise = RequestFetchOffscreenService.fetchOffscreen(resource, options);
186+
expect(requestPromise).toBeInstanceOf(Promise);
187+
await expect(requestPromise).resolves.not.toThrow();
188+
});
189+
190+
it("should throw if the message cannot be sent to the offscreen document for unexpected reason", async() => {
191+
expect.assertions(2);
192+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
193+
const options = fetchOptionsWithBodyFormData();
194+
jest.spyOn(chrome.runtime, "sendMessage").mockImplementationOnce(() => {
195+
throw new Error("Test error");
196+
});
197+
const requestPromise = RequestFetchOffscreenService.fetchOffscreen(resource, options);
198+
expect(requestPromise).toBeInstanceOf(Promise);
199+
await expect(requestPromise).rejects.toThrow();
200+
});
201+
});
202+
203+
describe("::fetchNative", () => {
204+
it("should call the native fetch API", async() => {
205+
expect.assertions(2);
206+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
207+
const options = fetchOptionsWithBodyFormData();
208+
fetch.doMockOnce(() => Promise.resolve({}));
209+
const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options);
210+
await expect(requestPromise).resolves.not.toThrow();
211+
expect(fetch).toHaveBeenCalledWith(resource, options);
212+
});
213+
214+
it("should fallback on fetchOffscreen if an unexpected error occurred", async() => {
215+
expect.assertions(3);
216+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
217+
const options = fetchOptionsWithBodyFormData();
218+
fetch.doMockOnce(() => Promise.reject({}));
219+
jest.spyOn(RequestFetchOffscreenService, "fetchOffscreen").mockImplementationOnce(() => jest.fn);
220+
const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options);
221+
await expect(requestPromise).resolves.not.toThrow();
222+
expect(RequestFetchOffscreenService.isFetchOffscreenPreferredCache).toBeTruthy();
223+
expect(RequestFetchOffscreenService.fetchOffscreen).toHaveBeenCalledWith(resource, options);
224+
});
225+
226+
it("should throw and not fallback on fetchOffscreen if the navigator is not online", async() => {
227+
expect.assertions(1);
228+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
229+
const options = fetchOptionsWithBodyFormData();
230+
fetch.doMockOnce(() => Promise.reject({}));
231+
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false);
232+
const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options);
233+
await expect(requestPromise).rejects.toThrow();
234+
});
235+
});
236+
237+
describe("::fetch", () => {
238+
it("should call the native fetch API if offscreen strategy has not been marked as preferred", async() => {
239+
expect.assertions(1);
240+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
241+
const options = fetchOptionsWithBodyFormData();
242+
jest.spyOn(RequestFetchOffscreenService, "fetchNative").mockImplementationOnce(() => jest.fn);
243+
await RequestFetchOffscreenService.fetch(resource, options);
244+
expect(RequestFetchOffscreenService.fetchNative).toHaveBeenCalledWith(resource, options);
245+
});
246+
247+
it("should call the native fetch offscreen if this strategy has been marked as preferred", async() => {
248+
expect.assertions(1);
249+
const resource = "https://test.passbolt.com/passbolt-unit-test/test.json";
250+
const options = fetchOptionsWithBodyFormData();
251+
jest.spyOn(RequestFetchOffscreenService, "fetchOffscreen").mockImplementationOnce(() => jest.fn);
252+
RequestFetchOffscreenService.isFetchOffscreenPreferredCache = true;
253+
await RequestFetchOffscreenService.fetch(resource, options);
254+
expect(RequestFetchOffscreenService.fetchOffscreen).toHaveBeenCalledWith(resource, options);
255+
});
256+
});
257+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
import {assertUuid} from "../../../../all/background_page/utils/assertions";
16+
import {
17+
FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR,
18+
FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS,
19+
SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER
20+
} from "../../../offscreens/service/network/fetchOffscreenService";
21+
import {RequestFetchOffscreenService} from "./requestFetchOffscreenService";
22+
23+
export default class ResponseFetchOffscreenService {
24+
/**
25+
* Handle fetch offscreen response message.
26+
* @param {object} message The message itself.
27+
* @return {void}
28+
*/
29+
static handleFetchResponse(message) {
30+
// Return early if this message isn't meant for the offscreen document.
31+
if (message.target !== SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER) {
32+
console.debug("ResponseFetchOffscreenService: received message not specific to the service worker fetch offscreen response handler.");
33+
return;
34+
}
35+
36+
ResponseFetchOffscreenService.assertMessage(message);
37+
const {id, type, data} = message;
38+
const offscreenRequestPromiseCallbacks = ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id);
39+
40+
if (type === FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS) {
41+
offscreenRequestPromiseCallbacks.resolve(ResponseFetchOffscreenService.buildFetchResponse(data));
42+
} else if (type === FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR) {
43+
offscreenRequestPromiseCallbacks.reject(new Error(data.message));
44+
}
45+
}
46+
47+
/**
48+
* Assert message data.
49+
* @param {object} message The message.
50+
* @returns {void}
51+
* @throws {Error} If the message id is not a valid uuid.
52+
* @throws {Error} If the message data is not an object.
53+
* @throws {Error} If the message type is not valid.
54+
*/
55+
static assertMessage(message) {
56+
// console.log(message);
57+
const FETCH_OFFSCREEN_RESPONSE_TYPES = [FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR];
58+
59+
if (!FETCH_OFFSCREEN_RESPONSE_TYPES.includes(message?.type)) {
60+
throw new Error(`ResponseFetchOffscreenService: message.type should be one of the following ${FETCH_OFFSCREEN_RESPONSE_TYPES.join(", ")}.`);
61+
}
62+
assertUuid(message?.id, "ResponseFetchOffscreenService: message.id should be a valid uuid.");
63+
if (!(message?.data instanceof Object)) {
64+
throw new Error("ResponseFetchOffscreenService: message.data should be an object.");
65+
}
66+
}
67+
68+
/**
69+
* Consume the offscreen request promise callbacks or fail.
70+
* @param {string} id The identifier of the offscreen fetch request.
71+
* @returns {object}
72+
* @throws {Error} If no request promise callbacks can be found for the given offscreen fetch request id.
73+
*/
74+
static consumeRequestPromiseCallbacksOrFail(id) {
75+
const offscreenRequestPromiseCallback = RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id];
76+
if (!offscreenRequestPromiseCallback) {
77+
throw new Error("ResponseFetchOffscreenService: No request promise callbacks found for the given offscreen fetch request id.");
78+
}
79+
delete RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id];
80+
81+
return offscreenRequestPromiseCallback;
82+
}
83+
84+
/**
85+
* Build native fetch response object based on offscreen message response data.
86+
* @param {object} data The fetch offscreen message response data.
87+
* @returns {Response}
88+
*/
89+
static buildFetchResponse(data) {
90+
return new Response(data.text, {
91+
status: data.status,
92+
statusText: data.statusText,
93+
headers: data.headers,
94+
redirected: data.redirected,
95+
url: data.url,
96+
ok: data.ok,
97+
});
98+
}
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
import {
15+
FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS,
16+
SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER
17+
} from "../../../offscreens/service/network/fetchOffscreenService";
18+
19+
export const defaultResponseMessage = (message = {}) => ({
20+
target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER,
21+
id: crypto.randomUUID(),
22+
type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS,
23+
data: {
24+
"status": 200,
25+
"statusText": "OK",
26+
"headers": [
27+
[
28+
"access-control-expose-headers",
29+
"X-GPGAuth-Verify-Response, X-GPGAuth-Progress, X-GPGAuth-User-Auth-Token, X-GPGAuth-Authenticated, X-GPGAuth-Refer, X-GPGAuth-Debug, X-GPGAuth-Error, X-GPGAuth-Pubkey, X-GPGAuth-Logout-Url, X-GPGAuth-Version"
30+
],
31+
[
32+
"cache-control",
33+
"no-store, no-cache, must-revalidate"
34+
],
35+
[
36+
"content-security-policy",
37+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self' https://*.duosecurity.com;"
38+
],
39+
[
40+
"content-type",
41+
"application/json"
42+
],
43+
[
44+
"date",
45+
"Tue, 23 Apr 2024 09:03:48 GMT"
46+
],
47+
[
48+
"expires",
49+
"Thu, 19 Nov 1981 08:52:00 GMT"
50+
],
51+
[
52+
"pragma",
53+
"no-cache"
54+
],
55+
[
56+
"referrer-policy",
57+
"same-origin"
58+
],
59+
[
60+
"server",
61+
"nginx/1.24.0 (Ubuntu)"
62+
],
63+
[
64+
"x-content-type-options",
65+
"nosniff"
66+
],
67+
[
68+
"x-download-options",
69+
"noopen"
70+
],
71+
[
72+
"x-frame-options",
73+
"sameorigin"
74+
],
75+
[
76+
"x-gpgauth-authenticated",
77+
"false"
78+
],
79+
[
80+
"x-gpgauth-debug",
81+
"There is no user associated with this key. No key id set."
82+
],
83+
[
84+
"x-gpgauth-error",
85+
"true"
86+
],
87+
[
88+
"x-gpgauth-login-url",
89+
"/auth/login"
90+
],
91+
[
92+
"x-gpgauth-logout-url",
93+
"/auth/logout"
94+
],
95+
[
96+
"x-gpgauth-progress",
97+
"stage0"
98+
],
99+
[
100+
"x-gpgauth-pubkey-url",
101+
"/auth/verify.json"
102+
],
103+
[
104+
"x-gpgauth-verify-url",
105+
"/auth/verify"
106+
],
107+
[
108+
"x-gpgauth-version",
109+
"1.3.0"
110+
],
111+
[
112+
"x-permitted-cross-domain-policies",
113+
"all"
114+
]
115+
],
116+
"redirected": false,
117+
"url": "https://www.passbolt.test/settings.json?api-version=v2",
118+
"ok": true,
119+
"text": "{\n \"header\": {\n \"id\": \"0682ab8f-ecba-4336-a628-8b6cac609f49\",\n \"status\": \"success\",\n \"servertime\": 1713863028,\n \"action\": \"bef9f3ca-86ef-5c6a-9b38-320e03ceb5df\",\n \"message\": \"The operation was successful.\",\n \"url\": \"\\/settings.json?api-version=v2\",\n \"code\": 200\n },\n \"body\": {\n \"app\": {\n \"url\": \"https:\\/\\/www.passbolt.test\\/\",\n \"locale\": \"en-UK\"\n },\n \"passbolt\": {\n \"legal\": {\n \"privacy_policy\": {\n \"url\": \"\"\n },\n \"terms\": {\n \"url\": \"https:\\/\\/www.passbolt.com\\/terms\"\n }\n },\n \"edition\": \"pro\",\n \"plugins\": {\n \"jwtAuthentication\": {\n \"enabled\": true\n },\n \"accountRecoveryRequestHelp\": {\n \"enabled\": true\n },\n \"accountRecovery\": {\n \"enabled\": true\n },\n \"selfRegistration\": {\n \"enabled\": true\n },\n \"sso\": {\n \"enabled\": true\n },\n \"mfaPolicies\": {\n \"enabled\": true\n },\n \"ssoRecover\": {\n \"enabled\": true\n },\n \"userPassphrasePolicies\": {\n \"enabled\": true\n },\n \"inFormIntegration\": {\n \"enabled\": true\n },\n \"locale\": {\n \"options\": [\n {\n \"locale\": \"de-DE\",\n \"label\": \"Deutsch\"\n },\n {\n \"locale\": \"en-UK\",\n \"label\": \"English\"\n },\n {\n \"locale\": \"es-ES\",\n \"label\": \"Espa\\u00f1ol\"\n },\n {\n \"locale\": \"fr-FR\",\n \"label\": \"Fran\\u00e7ais\"\n },\n {\n \"locale\": \"it-IT\",\n \"label\": \"Italiano (beta)\"\n },\n {\n \"locale\": \"ja-JP\",\n \"label\": \"\\u65e5\\u672c\\u8a9e\"\n },\n {\n \"locale\": \"ko-KR\",\n \"label\": \"\\ud55c\\uad6d\\uc5b4 (beta)\"\n },\n {\n \"locale\": \"lt-LT\",\n \"label\": \"Lietuvi\\u0173\"\n },\n {\n \"locale\": \"nl-NL\",\n \"label\": \"Nederlands\"\n },\n {\n \"locale\": \"pl-PL\",\n \"label\": \"Polski\"\n },\n {\n \"locale\": \"pt-BR\",\n \"label\": \"Portugu\\u00eas Brasil (beta)\"\n },\n {\n \"locale\": \"ro-RO\",\n \"label\": \"Rom\\u00e2n\\u0103 (beta)\"\n },\n {\n \"locale\": \"ru-RU\",\n \"label\": \"P\\u0443\\u0441\\u0441\\u043a\\u0438\\u0439 (beta)\"\n },\n {\n \"locale\": \"sv-SE\",\n \"label\": \"Svenska\"\n }\n ]\n },\n \"rememberMe\": {\n \"options\": {\n \"300\": \"5 minutes\",\n \"900\": \"15 minutes\",\n \"1800\": \"30 minutes\",\n \"3600\": \"1 hour\",\n \"-1\": \"until I log out\"\n }\n }\n }\n }\n }\n}"
120+
},
121+
...message
122+
});
123+
124+
export const defaultCallbacks = (data = {}) => ({
125+
resolve: jest.fn(),
126+
reject: jest.fn(),
127+
...data
128+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
import each from "jest-each";
16+
import {enableFetchMocks} from "jest-fetch-mock";
17+
import {RequestFetchOffscreenService} from "./requestFetchOffscreenService";
18+
import {
19+
FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR,
20+
FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS
21+
} from "../../../offscreens/service/network/fetchOffscreenService";
22+
import ResponseFetchOffscreenService from "./responseFetchOffscreenService";
23+
import {defaultCallbacks, defaultResponseMessage} from "./responseFetchOffscreenService.test.data";
24+
25+
beforeEach(() => {
26+
enableFetchMocks();
27+
fetch.resetMocks();
28+
jest.clearAllMocks();
29+
// Flush the requests promises callbacks stack.
30+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks = {};
31+
});
32+
33+
describe("ResponseFetchOffscreenService", () => {
34+
describe("::assertMessage", () => {
35+
each([
36+
{scenario: "success", type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS},
37+
{scenario: "error", type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR},
38+
]).describe("should accept if message type is valid", _props => {
39+
it(`should validate message type: ${_props.scenario}`, () => {
40+
const message = defaultResponseMessage({type: _props.type});
41+
try {
42+
ResponseFetchOffscreenService.assertMessage(message);
43+
expect(true).toBeTruthy();
44+
} catch (error) {
45+
expect(error).toBeNull();
46+
}
47+
});
48+
});
49+
50+
each([
51+
{scenario: "undefined", type: undefined},
52+
{scenario: "null", type: null},
53+
{scenario: "invalid string", type: "invalid"},
54+
{scenario: "boolean", type: true},
55+
{scenario: "object", type: {data: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS}},
56+
]).describe("should throw if message type is not valid", _props => {
57+
it(`should trow if message type: ${_props.scenario}`, () => {
58+
const message = defaultResponseMessage({type: _props.type});
59+
try {
60+
ResponseFetchOffscreenService.assertMessage(message);
61+
expect(true).toBeFalsy();
62+
} catch (error) {
63+
expect(error).toBeInstanceOf(Error);
64+
}
65+
});
66+
});
67+
68+
it("should validate message id", () => {
69+
expect(() => defaultResponseMessage({id: crypto.randomUUID()})).not.toThrow();
70+
});
71+
72+
each([
73+
{scenario: "undefined", id: undefined},
74+
{scenario: "null", id: null},
75+
{scenario: "invalid string", id: "invalid"},
76+
{scenario: "boolean", id: true},
77+
{scenario: "object", id: {data: crypto.randomUUID()}},
78+
]).describe("should throw if message id is not valid", _props => {
79+
it(`should trow if message id: ${_props.scenario}`, () => {
80+
const message = defaultResponseMessage({id: _props.id});
81+
try {
82+
ResponseFetchOffscreenService.assertMessage(message);
83+
expect(true).toBeFalsy();
84+
} catch (error) {
85+
expect(error).toBeInstanceOf(Error);
86+
}
87+
});
88+
});
89+
90+
it("should validate message data", () => {
91+
expect(() => defaultResponseMessage({data: {prop: "value"}})).not.toThrow();
92+
});
93+
94+
each([
95+
{scenario: "undefined", data: undefined},
96+
{scenario: "null", data: null},
97+
{scenario: "invalid string", data: "invalid"},
98+
{scenario: "boolean", data: true},
99+
]).describe("should throw if message data is not valid", _props => {
100+
it(`should trow if message id: ${_props.scenario}`, () => {
101+
const message = defaultResponseMessage({data: _props.data});
102+
try {
103+
ResponseFetchOffscreenService.assertMessage(message);
104+
expect(true).toBeFalsy();
105+
} catch (error) {
106+
expect(error).toBeInstanceOf(Error);
107+
}
108+
});
109+
});
110+
});
111+
112+
describe("::consumeRequestPromiseCallbacksOrFail", () => {
113+
it("should consume the response handler associated to the given id", () => {
114+
expect.assertions(3);
115+
const id = crypto.randomUUID();
116+
const callbacks = defaultCallbacks();
117+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks;
118+
const consumedCallbacks = ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id);
119+
expect(consumedCallbacks).not.toBeNull();
120+
expect(consumedCallbacks).toEqual(callbacks);
121+
expect(Object.keys(RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks).length).toEqual(0);
122+
});
123+
124+
it("should throw if no associated callbacks found for the given id", () => {
125+
expect.assertions(1);
126+
const id = crypto.randomUUID();
127+
expect(() => ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id)).toThrow();
128+
});
129+
});
130+
131+
describe("::buildFetchResponse", () => {
132+
it("should build the fetch response object based on the offscreen message data", async() => {
133+
expect.assertions(8);
134+
const message = defaultResponseMessage();
135+
const response = ResponseFetchOffscreenService.buildFetchResponse(message.data);
136+
expect(response).toBeInstanceOf(Response);
137+
expect(response.status).toEqual(message.data.status);
138+
expect(response.statusText).toEqual(message.data.statusText);
139+
expect(Array.from(response.headers.entries())).toEqual(message.data.headers);
140+
expect(response.redirected).toEqual(message.data.redirected);
141+
expect(response.url).toEqual(message.data.url);
142+
expect(response.ok).toEqual(message.data.ok);
143+
expect(await response.text()).toEqual(message.data.text);
144+
});
145+
});
146+
147+
describe("::handleFetchResponse", () => {
148+
it("should handle success response and execute the resolve callback", () => {
149+
expect.assertions(1);
150+
const id = crypto.randomUUID();
151+
const callbacks = defaultCallbacks();
152+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks;
153+
const message = defaultResponseMessage({id});
154+
ResponseFetchOffscreenService.handleFetchResponse(message);
155+
expect(callbacks.resolve).toHaveBeenCalledWith(expect.any(Response));
156+
});
157+
158+
it("should handle error response and execute the reject callback", () => {
159+
expect.assertions(1);
160+
const id = crypto.randomUUID();
161+
const callbacks = defaultCallbacks();
162+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks;
163+
// eslint-disable-next-line object-shorthand
164+
const message = defaultResponseMessage({id, type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR});
165+
ResponseFetchOffscreenService.handleFetchResponse(message);
166+
expect(callbacks.reject).toHaveBeenCalledWith(expect.any(Error));
167+
});
168+
169+
it("should ignore message having the wrong target", () => {
170+
expect.assertions(2);
171+
const id = crypto.randomUUID();
172+
const callbacks = defaultCallbacks();
173+
RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks;
174+
// eslint-disable-next-line object-shorthand
175+
const message = defaultResponseMessage({id, target: "other-target"});
176+
ResponseFetchOffscreenService.handleFetchResponse(message);
177+
expect(callbacks.resolve).not.toHaveBeenCalled();
178+
expect(callbacks.reject).not.toHaveBeenCalled();
179+
});
180+
});
181+
});

‎test/jest.setup.js

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import "./mocks/mockWebExtensionPolyfill";
1616
import browser from "../src/all/common/polyfill/browserPolyfill";
1717
import "./mocks/mockTextEncoder";
18+
import "./mocks/mockCrypto";
1819
import "./matchers/extendExpect";
1920
import MockNavigatorLocks from './mocks/mockNavigatorLocks';
2021
import OrganizationSettingsModel from "../src/all/background_page/model/organizationSettings/organizationSettingsModel";

‎test/mocks/mockCrypto.js

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Passbolt ~ Open source password manager for teams
3+
* Copyright (c) Passbolt SA (https://www.passbolt.com)
4+
*
5+
* Licensed under GNU Affero General Public License version 3 of the or any later version.
6+
* For full copyright and license information, please see the LICENSE.txt
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
10+
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
11+
* @link https://www.passbolt.com Passbolt(tm)
12+
* @since 4.7.0
13+
*/
14+
15+
import {v4 as uuid} from "uuid";
16+
17+
if (!global.crypto) {
18+
global.crypto = {};
19+
}
20+
if (!global.crypto.randomUUID) {
21+
global.crypto.randomUUID = uuid;
22+
}

‎test/mocks/mockWebExtensionPolyfill.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@ jest.mock("webextension-polyfill", () => {
2929
cookies: {
3030
get: jest.fn()
3131
},
32+
// offscreen is not mocked by jest-webextension-mock v3.8.9
33+
offscreen: {
34+
closeDocument: jest.fn(),
35+
createDocument: jest.fn(),
36+
},
3237
runtime: {
3338
...originalBrowser.runtime,
39+
// getContexts not mocked by jest-webextension-mock v3.8.9
40+
getContexts: jest.fn(() => []),
3441
// Force the extension runtime url
35-
getURL: jest.fn(() => "chrome-extension://didegimhafipceonhjepacocaffmoppf"),
42+
getURL: jest.fn(path => `chrome-extension://didegimhafipceonhjepacocaffmoppf/${path}`),
3643
// Force extension version
3744
getManifest: jest.fn(() => ({
3845
version: "v3.6.0"

‎webpack-offscreens.fetch.config.js

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
const path = require('path');
2+
const TerserPlugin = require("terser-webpack-plugin");
3+
4+
const config = {
5+
entry: {
6+
'fetch': path.resolve(__dirname, './src/chrome-mv3/offscreens/fetch.js'),
7+
},
8+
mode: 'production',
9+
module: {
10+
rules: [
11+
{
12+
test: /\.(js|jsx)$/,
13+
exclude: /(node_modules[\\/]((?!(passbolt\-styleguide))))/,
14+
loader: "babel-loader",
15+
options: {
16+
presets: ["@babel/react"],
17+
}
18+
}
19+
]
20+
},
21+
optimization: {
22+
minimize: true,
23+
minimizer: [new TerserPlugin()],
24+
splitChunks: {
25+
minSize: 0,
26+
cacheGroups: {
27+
commons: {
28+
test: /[\\/]node_modules[\\/]((?!(passbolt\-styleguide)).*)[\\/]/,
29+
name: 'vendors',
30+
chunks: 'all'
31+
},
32+
}
33+
},
34+
},
35+
resolve: {extensions: ["*", ".js"], fallback: {crypto: false}},
36+
output: {
37+
// Set a unique name to ensure the cohabitation of multiple webpack loader on the same page.
38+
chunkLoadingGlobal: 'offscreensFetchChunkLoadingGlobal',
39+
path: path.resolve(__dirname, './build/all/offscreens'),
40+
pathinfo: true,
41+
filename: '[name].js'
42+
}
43+
};
44+
45+
exports.default = function (env) {
46+
env = env || {};
47+
// Enable debug mode.
48+
if (env.debug) {
49+
config.mode = "development";
50+
config.devtool = "inline-source-map";
51+
config.optimization.minimize = false;
52+
config.optimization.minimizer = [];
53+
}
54+
return config;
55+
};

‎webpack.service-worker.config.js

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const config = {
1111
new webpack.ProvidePlugin({
1212
// Inject browser polyfill as a global API, and adapt it depending on the environment (MV2/MV3/Windows app).
1313
browser: path.resolve(__dirname, './src/all/common/polyfill/browserPolyfill.js'),
14+
// Inject custom api client fetch to MV3 extension as workaround of the invalid certificate issue.
15+
customApiClientFetch: path.resolve(__dirname, './src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js'),
1416
})
1517
],
1618
module: {

0 commit comments

Comments
 (0)
Please sign in to comment.