diff --git a/.gitignore b/.gitignore index fb9f16645c..c0f64c75fc 100644 --- a/.gitignore +++ b/.gitignore @@ -622,6 +622,7 @@ packages/app-desktop/integration-tests/models/NoteList.js packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/multiWindow.spec.js +packages/app-desktop/integration-tests/networking.spec.js packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/pluginApi.spec.js packages/app-desktop/integration-tests/resizableLayout.spec.js @@ -1989,6 +1990,7 @@ packages/lib/shim.js packages/lib/string-utils.test.js packages/lib/string-utils.js packages/lib/testing/ai/semanticSearch.js +packages/lib/testing/createLocalhostServer.js packages/lib/testing/dom-test-environment.js packages/lib/testing/plugins/createTestPlugin.js packages/lib/testing/share/makeMockShareInvitation.js diff --git a/.ignore.eslint b/.ignore.eslint index c1b0d020a4..93e9d3caac 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -648,6 +648,7 @@ packages/app-desktop/integration-tests/models/NoteList.js packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/multiWindow.spec.js +packages/app-desktop/integration-tests/networking.spec.js packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/pluginApi.spec.js packages/app-desktop/integration-tests/resizableLayout.spec.js @@ -2015,6 +2016,7 @@ packages/lib/shim.js packages/lib/string-utils.test.js packages/lib/string-utils.js packages/lib/testing/ai/semanticSearch.js +packages/lib/testing/createLocalhostServer.js packages/lib/testing/dom-test-environment.js packages/lib/testing/plugins/createTestPlugin.js packages/lib/testing/share/makeMockShareInvitation.js diff --git a/packages/app-desktop/integration-tests/networking.spec.ts b/packages/app-desktop/integration-tests/networking.spec.ts new file mode 100644 index 0000000000..72a2b67373 --- /dev/null +++ b/packages/app-desktop/integration-tests/networking.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from './util/test'; +import MainScreen from './models/MainScreen'; +import type shim from '@joplin/lib/shim'; +import createLocalhostServer from '@joplin/lib/testing/createLocalhostServer'; +import { readFile, writeFile } from 'node:fs/promises'; +import setSettingValue from './util/setSettingValue'; +import { join } from 'node:path'; +import { FetchOptions } from '@joplin/lib/shim'; + +interface ExtendedWindow extends Window { + joplin: { + shim: typeof shim; + }; +} + +declare const window: ExtendedWindow; + +// Networking can behave differently in Electron +test.describe('networking', () => { + + for (const protocol of ['http' as const, 'https' as const]) { + test(`shim.fetch and shim.fetchBlob should fetch an ${protocol} URL`, async ({ mainWindow, electronApp, profileDirectory }) => { + await using server = await createLocalhostServer((request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ success: 1, method: request.method })); + }, { https: protocol === 'https' }); + const targetUrl = `${server.baseUrl}/ping`; + + const tempDir = join(profileDirectory, 'tmp'); + if (protocol === 'https') { + const certPath = join(profileDirectory, 'test__serverCert.pem'); + await writeFile(certPath, server.cert, 'utf-8'); + await setSettingValue(electronApp, mainWindow, 'net.customCertificates', certPath); + } + + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.waitFor(); + + const sendFetchRequest = (url: string, options: FetchOptions) => ( + mainWindow.evaluate(async ({ url, options }) => { + const response = await window.joplin.shim.fetch(url, options); + return { + ok: response.ok, + json: await response.json(), + }; + }, { url, options }) + ); + expect(await sendFetchRequest(targetUrl, { method: 'GET' })).toMatchObject({ + ok: true, + json: { success: 1, method: 'GET' }, + }); + expect(await sendFetchRequest(targetUrl, { method: 'POST' })).toMatchObject({ + ok: true, + json: { success: 1, method: 'POST' }, + }); + + const downloadedFile = await mainWindow.evaluate(async ([targetUrl, tempDir]) => { + const outputPath = `${tempDir}/test.txt`; + await window.joplin.shim.fetchBlob(targetUrl, { path: outputPath }); + return outputPath; + }, [targetUrl, tempDir]); + expect(JSON.parse(await readFile(downloadedFile, 'utf-8'))).toMatchObject({ + success: 1, + method: 'GET', + }); + }); + } + + +}); + diff --git a/packages/lib/package.json b/packages/lib/package.json index a4482c2f88..355424eb1f 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -40,6 +40,7 @@ "pdfjs-dist": "3.11.174", "react": "19.1.5", "react-dom": "19.1.5", + "selfsigned": "5.5.0", "sharp": "0.34.5", "sqlite-vec": "0.1.9", "tesseract.js": "7.0.0", diff --git a/packages/lib/shim-init-node.test.ts b/packages/lib/shim-init-node.test.ts index f718c923e8..8e0019267b 100644 --- a/packages/lib/shim-init-node.test.ts +++ b/packages/lib/shim-init-node.test.ts @@ -1,20 +1,8 @@ import { shimInit } from './shim-init-node'; import shim from './shim'; -import { createTempDir, setupDatabaseAndSynchronizer, supportDir } from './testing/test-utils'; +import { createTempDir, setupDatabaseAndSynchronizer, supportDir, withExtraRootCa } from './testing/test-utils'; import { copyFile } from 'fs-extra'; -import * as http from 'http'; -import { AddressInfo } from 'net'; - -const startServer = async (handler: http.RequestListener) => { - const server = http.createServer(handler); - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const port = (server.address() as AddressInfo).port; - return { server, url: `http://127.0.0.1:${port}` }; -}; - -const closeServer = async (server: http.Server) => { - await new Promise(resolve => server.close(() => resolve())); -}; +import createLocalhostServer from './testing/createLocalhostServer'; describe('shim-init-node', () => { @@ -45,30 +33,22 @@ describe('shim-init-node', () => { // https://github.com/laurent22/joplin/issues/16486 - the agent used to be bound once from the // initial URL, which made the request fail with ERR_INVALID_PROTOCOL when a redirect switched - // between http: and https:. node-fetch calls the agent function for every hop, so passing a - // function is what allows the agent to follow the protocol change. - test('should pass an agent that resolves per protocol rather than a fixed one', async () => { - const server = await startServer((_req, res) => { + // between http: and https:. + test('should handle redirects from http to https', async () => { + await using httpsServer = await createLocalhostServer((_req, res) => { res.writeHead(200); + res.end('success!'); + }, { https: true }); + await using httpServer = await createLocalhostServer((_req, res) => { + res.writeHead(302, { location: `${httpsServer.baseUrl}/` }); res.end('done'); - }); + }, { https: false }); - // shim.fetch sets `agent` on the options object it is given, so it can be inspected afterwards - const options: { agent?: unknown } = {}; - - try { - const response = await shim.fetch(`${server.url}/`, options); + await withExtraRootCa(httpsServer.cert, async () => { + const response = await shim.fetch(`${httpServer.baseUrl}`); expect(response.status).toBe(200); - - expect(typeof options.agent).toBe('function'); - - const agents = shim.httpAgents(); - const resolve = options.agent as (url: { protocol: string })=> unknown; - expect(resolve({ protocol: 'https:' })).toBe(agents.https); - expect(resolve({ protocol: 'http:' })).toBe(agents.http); - } finally { - await closeServer(server.server); - } + expect(await response.text()).toBe('success!'); + }); }); test('should expose an agent per protocol', async () => { @@ -79,5 +59,4 @@ describe('shim-init-node', () => { expect(shim.httpAgent('http://example.com')).toBe(agents.http); expect(shim.httpAgent('https://example.com')).toBe(agents.https); }); - }); diff --git a/packages/lib/testing/createLocalhostServer.ts b/packages/lib/testing/createLocalhostServer.ts new file mode 100644 index 0000000000..3af4c90e69 --- /dev/null +++ b/packages/lib/testing/createLocalhostServer.ts @@ -0,0 +1,99 @@ +import selfsigned from 'selfsigned'; +import https from 'node:https'; +import http from 'node:http'; + +const startListening = (server: https.Server|http.Server) => { + const listeningPromise = new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + resolve(); + server.off('error', onError); + }; + + server.once('listening', onListening); + server.once('error', onError); + }); + // Arbitrary port + server.listen(0, 'localhost'); + + return listeningPromise; +}; + +interface Options { + https: boolean; +} + +const createLocalhostCerts = async () => { + const rootCa = await selfsigned.generate( + [{ name: 'commonName', value: 'ca.localhost' }], + { + extensions: [ + { + name: 'basicConstraints', + cA: true, + critical: true, + }, + ], + }, + ); + const localhostCert = await selfsigned.generate( + [{ name: 'commonName', value: 'localhost' }], + { + algorithm: 'sha256', + extensions: [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, critical: true }, + { name: 'extKeyUsage', serverAuth: true, clientAuth: true }, + { name: 'subjectAltName', altNames: [ + { type: 2, value: 'localhost' }, + { type: 2, value: 'www.localhost' }, + { type: 7, value: '127.0.0.1' }, + ] }, + ], + ca: { key: rootCa.private, cert: rootCa.cert }, + }, + ); + + return { rootCa: rootCa.cert, localhost: localhostCert }; +}; + +const createLocalhostServer = async (requestHandler: http.RequestListener, { https: useHttps }: Options) => { + // See https://github.com/jfromaniello/selfsigned#custom-extensions + const keys = useHttps ? await createLocalhostCerts() : undefined; + + const server = keys ? https.createServer({ + key: keys.localhost.private, + cert: keys.localhost.cert, + }, requestHandler) : http.createServer(requestHandler); + + await startListening(server); + + const address = server.address(); + if (typeof address === 'string') { + throw new Error('Unexpected server.address() return type (expected AddressInfo)'); + } + + return { + baseUrl: `${useHttps ? 'https:' : 'http:'}//localhost:${address.port}`, + port: address.port, + cert: keys?.rootCa, + server, + + [Symbol.asyncDispose]() { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +}; + +export default createLocalhostServer; diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index f2378c526b..ef01c03f76 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -1185,6 +1185,19 @@ export const mockFetch = (requestHandler: MockFetchRequestHandler) => { }; }; +export const withExtraRootCa = async (caPemData: string, task: ()=> Promise) => { + // getCACertificates requires a newer NodeJS version than the current @types/node version. + // Dynamically import tls to work around the missing types: + const tls = require('node:tls'); + const trustedCas = tls.getCACertificates(); + try { + tls.setDefaultCACertificates([...trustedCas, caPemData]); + await task(); + } finally { + tls.setDefaultCACertificates([...trustedCas]); + } +}; + interface WithWarningSilencedOptions { requireWarning: boolean; } diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 5923098c52..45851bcacf 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -349,3 +349,4 @@ collegeboom jsdiff unmerged unspaced +selfsigned diff --git a/yarn.lock b/yarn.lock index dc9697055f..54b291f8d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12932,6 +12932,7 @@ __metadata: redux: "npm:4.2.1" relative: "npm:3.0.2" reselect: "npm:4.1.8" + selfsigned: "npm:5.5.0" server-destroy: "npm:1.0.1" sharp: "npm:0.34.5" sprintf-js: "npm:1.1.3" @@ -52628,16 +52629,7 @@ __metadata: languageName: node linkType: hard -"selfsigned@npm:^2.1.1": - version: 2.1.1 - resolution: "selfsigned@npm:2.1.1" - dependencies: - node-forge: "npm:^1" - checksum: 10/6005206e0d005448274aceceaded5195b944f67a42b72d212a6169d2e5f4bdc87c15a3fe45732c544db8c7175702091aaf95403ad6632585294a6ec8cca63638 - languageName: node - linkType: hard - -"selfsigned@npm:^5.5.0": +"selfsigned@npm:5.5.0, selfsigned@npm:^5.5.0": version: 5.5.0 resolution: "selfsigned@npm:5.5.0" dependencies: @@ -52647,6 +52639,15 @@ __metadata: languageName: node linkType: hard +"selfsigned@npm:^2.1.1": + version: 2.1.1 + resolution: "selfsigned@npm:2.1.1" + dependencies: + node-forge: "npm:^1" + checksum: 10/6005206e0d005448274aceceaded5195b944f67a42b72d212a6169d2e5f4bdc87c15a3fe45732c544db8c7175702091aaf95403ad6632585294a6ec8cca63638 + languageName: node + linkType: hard + "semver-compare@npm:^1.0.0": version: 1.0.0 resolution: "semver-compare@npm:1.0.0"