Files
bun-src/test/js/node/tls/node-tls-connect.test.ts
2026-08-27 21:09:14 +00:00

1429 lines
52 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "bun:test";
import { once } from "events";
import { bunEnv, bunExe, tls as COMMON_CERT_, isASAN } from "harness";
import https from "https";
import net from "net";
import { join } from "path";
import stream from "stream";
import tls, { checkServerIdentity, connect as tlsConnect, TLSSocket } from "tls";
import type { AddressInfo } from "net";
import { Duplex } from "node:stream";
const symbolConnectOptions = Symbol.for("::buntlsconnectoptions::");
class SocketProxy extends Duplex {
socket: net.Socket;
constructor(socket: net.Socket) {
super();
this.socket = socket;
// Handle incoming data from the socket
this.socket.on("data", chunk => {
// Push data to be read by the Duplex stream
if (!this.push(chunk)) {
this.socket.pause();
}
});
// Handle when the socket ends
this.socket.on("end", () => {
this.push(null); // Signal that no more data will be provided
});
// Handle socket errors
this.socket.on("error", err => {
console.error("Socket error:", err);
this.destroy(err); // Destroy the stream on error
});
// Handle socket close
this.socket.on("close", () => {
this.push(null); // Signal the end of data if the socket closes
});
this.socket.on("drain", () => {
this.emit("drain");
});
}
// Implement the _read method to receive data
_read(size: number) {
// Resume the socket if it was paused
if (this.socket.isPaused()) {
this.socket.resume();
}
}
// Implement the _write method to send data
_write(chunk, encoding, callback) {
// Write data to the socket
this.socket.write(chunk, encoding, callback);
}
// Implement the _final method to handle stream ending
_final(callback) {
// End the socket connection
this.socket.end();
callback();
}
}
function duplexProxy(options: tls.ConnectionOptions, callback?: () => void): TLSSocket {
if (typeof options === "number") {
// handle port, host, options
let options = arguments[2] || {};
let callback = arguments[3];
if (typeof options === "function") {
callback = options;
options = {};
}
//@ts-ignore
const socket = net.connect(arguments[0], arguments[1], options);
const duplex = new SocketProxy(socket);
return tls.connect(
{
...options,
socket: duplex,
host: arguments[1],
servername: options.servername || arguments[1],
},
callback,
);
}
//@ts-ignore
const socket = net.connect(options);
const duplex = new SocketProxy(socket);
return tls.connect(
{
...options,
socket: duplex,
host: options.host,
servername: options.servername || options.host,
},
callback,
);
}
const tests = [
{
name: "tls.connect",
connect: tlsConnect,
},
{
name: "tls.connect using duplex proxy",
connect: duplexProxy,
},
];
// Some tests connect to the live bun.sh host. Skip them when the network (or
// DNS) is unavailable so the suite still passes offline instead of failing
// with DNSException/getaddrinfo errors unrelated to what is under test. The
// probe is bounded by a hard timer so a stalled handshake or black-holed
// connection can't hang module load (per-test timeouts don't apply yet).
const canReachBunSh = await (async () => {
const socket = tlsConnect({ host: "bun.sh", servername: "bun.sh", port: 443, rejectUnauthorized: false });
const { promise, resolve } = Promise.withResolvers<boolean>();
const timer = setTimeout(() => resolve(false), 5000);
socket.once("secureConnect", () => resolve(true));
socket.once("error", () => resolve(false));
try {
return await promise;
} finally {
clearTimeout(timer);
socket.destroy();
}
})();
const itNetwork = it.skipIf(!canReachBunSh);
it("should have checkServerIdentity", async () => {
expect(checkServerIdentity).toBeFunction();
expect(tls.checkServerIdentity).toBeFunction();
});
it("should thow ECONNRESET if FIN is received before handshake", async () => {
await using server = net.createServer(c => {
// resume() so the ClientHello the peer still sends is discarded and `c`
// can reach 'end' -> autoDestroy; Node buffers otherwise and server.close()
// (from await using) would wait on it forever.
c.resume();
c.end();
});
await once(server.listen(0, "127.0.0.1"), "listening");
const { promise, resolve } = Promise.withResolvers();
tls.connect((server.address() as AddressInfo).port).on("error", resolve);
const error = await promise;
expect(error).toBeDefined();
expect((error as Error).code as string).toBe("ECONNRESET");
});
it("initializes authorizationError to null in the TLSSocket constructor", () => {
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L556
// Node's onServerSocketSecure/onConnectSecure only assign on failure; a
// clean handshake leaves the constructor's null untouched.
const socket = new tls.TLSSocket();
expect({ value: socket.authorizationError, hasOwn: "authorizationError" in socket }).toEqual({
value: null,
hasOwn: true,
});
socket.destroy();
});
it("setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing", async () => {
// Node returns whatever SSL_set_max_send_fragment returns: OpenSSL rejects a
// size outside [512, 16384] with 0 (-> false). BoringSSL clamps and always
// returns 1, so bun enforces the same contract in the native binding.
const server = tls.createServer(COMMON_CERT_, s => s.on("data", () => {}));
await once(server.listen(0, "127.0.0.1"), "listening");
const connected = Promise.withResolvers<void>();
const client = tls.connect(
{ port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: false },
connected.resolve,
);
client.on("error", connected.reject);
try {
await connected.promise;
const results = [0, -1, 511, 512, 16384, 16385].map(size => [size, client.setMaxSendFragment(size)]);
expect(results).toEqual([
[0, false],
[-1, false],
[511, false],
[512, true],
[16384, true],
[16385, false],
]);
} finally {
client.destroy();
server.close();
}
});
it("should be able to grab the JSStreamSocket constructor", () => {
// this keep http2-wrapper compatibility with node.js
const socket = new tls.TLSSocket(new stream.PassThrough());
//@ts-ignore
expect(socket._handle).not.toBeNull();
//@ts-ignore
expect(socket._handle._parentWrap).not.toBeNull();
//@ts-ignore
expect(socket._handle._parentWrap.constructor).toBeFunction();
});
for (const { name, connect } of tests) {
describe(name, () => {
itNetwork("should work with alpnProtocols", done => {
try {
let socket: TLSSocket | null = connect({
ALPNProtocols: ["http/1.1"],
host: "bun.sh",
servername: "bun.sh",
port: 443,
rejectUnauthorized: false,
});
socket.on("error", err => {
done(err);
});
socket.on("secureConnect", () => {
done(socket?.alpnProtocol === "http/1.1" ? undefined : "alpnProtocol is not http/1.1");
socket?.end();
socket = null;
});
} catch (err) {
done(err);
}
});
const COMMON_CERT = { ...COMMON_CERT_ };
it("Bun.serve() should work with tls and Bun.file()", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(Bun.file(join(import.meta.dir, "fixtures/index.html")));
},
tls: {
cert: COMMON_CERT.cert,
key: COMMON_CERT.key,
},
});
const res = await fetch(`https://${server.hostname}:${server.port}/`, { tls: { rejectUnauthorized: false } });
expect(await res.text()).toBe("<h1>HELLO</h1>");
});
it("should have peer certificate when using self asign certificate", async () => {
using server = Bun.serve({
tls: {
cert: COMMON_CERT.cert,
key: COMMON_CERT.key,
passphrase: COMMON_CERT.passphrase,
},
port: 0,
fetch() {
return new Response("Hello World");
},
});
const { promise: socketPromise, resolve: resolveSocket, reject: rejectSocket } = Promise.withResolvers();
const socket = connect(
{
ALPNProtocols: ["http/1.1"],
host: server.hostname,
servername: "localhost",
port: server.port,
rejectUnauthorized: false,
requestCert: true,
},
resolveSocket,
).on("error", rejectSocket);
await socketPromise;
try {
expect(socket).toBeDefined();
const cert = socket.getPeerCertificate();
expect(cert).toBeDefined();
expect(cert.subject).toMatchObject({
C: "US",
CN: "server-bun",
L: "San Francisco",
O: "Oven",
OU: "Team Bun",
ST: "CA",
});
expect(cert.issuer).toBeDefined();
expect(cert.issuer).toMatchObject({
C: "US",
CN: "server-bun",
L: "San Francisco",
O: "Oven",
OU: "Team Bun",
ST: "CA",
});
expect(cert.subjectaltname).toBe("DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1");
expect(cert.infoAccess).toBeUndefined();
expect(cert.ca).toBe(true);
expect(cert.bits).toBe(2048);
expect(cert.modulus).toBe(
"E5633A2C8118171CBEAF321D55D0444586CBE566BB51A234B0EAD69FAF7490069854EFDDFFAC68986652FF949F472252E4C7D24C6EE4E3366E54D9E4701E24D021E583E1A088112C0F96475A558B42F883A3E796C937CC4D6BB8791B227017B3E73DEB40B0AC84F033019F580A3216888ACEC71CE52D938FCADD8E29794E38774E33D323EDE89B58E526EF8B513BA465FA4FFD9CF6C1EC7480DE0DCB569DEC295D7B3CCE40256B428D5907E90E7A52E77C3101F4AD4C0E254AB03D75AC42EE1668A5094BC4521B264FB404B6C4B17B6B279E13E6282E1E4FB6303540CB830EA8FF576CA57B7861E4EF797AF824B0987C870718780A1C5141E4F904FD0C5139F5",
);
expect(cert.exponent).toBe("0x10001");
expect(cert.pubkey).toBeInstanceOf(Buffer);
expect(cert.valid_from).toBe("Sep 6 03:00:49 2025 GMT"); // yes this space is intentional
expect(cert.valid_to).toBe("Sep 4 03:00:49 2035 GMT");
expect(cert.fingerprint).toBe("D2:5E:B9:AD:8B:48:3B:7A:35:D3:1A:45:BD:32:AC:AD:55:4A:BA:AD");
expect(cert.fingerprint256).toBe(
"85:F4:47:0C:6D:D8:DE:C8:68:77:7C:5E:3F:9B:56:A6:D3:69:C7:C2:1A:E8:B8:F8:1C:16:1D:04:78:A0:E9:91",
);
expect(cert.fingerprint512).toBe(
"CE:00:17:97:29:5E:1C:7E:59:86:8D:1F:F0:F4:AF:A0:B0:10:F2:2E:0E:79:D1:32:D0:44:F9:B4:3A:DE:D5:83:A9:15:0E:E4:47:24:D4:2A:10:FB:21:BE:3A:38:21:FC:40:20:B3:BC:52:64:F7:38:93:EF:C9:3F:C8:57:89:31",
);
expect(cert.serialNumber).toBe("71A46AE89FD817EF81A34D5973E1DE42F09B9D63");
expect(cert.raw).toBeInstanceOf(Buffer);
} finally {
// Tear the socket down immediately: the local server is disposed right
// after this test, and a lingering half-closed connection would observe
// its hard close as ECONNRESET (Node surfaces the same error).
socket.destroy();
}
});
itNetwork("should have peer certificate", async () => {
const socket = (await new Promise((resolve, reject) => {
const instance = connect(
{
ALPNProtocols: ["http/1.1"],
host: "bun.sh",
servername: "bun.sh",
port: 443,
rejectUnauthorized: false,
requestCert: true,
},
function () {
resolve(instance);
},
).on("error", reject);
})) as TLSSocket;
try {
expect(socket).toBeDefined();
const cert = socket.getPeerCertificate();
expect(cert).toBeDefined();
expect(cert.subject).toBeDefined();
// this should never change
expect(cert.subject.CN).toBe("bun.sh");
expect(cert.subjectaltname).toContain("DNS:bun.sh");
expect(cert.infoAccess).toBeDefined();
// The live cert's AIA contents change on reissue (public CAs stopped
// including OCSP URIs in 2025), so only assert the stable CA Issuers
// entry here; exact parsing is covered by the fixed-fixture x509 tests.
const infoAccess = cert.infoAccess as NodeJS.Dict<string[]>;
expect(infoAccess["CA Issuers - URI"]).toEqual(expect.arrayContaining([expect.stringMatching(/^https?:\/\//)]));
expect(cert.ca).toBeFalse();
expect(cert.bits).toBeInteger();
// These can change:
// expect(typeof cert.modulus).toBe("string");
// expect(typeof cert.exponent).toBe("string");
expect(cert.pubkey).toBeInstanceOf(Buffer);
expect(typeof cert.valid_from).toBe("string");
expect(typeof cert.valid_to).toBe("string");
expect(typeof cert.fingerprint).toBe("string");
expect(typeof cert.fingerprint256).toBe("string");
expect(typeof cert.fingerprint512).toBe("string");
expect(typeof cert.serialNumber).toBe("string");
expect(cert.raw).toBeInstanceOf(Buffer);
} finally {
socket.end();
}
});
itNetwork(
"getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work",
async () => {
const allowedCipherObjects = [
{
name: "TLS_AES_128_GCM_SHA256",
standardName: "TLS_AES_128_GCM_SHA256",
version: "TLSv1/SSLv3",
},
{
name: "TLS_AES_256_GCM_SHA384",
standardName: "TLS_AES_256_GCM_SHA384",
version: "TLSv1/SSLv3",
},
{
name: "TLS_CHACHA20_POLY1305_SHA256",
standardName: "TLS_CHACHA20_POLY1305_SHA256",
version: "TLSv1/SSLv3",
},
];
const socket = (await new Promise((resolve, reject) => {
connect({
ALPNProtocols: ["http/1.1"],
host: "bun.sh",
servername: "bun.sh",
port: 443,
rejectUnauthorized: false,
requestCert: true,
})
.on("secure", resolve)
.on("error", reject);
})) as TLSSocket;
try {
const cipher = socket.getCipher();
let hadMatch = false;
for (const allowedCipher of allowedCipherObjects) {
if (cipher.name === allowedCipher.name) {
expect(cipher).toMatchObject(allowedCipher);
hadMatch = true;
break;
}
}
if (!hadMatch) {
throw new Error(`Unexpected cipher ${cipher.name}`);
}
expect(socket.getProtocol()).toBe("TLSv1.3");
expect(typeof socket.getEphemeralKeyInfo()).toBe("object");
expect(socket.getSharedSigalgs()).toBeInstanceOf(Array);
expect(socket.getSession()).toBeInstanceOf(Buffer);
expect(socket.exportKeyingMaterial(512, "client finished")).toBeInstanceOf(Buffer);
expect(socket.isSessionReused()).toBe(false);
// BoringSSL does not support these methods for >= TLSv1.3
expect(socket.getFinished()).toBeUndefined();
expect(socket.getPeerFinished()).toBeUndefined();
} finally {
socket.end();
}
},
);
// Test using only options
// prettier-ignore
it.skipIf(connect === duplexProxy || !canReachBunSh)("should process options correctly when connect is called with only options", done => {
let socket = connect({
port: 443,
host: "bun.sh",
rejectUnauthorized: false,
});
socket.on("secureConnect", () => {
expect(socket.remotePort).toBe(443);
expect(socket[symbolConnectOptions].servername).toBe("bun.sh");
socket.end();
done();
});
socket.on("error", err => {
socket.end();
done(err);
});
});
// Test using port and host
itNetwork("should process port and host correctly", done => {
let socket = connect(443, "bun.sh", {
rejectUnauthorized: false,
});
socket.on("secureConnect", () => {
if (connect === tlsConnect) {
expect(socket.remotePort).toBe(443);
}
expect(socket[symbolConnectOptions].servername).toBe("bun.sh");
socket.end();
done();
});
socket.on("error", err => {
socket.end();
done(err);
});
});
// Test using port, host, and callback
itNetwork("should process port, host, and callback correctly", done => {
let socket = connect(
443,
"bun.sh",
{
rejectUnauthorized: false,
},
() => {
if (connect === tlsConnect) {
expect(socket.remotePort).toBe(443);
}
expect(socket[symbolConnectOptions].servername).toBe("bun.sh");
socket.end();
done();
},
).on("error", err => {
done(err);
});
});
// Additional tests to ensure the callback is optional and handled correctly
itNetwork("should handle the absence of a callback gracefully", done => {
let socket = connect(443, "bun.sh", {
rejectUnauthorized: false,
});
socket.on("secureConnect", () => {
expect(socket[symbolConnectOptions].servername).toBe("bun.sh");
if (connect === tlsConnect) {
expect(socket.remotePort).toBe(443);
}
socket.end();
done();
});
socket.on("error", err => {
socket.end();
done(err);
});
});
itNetwork(
"should timeout",
done => {
const socket = connect(
{
port: 443,
host: "bun.sh",
},
() => {
socket.setTimeout(1000, () => {
done();
socket.end();
});
},
);
socket.on("error", err => {
socket.end();
done(err);
});
},
10_000,
); // 10 seconds because uWS sometimes is not that precise with timeouts
itNetwork("should be able to transfer data", done => {
const socket = connect(
{
port: 443,
host: "bun.com",
servername: "bun.com",
},
() => {
let data = "";
socket.on("data", chunk => {
data += chunk.toString();
});
socket.on("end", () => {
if (data.indexOf("HTTP/1.1 200 OK") !== -1) {
done();
} else {
done(new Error(`missing expected HTTP response, got: ${data.slice(0, 200)}`));
}
});
socket.write("GET / HTTP/1.1\r\n");
socket.write("Host: bun.com\r\n");
socket.write("User-Agent: Bun/1.0\r\n");
socket.write("Accept: */*\r\n");
socket.write("Accept-Encoding: identity\r\n");
socket.write("Connection: close\r\n");
socket.write("\r\n");
},
);
socket.on("error", err => {
socket.end();
done(err);
});
});
});
}
it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", async () => {
// d2i_SSL_SESSION returns an owned SSL_SESSION; SSL_set_session takes its own
// reference ("the caller retains ownership"), so the caller's reference must
// be freed. The fixture calls setSession 20,000× on one socket in its `open`
// handler (the only window before the handshake starts) and reports RSS growth.
//
// Without the SSL_SESSION_free: ~125140 MB growth (~7 KB leaked per call).
// With it: ~510 MB (allocator noise, no per-call growth).
await using proc = Bun.spawn({
cmd: [bunExe(), join(import.meta.dirname, "node-tls-set-session-leak.fixture.ts"), "20000"],
env: {
...bunEnv,
// ASAN's default 256MB quarantine retains every freed allocation, so
// RSS growth would measure the total allocation churn instead of leaks
// on any ASAN-instrumented build (including a local `bun bd` debug
// build, which is ASAN but not named `bun-asan`). Cap the quarantine
// so the measurement reflects live memory.
// Preserve the harness ASAN options (bunEnv sets allow_user_segv_handler /
// disable_coredump) instead of rebuilding from process.env only.
ASAN_OPTIONS: ["quarantine_size_mb=8", bunEnv.ASAN_OPTIONS ?? process.env.ASAN_OPTIONS].filter(Boolean).join(":"),
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const { calls, growthBytes } = JSON.parse(stdout);
expect(calls).toBe(20000);
// Leave generous headroom above the fixed-build measurement so unrelated
// allocator changes don't turn this into a flaky test, while still being
// far below the ~125 MB leak signature.
expect(growthBytes).toBeLessThan((isASAN ? 60 : 40) * 1024 * 1024);
expect(exitCode).toBe(0);
}, 60_000);
it.each([["TLSv1.2"], ["TLSv1.3"]] as const)(
"%s: data written after secureConnect is delivered both ways even when the server ends first",
async version => {
// Under TLS 1.2 the server finishes its handshake one flight before the
// client, so a write()+end() server has already sent its FIN by the time
// the client's reply arrives - the half-closed socket must keep reading.
const serverReceived: string[] = [];
const serverGotData = Promise.withResolvers<void>();
const server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => {
socket.on("data", d => {
serverReceived.push(d.toString());
serverGotData.resolve();
});
socket.write("hello");
socket.end();
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false });
let clientReceived = "";
client.on("data", d => (clientReceived += d));
await once(client, "secureConnect");
expect(client.getProtocol()).toBe(version);
client.write("hello");
client.end();
await once(client, "close");
// The server's read of the client's last record happens on its own loop
// turn - wait for it instead of sleeping.
await serverGotData.promise;
expect(clientReceived).toBe("hello");
expect(serverReceived.join("")).toBe("hello");
server.close();
await once(server, "close");
},
);
it("tls.DEFAULT_MAX_VERSION is honored by contexts built without explicit versions", async () => {
const prev = tls.DEFAULT_MAX_VERSION;
try {
tls.DEFAULT_MAX_VERSION = "TLSv1.2";
const server = tls.createServer({ ...COMMON_CERT_ }, socket => {
socket.end();
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false });
await once(client, "secureConnect");
expect(client.getProtocol()).toBe("TLSv1.2");
client.end();
await once(client, "close");
server.close();
await once(server, "close");
} finally {
tls.DEFAULT_MAX_VERSION = prev;
}
});
it("'session' and 'keylog' are emitted for a TLSSocket over a duplex stream (tls.connect({ socket }))", async () => {
// The TLS-over-duplex wrapper has no us_socket_t, so its parked
// new-session/keylog queues are drained by the Rust SSLWrapper instead of
// us_dispatch_session/us_dispatch_keylog - this covers that path end to end.
const server = tls.createServer({ ...COMMON_CERT_ }, socket => {
socket.on("data", () => socket.end());
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
const raw = net.connect(port, "127.0.0.1");
await once(raw, "connect");
const duplex = new SocketProxy(raw);
const client = tls.connect({ socket: duplex, rejectUnauthorized: false });
const sessionPromise = once(client, "session");
const keylogPromise = once(client, "keylog");
await once(client, "secureConnect");
client.write("x");
const [session] = await sessionPromise;
const [keylogLine] = await keylogPromise;
expect(Buffer.isBuffer(session)).toBe(true);
expect(session.length).toBeGreaterThan(0);
expect(Buffer.isBuffer(keylogLine)).toBe(true);
expect(keylogLine.length).toBeGreaterThan(0);
client.end();
await once(client, "close");
server.close();
await once(server, "close");
});
it("a write from inside 'data' does not re-enter 'data' on a TLSSocket over a duplex (tls.connect({ socket }))", async () => {
// The TLS engine behind a duplex decrypts into a 64 KiB buffer and emits one
// buffer at a time. A write() from inside the 'data' handler used to pump the
// engine again from within that dispatch, so the next 64 KiB arrived as a
// 'data' event nested inside the handler for the previous one; node never
// does that. The transport below hands the engine the whole burst in one
// push, so there is always more to decrypt at the moment the handler writes.
const payload = Buffer.alloc(128 * 1024, Buffer.from(Array.from({ length: 256 }, (_, i) => i)));
// 64 KiB of plaintext plus a whole 16 KiB TLS record of slack.
const releaseAt = 96 * 1024;
// "got reply" is only sent once the ack written from inside 'data' actually
// arrived; the client writes nothing after the ack, so the ack has to reach
// the wire on its own.
await using server = tls.createServer({ ...COMMON_CERT_ }, socket => {
socket.on("data", data => {
if (data.toString() === "go") socket.write(payload);
else if (data.toString() === "ack") socket.end("got reply");
});
});
await once(server.listen(0, "127.0.0.1"), "listening");
const port = (server.address() as AddressInfo).port;
// Like SocketProxy, except that while `held` is set the ciphertext is
// accumulated and pushed as one chunk once `releaseAt` bytes are queued.
class HoldingTransport extends Duplex {
held: Buffer[] | null = null;
constructor(readonly raw: net.Socket) {
super();
raw.on("data", chunk => {
if (this.held === null) {
this.push(chunk);
return;
}
this.held.push(chunk);
if (this.held.reduce((total, part) => total + part.length, 0) >= releaseAt) {
const burst = Buffer.concat(this.held);
this.held = null;
this.push(burst);
}
});
raw.on("end", () => this.push(null));
}
_read() {}
_write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void) {
this.raw.write(chunk, callback);
}
_final(callback: () => void) {
this.raw.end();
callback();
}
}
const raw = net.connect(port, "127.0.0.1");
await once(raw, "connect");
const transport = new HoldingTransport(raw);
const client = tls.connect({ socket: transport, rejectUnauthorized: false });
await once(client, "secureConnect");
const chunks: Buffer[] = [];
let depth = 0;
let nestedDataEvents = 0;
let acked = false;
client.on("data", (chunk: Buffer) => {
depth++;
if (depth > 1) nestedDataEvents++;
chunks.push(chunk);
if (!acked) {
acked = true;
client.write("ack");
}
depth--;
});
transport.held = [];
client.write("go");
await once(client, "end");
client.end();
await once(client, "close");
const received = Buffer.concat(chunks);
expect({
nestedDataEvents,
payloadIntact: received.subarray(0, payload.length).equals(payload),
tail: received.subarray(payload.length).toString(),
}).toEqual({ nestedDataEvents: 0, payloadIntact: true, tail: "got reply" });
});
it("a client and a server TLSSocket connected through a synchronous in-memory duplex pair talk to each other", async () => {
// Each side's _write pushes straight into the other side, so every reply,
// including the server's handshake flight, is fed to the engine from inside
// its own write callback. The engine has to pick those bytes up after the
// write that provoked them instead of decrypting them in place.
const makeSide = (peer: () => Duplex) =>
new Duplex({
read() {},
write(chunk, _encoding, callback) {
peer().push(chunk);
callback();
},
final(callback) {
peer().push(null);
callback();
},
});
const clientSide: Duplex = makeSide(() => serverSide);
const serverSide: Duplex = makeSide(() => clientSide);
const secure = { client: false, server: false };
const exchange: string[] = [];
const server = new TLSSocket(serverSide, { isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) });
server.on("secure", () => (secure.server = true));
server.on("data", (data: Buffer) => {
exchange.push(`server got ${data}`);
server.write(`pong ${data}`);
});
server.on("end", () => server.end());
const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }, () => {
secure.client = true;
client.write("one");
});
client.on("data", (data: Buffer) => {
exchange.push(`client got ${data}`);
if (String(data) === "pong one") client.write("two");
else client.end();
});
await once(client, "close");
expect({ secure, exchange }).toEqual({
secure: { client: true, server: true },
exchange: ["server got one", "client got pong one", "server got two", "client got pong two"],
});
});
it("delivers 'session' even when the data handler destroys the socket immediately", async () => {
// The TLS1.3 NewSessionTickets ride in the same read pass as the response
// bytes. If the parked session were only flushed after the data dispatch,
// a consumer that tears the socket down inside 'data' (an https.Agent with
// keepAlive off destroys the tunneled socket as soon as the response
// completes) would silently lose the 'session' event - Node delivers the
// session before the data reaches JS.
const server = tls.createServer({ ...COMMON_CERT_ }, socket => {
socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"));
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
let session = false;
const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => {
client.write("x");
});
client.on("session", () => (session = true));
client.on("data", () => {
// Mirrors the agent flow: socket destroyed during the data dispatch,
// before any later flush could run.
client.destroy();
});
await once(client, "close");
expect(session).toBe(true);
server.close();
await once(server, "close");
});
describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s: getSession() / getTLSTicket()", version => {
// BoringSSL never folds a TLS 1.3 NewSessionTicket into the SSL's own
// established_session; it only hands the ticket-bearing session to the
// new-session callback. getSession()/getTLSTicket() must read from that
// session once it arrives, or the blob they return cannot resume.
type Snapshot = {
protocol: string | null;
viaGetSession: Buffer | undefined;
ticket: Buffer | undefined;
};
function onFirstSession(client: TLSSocket) {
const { promise, resolve, reject } = Promise.withResolvers<Snapshot>();
client.on("error", reject);
client.on("data", () => {});
client.once("session", () =>
resolve({
protocol: client.getProtocol(),
viaGetSession: client.getSession(),
ticket: client.getTLSTicket(),
}),
);
return promise;
}
it("returns a resumable session once the NewSessionTicket arrives", async () => {
const serverReused: boolean[] = [];
const twoConnections = Promise.withResolvers<void>();
await using server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => {
serverReused.push(socket.isSessionReused());
if (serverReused.length === 2) twoConnections.resolve();
socket.write("x");
socket.on("data", () => {});
});
await once(server.listen(0, "127.0.0.1"), "listening");
const port = (server.address() as AddressInfo).port;
const opts = {
port,
host: "127.0.0.1",
servername: "localhost",
ca: COMMON_CERT_.cert,
minVersion: version,
maxVersion: version,
} as const;
const first = tlsConnect(opts);
const { protocol, viaGetSession, ticket } = await onFirstSession(first);
first.destroy();
await once(first, "close");
expect(protocol).toBe(version);
expect(Buffer.isBuffer(viaGetSession)).toBe(true);
// Second connection: the getSession() blob resumes on both ends.
const second = tlsConnect({ ...opts, session: viaGetSession });
second.on("data", () => {});
await once(second, "secureConnect");
const clientReused = second.isSessionReused();
await twoConnections.promise;
second.destroy();
await once(second, "close");
expect({ clientReused, serverReused }).toEqual({ clientReused: true, serverReused: [false, true] });
expect(Buffer.isBuffer(ticket)).toBe(true);
expect(ticket!.length).toBeGreaterThan(0);
});
it("over a duplex-wrapped TLSSocket", async () => {
await using server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => {
socket.on("data", () => socket.end());
});
await once(server.listen(0, "127.0.0.1"), "listening");
const port = (server.address() as AddressInfo).port;
const raw = net.connect(port, "127.0.0.1");
await once(raw, "connect");
const client = tls.connect({
socket: new SocketProxy(raw),
servername: "localhost",
ca: COMMON_CERT_.cert,
minVersion: version,
maxVersion: version,
});
const snapshot = onFirstSession(client);
await once(client, "secureConnect");
client.write("x");
const { viaGetSession, ticket } = await snapshot;
client.destroy();
await once(client, "close");
expect(Buffer.isBuffer(viaGetSession)).toBe(true);
expect(Buffer.isBuffer(ticket)).toBe(true);
expect(ticket!.length).toBeGreaterThan(0);
});
});
it("a write before 'secureConnect' still reports the handshake's own failure", async () => {
// An early write drives the handshake from inside SSL_write. The fatal
// reason that write hit used to be dropped, so the handshake dispatch had
// nothing to report and the dead session looked established: the only error
// left was checkServerIdentity's verdict on an empty peer certificate.
await using server = tls.createServer({ ...COMMON_CERT_ }, socket => socket.end());
await once(server.listen(0, "127.0.0.1"), "listening");
const client = tlsConnect({
port: (server.address() as AddressInfo).port,
host: "127.0.0.1",
servername: "localhost",
ca: COMMON_CERT_.cert,
minVersion: "TLSv1.3",
maxVersion: "TLSv1.2",
});
let secureConnect = false;
client.on("secureConnect", () => (secureConnect = true));
client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
const [error] = await once(client, "error");
expect(error.code).toBe("ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED");
expect(secureConnect).toBe(false);
});
it("https.request reports an impossible version window as a TLS error, not a certificate error", async () => {
// The http client flushes the request headers as soon as the socket
// connects, so every https.request hits the early-write path above.
await using server = https.createServer({ ...COMMON_CERT_ }, (_req, res) => res.end("ok"));
await once(server.listen(0, "127.0.0.1"), "listening");
const options = {
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
servername: "localhost",
ca: COMMON_CERT_.cert,
agent: false as const,
};
const failing = https.request({ ...options, minVersion: "TLSv1.3", maxVersion: "TLSv1.2" });
failing.end();
const [error] = await once(failing, "error");
expect(error.code).toBe("ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED");
// A satisfiable window over the same cert/CA/servername still succeeds: the
// version range is the only thing that failed above.
const ok = https.request({ ...options, minVersion: "TLSv1.2", maxVersion: "TLSv1.3" });
ok.end();
const [response] = await once(ok, "response");
let body = "";
response.on("data", (chunk: Buffer) => (body += chunk));
await once(response, "end");
expect(body).toBe("ok");
});
describe("rejectUnauthorized only treats a literal `false` as opting out", () => {
// Node applies `options.rejectUnauthorized !== false`, so other falsy
// values must keep peer verification enabled.
it.each([null, 0, ""])("rejects a self-signed peer when rejectUnauthorized is %p", async value => {
const server = tls.createServer({ ...COMMON_CERT_ }, s => s.end());
let client: TLSSocket | undefined;
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { promise, resolve, reject } = Promise.withResolvers<Error & { code?: string }>();
client = tlsConnect(
{ port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: value as any },
() => reject(new Error("secureConnect must not be reached")),
);
client.on("error", resolve);
const error = await promise;
expect(error.code).toBe("DEPTH_ZERO_SELF_SIGNED_CERT");
} finally {
client?.destroy();
server.close();
}
});
it("still completes the handshake unauthorized for a literal `false`", async () => {
const server = tls.createServer({ ...COMMON_CERT_ }, s => s.end());
let client: TLSSocket | undefined;
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { promise, resolve, reject } = Promise.withResolvers<void>();
client = tlsConnect(
{ port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: false },
resolve,
);
client.on("error", reject);
await promise;
expect(client.authorized).toBe(false);
} finally {
client?.destroy();
server.close();
}
});
});
it("a server using `crl` must not poison the process-wide default CA store", async () => {
// An mTLS server with `crl` and no `ca` shares the process-wide default
// root store; the CRL flags must land on a private copy or every later
// default-CA verification in the process fails with UNABLE_TO_GET_CRL.
const fixturesDir = join(import.meta.dir, "fixtures");
const agent6KeyPath = join(fixturesDir, "agent6-key.pem");
const agent6CertPath = join(fixturesDir, "agent6-cert.pem");
const crlPath = join(import.meta.dir, "..", "test", "fixtures", "keys", "ca2-crl.pem");
const script = `
const tls = require("node:tls");
const { readFileSync } = require("node:fs");
const { once } = require("node:events");
const key = readFileSync(${JSON.stringify(agent6KeyPath)}, "utf8");
const cert = readFileSync(${JSON.stringify(agent6CertPath)}, "utf8");
const crl = readFileSync(${JSON.stringify(crlPath)}, "utf8");
async function main() {
const poison = tls.createServer({ key, cert, requestCert: true, crl });
poison.listen(0, "127.0.0.1");
await once(poison, "listening");
const server = tls.createServer({ key, cert }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
// No \`ca\`: relies on NODE_EXTRA_CA_CERTS reaching the default store.
const socket = tls.connect({ port: server.address().port, host: "127.0.0.1", checkServerIdentity: () => undefined });
await once(socket, "secureConnect");
console.log("authorized=" + socket.authorized);
socket.end();
poison.close();
server.close();
}
main().catch(error => {
console.error(error?.code || error?.message || String(error));
process.exit(1);
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(fixturesDir, "ca1-cert.pem") },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stderr is drained but only surfaced on failure: debug builds may emit
// benign warnings, so it must never be asserted to be empty.
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "authorized=true",
exitCode: 0,
failureDetail: "",
});
});
it("a no-`ca` tls.connect({ crl }) applies the CRL to its own copy of the default roots", async () => {
// The context starts on SSL_CTX_new()'s empty store; it must be seeded with
// a private copy of the default roots that the per-socket attach keeps, so
// CRL checking fails closed exactly like Node (no CRL covers the ca1 chain).
const fixturesDir = join(import.meta.dir, "fixtures");
const crlPath = join(import.meta.dir, "..", "test", "fixtures", "keys", "ca2-crl.pem");
const script = `
const tls = require("node:tls");
const { readFileSync } = require("node:fs");
const { once } = require("node:events");
const key = readFileSync(${JSON.stringify(join(fixturesDir, "agent6-key.pem"))}, "utf8");
const cert = readFileSync(${JSON.stringify(join(fixturesDir, "agent6-cert.pem"))}, "utf8");
const crl = readFileSync(${JSON.stringify(crlPath)}, "utf8");
async function main() {
const server = tls.createServer({ key, cert }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = tls.connect({ port: server.address().port, host: "127.0.0.1", checkServerIdentity: () => undefined, crl });
socket.on("error", error => {
console.log("error=" + error.code);
process.exit(0);
});
await once(socket, "secureConnect");
console.log("authorized=" + socket.authorized);
process.exit(0);
}
main().catch(error => {
console.error(error?.code || error?.message || String(error));
process.exit(1);
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(fixturesDir, "ca1-cert.pem") },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stderr is drained but only surfaced on failure (debug builds may warn).
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "error=UNABLE_TO_GET_CRL",
exitCode: 0,
failureDetail: "",
});
});
it("TLSSocket._requestCert follows Node's _init rule", () => {
// Clients always request the peer certificate; servers only when asked.
// Must be decided in the constructor, before a server wrap starts its
// upgrade: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L845-L848
// Like the JSStreamSocket test above, the detached wrappers are not
// destroyed: tearing down a never-connected duplex wrap is its own quirk.
const cases = [
new TLSSocket(new stream.PassThrough()), // client
new TLSSocket(new stream.PassThrough(), { isServer: true }),
new TLSSocket(new stream.PassThrough(), { isServer: true, requestCert: true }),
];
expect(cases.map(s => (s as any)._requestCert)).toEqual([true, false, true]);
});
it("socket.ssl is assignable like Node's plain own property", async () => {
// Node assigns `this.ssl` in _init and nulls it in _destroySSL, so it must
// accept writes; a getter-only accessor would throw in strict mode.
const server = tls.createServer({ ...COMMON_CERT_ }, s => s.end());
let client: TLSSocket | undefined;
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const connected = Promise.withResolvers<void>();
client = tlsConnect(
{ port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: false },
connected.resolve,
);
client.on("error", connected.reject);
await connected.promise;
expect(typeof (client as any).ssl?.verifyError).toBe("function");
(client as any).ssl = null;
expect((client as any).ssl).toBeNull();
} finally {
client?.destroy();
server.close();
}
});
it("rejects a `ca` option that contains no certificates at construction time", () => {
// Deliberately stricter than Node here: Node tolerates an unusable `ca`
// (zero certificates parse) and only fails later at verification with an
// empty trust store; Bun rejects the option itself, so a misconfigured pin
// can never silently fall back to any other roots.
let err: any;
try {
tls.createSecureContext({ ca: "\n" });
} catch (e) {
err = e;
}
expect(err?.message).toBe("Invalid CA");
});
it("a `ca` that parses to zero certificates is an empty pin set, never the default roots", async () => {
// A key PEM passed as `ca` is tolerated (like Node) and adds nothing; the
// resulting empty own store must fail closed instead of falling back to the
// default roots, which NODE_EXTRA_CA_CERTS makes able to verify this chain:
// https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1831
const fixturesDir = join(import.meta.dir, "fixtures");
const script = `
const tls = require("node:tls");
const { readFileSync } = require("node:fs");
const { once } = require("node:events");
const key = readFileSync(${JSON.stringify(join(fixturesDir, "agent6-key.pem"))}, "utf8");
const cert = readFileSync(${JSON.stringify(join(fixturesDir, "agent6-cert.pem"))}, "utf8");
async function main() {
const server = tls.createServer({ key, cert }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = tls.connect({
port: server.address().port,
host: "127.0.0.1",
ca: key,
allowPartialTrustChain: true,
checkServerIdentity: () => undefined,
});
socket.on("error", error => {
console.log("error=" + error.code);
server.close();
});
socket.on("secureConnect", () => {
console.log("secureConnect authorized=" + socket.authorized);
socket.end();
server.close();
});
}
main();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(fixturesDir, "ca1-cert.pem") },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "error=UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
exitCode: 0,
failureDetail: "",
});
});
it("tls.connect({rejectUnauthorized: undefined}) with NODE_TLS_REJECT_UNAUTHORIZED=0 still rejects", async () => {
// Node's spread `{rejectUnauthorized: !allowUnauthorized, ...options}`:
// an explicit own-property `undefined` overrides the env-derived default and
// then coerces to true via `!== false`; only an OMITTED key falls through to
// the env var. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1732-L1781
const script = `
const tls = require("node:tls");
const { once } = require("node:events");
const server = tls.createServer(${JSON.stringify(COMMON_CERT_)}, s => s.end());
server.listen(0, "127.0.0.1");
server.on("listening", () => {
const socket = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: undefined });
socket.on("error", error => {
console.log("error=" + error.code);
socket.destroy();
server.close();
});
socket.on("secureConnect", () => {
console.log("secureConnect authorized=" + socket.authorized);
socket.end();
server.close();
});
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_TLS_REJECT_UNAUTHORIZED: "0" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "error=DEPTH_ZERO_SELF_SIGNED_CERT",
exitCode: 0,
failureDetail: "",
});
});
it("an inherited rejectUnauthorized cannot disable certificate verification", async () => {
// Node merges the user options with an own-property spread, so a polluted
// Object.prototype never reaches the socket and the peer is still verified.
const script = `
Object.prototype.rejectUnauthorized = false;
const tls = require("node:tls");
const server = tls.createServer(${JSON.stringify(COMMON_CERT_)}, s => s.end());
server.listen(0, "127.0.0.1");
server.on("listening", () => {
const socket = tls.connect({ port: server.address().port, host: "127.0.0.1" });
socket.on("error", error => {
console.log("error=" + error.code);
socket.destroy();
server.close();
});
socket.on("secureConnect", () => {
console.log("secureConnect authorized=" + socket.authorized);
socket.end();
server.close();
});
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_TLS_REJECT_UNAUTHORIZED: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "error=DEPTH_ZERO_SELF_SIGNED_CERT",
exitCode: 0,
failureDetail: "",
});
});
it("an inherited checkServerIdentity cannot become the hostname verifier", async () => {
// Node installs its own default before spreading the user options, so a
// polluted Object.prototype never reaches the socket. Trusting the cert makes
// verification succeed, which is the only path that runs the identity check.
const script = `
let called = false;
Object.prototype.checkServerIdentity = () => { called = true; };
const tls = require("node:tls");
const c = ${JSON.stringify(COMMON_CERT_)};
const server = tls.createServer({ key: c.key, cert: c.cert }, s => s.end());
server.listen(0, "127.0.0.1", () => {
const socket = tls.connect({
port: server.address().port,
host: "127.0.0.1",
ca: [c.cert],
servername: "localhost",
});
socket.on("secureConnect", () => {
console.log("polluted=" + called);
socket.end();
server.close();
});
socket.on("error", error => {
console.log("error=" + error.code);
socket.destroy();
server.close();
});
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, NODE_TLS_REJECT_UNAUTHORIZED: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "polluted=false",
exitCode: 0,
failureDetail: "",
});
});
describe("throwing 'secureConnect' listener", () => {
// Node has no try/catch around the handshake-done emits, so a throwing
// listener becomes uncaughtException — never a socket 'error'. Verified
// against node v26.3.0.
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1107
it("becomes uncaughtException, not a socket 'error'", async () => {
const script = `
const tlsMod = require("node:tls");
const state = { uncaught: null, socketError: null };
function finish() {
console.log(JSON.stringify(state));
process.exit(0);
}
process.on("uncaughtException", function onUncaught(err) {
state.uncaught = err.message;
setImmediate(finish);
});
const server = tlsMod.createServer(${JSON.stringify(COMMON_CERT_)}, function onConn() {});
server.listen(0, "127.0.0.1", function onListen() {
const client = tlsMod.connect({
port: server.address().port,
host: "127.0.0.1",
rejectUnauthorized: false,
});
client.on("secureConnect", function onSecureConnect() {
throw new Error("boom-secureConnect");
});
client.on("error", function onError(err) {
state.socketError = err.message;
setImmediate(finish);
});
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim())).toEqual({ uncaught: "boom-secureConnect", socketError: null });
expect(exitCode).toBe(0);
});
});