Add tests for HTTPS-specific behaviour (#41341)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/41341

* Extends `dev-middleware`'s test utilities to enable testing against an HTTPS server with a self-signed cert.
* Runs the CDP transport integration tests (D51002261) using both HTTP and HTTPS.
* Adds a test to explicitly cover the `ws=...` / `wss=...` variation in `devtoolsFrontendUrl` first introduced in D49158227.

Changelog: [Internal]

Reviewed By: blakef

Differential Revision: D51006835

fbshipit-source-id: df3db8cd865898248cd0d8f307f75949a7f313fd
This commit is contained in:
Moti Zilberman
2023-11-08 07:19:40 -08:00
committed by Facebook GitHub Bot
parent c85b2da1e7
commit c6eccda2c7
11 changed files with 407 additions and 121 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
declare module 'selfsigned' {
declare interface SelfsignedOptions {
/**
* The number of days before expiration
*
* @default 365 */
days?: number;
/**
* The date before which the certificate should not be valid
*
* @default now */
notBeforeDate?: Date;
/**
* the size for the private key in bits
* @default 1024
*/
keySize?: number;
/**
* additional extensions for the certificate
*/
extensions?: mixed[];
/**
* The signature algorithm sha256 or sha1
* @default "sha1"
*/
algorithm?: string;
/**
* include PKCS#7 as part of the output
* @default false
*/
pkcs7?: boolean;
/**
* generate client cert signed by the original key
* @default false
*/
clientCertificate?: boolean;
/**
* client certificate's common name
* @default "John Doe jdoe123"
*/
clientCertificateCN?: string;
/**
* the size for the client private key in bits
* @default 1024
*/
clientCertificateKeySize?: number;
}
declare interface GenerateResult {
private: string;
public: string;
cert: string;
fingerprint: string;
}
declare export function generate(
attrs?: pki$CertificateField[],
opts?: SelfsignedOptions,
): GenerateResult;
declare export function generate(
attrs?: pki$CertificateField[],
opts?: SelfsignedOptions,
/** Optional callback, if not provided the generation is synchronous */
done?: (err: void | Error, result: GenerateResult) => mixed,
): void;
// definitions from node-forge's `pki` and `asn1` namespaces
declare interface pki$CertificateFieldOptions {
name?: string | void;
type?: string | void;
shortName?: string | void;
}
declare enum asn1$Class {
UNIVERSAL = 0x00,
APPLICATION = 0x40,
CONTEXT_SPECIFIC = 0x80,
PRIVATE = 0xc0,
}
declare interface pki$CertificateField extends pki$CertificateFieldOptions {
valueConstructed?: boolean | void;
valueTagClass?: asn1$Class | void;
value?: mixed[] | string | void;
extensions?: mixed[] | void;
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
// Very incomplete types for the undici package.
declare interface undici$Agent$Options {
connect?: tls$connectOptions;
}
declare module 'undici' {
declare export class Dispatcher extends events$EventEmitter {
constructor(): void;
}
declare export class Agent extends Dispatcher {
constructor(opts?: undici$Agent$Options): void;
}
}
+2
View File
@@ -30,6 +30,7 @@
"debug": "^2.2.0",
"node-fetch": "^2.2.0",
"open": "^7.0.3",
"selfsigned": "^2.4.1",
"serve-static": "^1.13.1",
"temp-dir": "^2.0.0"
},
@@ -37,6 +38,7 @@
"node": ">=18"
},
"devDependencies": {
"undici": "^5.27.2",
"wait-for-expect": "^3.0.2"
}
}
@@ -11,8 +11,33 @@
import type {JSONSerializable} from '../inspector-proxy/types';
export async function fetchJson(url: string): Promise<JSONSerializable> {
const response = await fetch(url);
import {Agent} from 'undici';
/**
* A version of `fetch` that is usable with the HTTPS server created in
* ServerUtils (which uses a self-signed certificate).
*/
export async function fetchLocal(
url: string,
options?: Parameters<typeof fetch>[1] & {dispatcher?: mixed},
): ReturnType<typeof fetch> {
return await fetch(url, {
...options,
// Node's native `fetch` comes from undici and supports the same options,
// including `dispatcher` which we use to make it accept self-signed
// certificates.
dispatcher:
options?.dispatcher ??
new Agent({
connect: {
rejectUnauthorized: false,
},
}),
});
}
export async function fetchJson<T: JSONSerializable>(url: string): Promise<T> {
const response = await fetchLocal(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
@@ -19,7 +19,10 @@ export class DebuggerAgent {
_readyPromise: Promise<void>;
constructor(url: string, signal?: AbortSignal) {
const ws = new WebSocket(url);
const ws = new WebSocket(url, {
// The mock server uses a self-signed certificate.
rejectUnauthorized: false,
});
this._ws = ws;
ws.on('message', data => {
this.__handle(JSON.parse(data.toString()));
@@ -27,7 +27,10 @@ export class DeviceAgent {
_readyPromise: Promise<void>;
constructor(url: string, signal?: AbortSignal) {
const ws = new WebSocket(url);
const ws = new WebSocket(url, {
// The mock server uses a self-signed certificate.
rejectUnauthorized: false,
});
this._ws = ws;
ws.on('message', data => {
this.__handle(JSON.parse(data.toString()));
@@ -23,126 +23,130 @@ jest.useRealTimers();
jest.setTimeout(10000);
describe('inspector proxy CDP transport', () => {
const serverRef = withServerForEachTest({
logger: undefined,
projectRoot: '',
});
const autoCleanup = withAbortSignalForEachTest();
afterEach(() => {
jest.clearAllMocks();
});
describe.each(['HTTP', 'HTTPS'])(
'inspector proxy CDP transport over %s',
protocol => {
const serverRef = withServerForEachTest({
logger: undefined,
projectRoot: '',
secure: protocol === 'HTTPS',
});
const autoCleanup = withAbortSignalForEachTest();
afterEach(() => {
jest.clearAllMocks();
});
test('connection/disconnection and message from debugger to device', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
try {
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);
let pageList: Array<PageDescription> = [];
await until(async () => {
pageList = (await fetchJson(
`${serverRef.serverBaseUrl}/json`,
// $FlowIgnore[unclear-type]
): any);
expect(pageList).toHaveLength(1);
});
const [{webSocketDebuggerUrl}] = pageList;
expect(webSocketDebuggerUrl).toBeDefined();
const debugger_ = await createDebuggerMock(
webSocketDebuggerUrl,
test('connection/disconnection and message from debugger to device', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
try {
await until(() => expect(device1.connect).toBeCalled());
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);
debugger_.send({
method: 'Runtime.enable',
id: 0,
let pageList: Array<PageDescription> = [];
await until(async () => {
pageList = (await fetchJson(
`${serverRef.serverBaseUrl}/json`,
// $FlowIgnore[unclear-type]
): any);
expect(pageList).toHaveLength(1);
});
const [{webSocketDebuggerUrl}] = pageList;
expect(webSocketDebuggerUrl).toBeDefined();
await until(() => expect(device1.wrappedEvent).toBeCalled());
const debugger_ = await createDebuggerMock(
webSocketDebuggerUrl,
autoCleanup.signal,
);
try {
await until(() => expect(device1.connect).toBeCalled());
expect(device1.wrappedEventParsed).toBeCalledWith({
pageId: 'page1',
wrappedEvent: {
debugger_.send({
method: 'Runtime.enable',
id: 0,
},
});
});
debugger_.close();
await until(() => expect(device1.wrappedEvent).toBeCalled());
await until(() => expect(device1.disconnect).toBeCalled());
expect(device1.wrappedEventParsed).toBeCalledWith({
pageId: 'page1',
wrappedEvent: {
method: 'Runtime.enable',
id: 0,
},
});
debugger_.close();
await until(() => expect(device1.disconnect).toBeCalled());
} finally {
debugger_.close();
}
} finally {
debugger_.close();
device1.close();
}
} finally {
device1.close();
}
});
});
test('message and disconnection from device to debugger', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
try {
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);
let pageList: Array<PageDescription> = [];
await until(async () => {
pageList = (await fetchJson(
`${serverRef.serverBaseUrl}/json`,
// $FlowIgnore[unclear-type]
): any);
expect(pageList).toHaveLength(1);
});
const [{webSocketDebuggerUrl}] = pageList;
expect(webSocketDebuggerUrl).toBeDefined();
const debugger_ = await createDebuggerMock(
webSocketDebuggerUrl,
test('message and disconnection from device to debugger', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
let debuggerSocketClosed = false;
debugger_.socket.once('close', () => {
debuggerSocketClosed = true;
});
try {
await until(() => expect(device1.connect).toBeCalled());
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);
device1.sendWrappedEvent('page1', {
id: 0,
let pageList: Array<PageDescription> = [];
await until(async () => {
pageList = (await fetchJson(
`${serverRef.serverBaseUrl}/json`,
// $FlowIgnore[unclear-type]
): any);
expect(pageList).toHaveLength(1);
});
const [{webSocketDebuggerUrl}] = pageList;
expect(webSocketDebuggerUrl).toBeDefined();
await until(() => expect(debugger_.handle).toBeCalledWith({id: 0}));
const debugger_ = await createDebuggerMock(
webSocketDebuggerUrl,
autoCleanup.signal,
);
let debuggerSocketClosed = false;
debugger_.socket.once('close', () => {
debuggerSocketClosed = true;
});
try {
await until(() => expect(device1.connect).toBeCalled());
device1.close();
device1.sendWrappedEvent('page1', {
id: 0,
});
await until(() => expect(debuggerSocketClosed).toBe(true));
await until(() => expect(debugger_.handle).toBeCalledWith({id: 0}));
device1.close();
await until(() => expect(debuggerSocketClosed).toBe(true));
} finally {
debugger_.close();
}
} finally {
debugger_.close();
device1.close();
}
} finally {
device1.close();
}
});
});
});
},
);
@@ -9,6 +9,11 @@
* @oncall react_native
*/
import type {
JsonPagesListResponse,
JsonVersionResponse,
} from '../inspector-proxy/types';
import {fetchJson} from './FetchUtils';
import {createDeviceMock} from './InspectorDeviceUtils';
import {withAbortSignalForEachTest} from './ResourceUtils';
@@ -31,14 +36,18 @@ describe('inspector proxy HTTP API', () => {
describe('/json/version endpoint', () => {
test('returns version', async () => {
const json = await fetchJson(`${serverRef.serverBaseUrl}/json/version`);
const json = await fetchJson<JsonVersionResponse>(
`${serverRef.serverBaseUrl}/json/version`,
);
expect(json).toMatchSnapshot();
});
});
describe.each(['/json', '/json/list'])('%s endpoint', endpoint => {
test('empty on start', async () => {
const json = await fetchJson(`${serverRef.serverBaseUrl}${endpoint}`);
const json = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
expect(json).toEqual([]);
});
@@ -59,7 +68,7 @@ describe('inspector proxy HTTP API', () => {
jest.advanceTimersByTime(PAGES_POLLING_DELAY);
const jsonBefore = await fetchJson(
const jsonBefore = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
@@ -74,7 +83,7 @@ describe('inspector proxy HTTP API', () => {
jest.advanceTimersByTime(PAGES_POLLING_DELAY);
const jsonAfter = await fetchJson(
const jsonAfter = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
@@ -115,13 +124,13 @@ describe('inspector proxy HTTP API', () => {
jest.advanceTimersByTime(PAGES_POLLING_DELAY);
const jsonBefore = await fetchJson(
const jsonBefore = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
device1.close();
const jsonAfter = await fetchJson(
const jsonAfter = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
@@ -171,7 +180,9 @@ describe('inspector proxy HTTP API', () => {
jest.advanceTimersByTime(10 * PAGES_POLLING_DELAY);
try {
const json = await fetchJson(`${serverRef.serverBaseUrl}${endpoint}`);
const json = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
expect(json).toEqual([
{
description: 'bar-app',
@@ -207,5 +218,55 @@ describe('inspector proxy HTTP API', () => {
device2.close();
}
});
describe('HTTP vs HTTPS', () => {
const secureServerRef = withServerForEachTest({
logger: undefined,
projectRoot: '',
secure: true,
});
test('uses `wss` scheme and param if server is HTTPS', async () => {
const page = {
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
};
let deviceHttp, deviceHttps;
try {
deviceHttp = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
deviceHttp.getPages.mockImplementation(() => [page]);
deviceHttps = await createDeviceMock(
`${secureServerRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
deviceHttps.getPages.mockImplementation(() => [page]);
jest.advanceTimersByTime(PAGES_POLLING_DELAY);
const [pageHttp] = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}${endpoint}`,
);
const [pageHttps] = await fetchJson<JsonPagesListResponse>(
`${secureServerRef.serverBaseUrl}${endpoint}`,
);
expect(pageHttp.webSocketDebuggerUrl).toMatch(/^ws:\/\//);
expect(pageHttps.webSocketDebuggerUrl).toMatch(/^wss:\/\//);
expect(pageHttp.devtoolsFrontendUrl).toMatch(/[&?]ws=/);
expect(pageHttps.devtoolsFrontendUrl).toMatch(/[&?]wss=/);
} finally {
deviceHttp?.close();
deviceHttps?.close();
}
});
});
});
});
@@ -12,10 +12,15 @@
import {createDevMiddleware} from '../';
import connect from 'connect';
import http from 'http';
import https from 'https';
import * as selfsigned from 'selfsigned';
import url from 'url';
type CreateDevMiddlewareOptions = Parameters<typeof createDevMiddleware>[0];
type CreateServerOptions = Omit<CreateDevMiddlewareOptions, 'serverBaseUrl'>;
type CreateServerOptions = {
...Omit<CreateDevMiddlewareOptions, 'serverBaseUrl'>,
secure?: boolean,
};
export function withServerForEachTest(options: CreateServerOptions): $ReadOnly<{
serverBaseUrl: string,
@@ -35,11 +40,17 @@ export function withServerForEachTest(options: CreateServerOptions): $ReadOnly<{
);
},
};
let server: http$Server;
let server: http$Server | https$Server;
beforeEach(async () => {
server = await createServer(options);
const serverBaseUrl = baseUrlForServer(server, 'http');
const serverBaseWsUrl = baseUrlForServer(server, 'ws');
const serverBaseUrl = baseUrlForServer(
server,
options.secure ?? false ? 'https' : 'http',
);
const serverBaseWsUrl = baseUrlForServer(
server,
options.secure ?? false ? 'wss' : 'ws',
);
Object.defineProperty(ref, 'serverBaseUrl', {value: serverBaseUrl});
Object.defineProperty(ref, 'serverBaseWsUrl', {value: serverBaseWsUrl});
});
@@ -51,16 +62,30 @@ export function withServerForEachTest(options: CreateServerOptions): $ReadOnly<{
export async function createServer(
options: CreateServerOptions,
): Promise<http$Server> {
): Promise<http$Server | https$Server> {
const app = connect();
const httpServer = http.createServer(app);
const {secure = false, ...devMiddlewareOptions} = options;
let httpServer;
if (secure) {
const {cert, private: key} = selfsigned.generate(
[{name: 'commonName', value: 'localhost'}],
{days: 1},
);
httpServer = https.createServer(
{cert, key},
// $FlowFixMe[incompatible-call] The types for `connect` and `https` are subtly incompatible as written.
app,
);
} else {
httpServer = http.createServer(app);
}
return new Promise((resolve, reject) => {
httpServer.once('error', reject);
httpServer.listen(() => {
const {middleware, websocketEndpoints} = createDevMiddleware({
...options,
serverBaseUrl: baseUrlForServer(httpServer, 'http'),
...devMiddlewareOptions,
serverBaseUrl: baseUrlForServer(httpServer, secure ? 'https' : 'http'),
});
app.use(middleware);
httpServer.on('upgrade', (request, socket, head) => {
@@ -83,8 +108,12 @@ export async function createServer(
});
}
export function baseUrlForServer(server: http$Server, scheme: string): string {
export function baseUrlForServer(
server: http$Server | https$Server,
scheme: string,
): string {
const address = server.address();
// Assumption: `server` is local and listening on `localhost`.
// Assumption: `server` is local and listening on `localhost`. We can't use
// the IP address because HTTPS requires a hostname.
return `${scheme}://localhost:${address.port}`;
}
@@ -138,5 +138,5 @@ export type JSONSerializable =
| number
| string
| null
| Array<JSONSerializable>
| {[string]: JSONSerializable};
| $ReadOnlyArray<JSONSerializable>
| {+[string]: JSONSerializable};
+32
View File
@@ -1366,6 +1366,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@fastify/busboy@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8"
integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==
"@firebase/analytics-compat@0.1.13":
version "0.1.13"
resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.1.13.tgz#61e1d6f9e4d033c3ed9943d91530eb3e0f382f92"
@@ -2745,6 +2750,13 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
"@types/node-forge@^1.3.0":
version "1.3.8"
resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.8.tgz#044ad98354ff309a031a55a40ad122f3be1ac2bb"
integrity sha512-vGXshY9vim9CJjrpcS5raqSjEfKlJcWy2HNdgUasR66fAnVEYarrf1ULV4nfvpC1nZq/moA9qyqBcu83x+Jlrg==
dependencies:
"@types/node" "*"
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^18.0.0":
version "18.16.14"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.14.tgz#ab67bb907f1146afc6fedb9ce60ae8a99c989631"
@@ -7417,6 +7429,11 @@ node-fetch@2.6.7, node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"
node-forge@^1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -8474,6 +8491,14 @@ selenium-webdriver@4.1.2:
tmp "^0.2.1"
ws ">=7.4.6"
selfsigned@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.4.1.tgz#560d90565442a3ed35b674034cec4e95dceb4ae0"
integrity sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==
dependencies:
"@types/node-forge" "^1.3.0"
node-forge "^1"
"semver@2 >=2.2.1 || 3.x || 4 || 5 || 7", semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8:
version "7.5.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e"
@@ -9198,6 +9223,13 @@ unbzip2-stream@1.4.3:
buffer "^5.2.1"
through "^2.3.8"
undici@^5.27.2:
version "5.27.2"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.27.2.tgz#a270c563aea5b46cc0df2550523638c95c5d4411"
integrity sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==
dependencies:
"@fastify/busboy" "^2.0.0"
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"